Go Web APIs with net/http: Routing, Middleware, Context, JSON and Database Access
Key takeaways
Go's standard library is powerful enough to build production web APIs without a framework. This guide covers routing, middleware, database integration, and deployment — from first endpoint to production.
Go’s standard library ships a production-capable HTTP server. This guide builds a complete REST API from scratch — routing, middleware, JSON, database, auth — then covers deployment.
Go’s pitch for web backends is unusual for a compiled, statically-typed language: you genuinely don’t need a framework to reach production. net/http in the standard library already handles routing (as of Go 1.22), TLS, timeouts, and concurrent connection handling well enough that companies like Cloudflare, Twitch, and Uber run substantial production traffic through code that’s much closer to “plain net/http plus a thin routing layer” than “built on a heavyweight framework.” That’s a genuinely different default than Node.js (where Express or Fastify is close to mandatory) or Python (Django/FastAPI) — Go’s standard library was designed with networked services as a first-class use case from day one, not bolted on after the fact.
Setup
go mod init github.com/yourname/myapi
No external dependencies required for a basic server.
Basic HTTP Server
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") // Go 1.22+
fmt.Fprintf(w, "Hello, %s!\n", name)
})
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Go 1.22 added method and path parameter support to ServeMux — no router library needed for most APIs. This mattered enough to reshape the ecosystem: before 1.22, plain net/http couldn’t distinguish GET /users/1 from POST /users/1 or extract 1 as a path parameter without manual string parsing, which is exactly the gap third-party routers like chi and gorilla/mux existed to fill. Reach for one of those routers now mainly when you need regex path matching, route groups with shared middleware, or subrouter mounting — for a REST API with a handful of resources, ServeMux’s "GET /path/{param}" pattern syntax covers the common case with zero dependencies.
JSON Request and Response
type CreateUserRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
type UserResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
if req.Name == "" || req.Email == "" {
writeError(w, http.StatusBadRequest, "name and email are required")
return
}
user := UserResponse{ID: 1, Name: req.Name, Email: req.Email}
writeJSON(w, http.StatusCreated, user)
}
The validation here is intentionally minimal — checking for empty strings — and that’s worth flagging rather than glossing over: Go’s standard library has no built-in request-body validation beyond what you write by hand, unlike frameworks with declarative validation decorators. The validate:"required,email" struct tags shown are inert unless you pair them with a validation library like go-playground/validator that actually reads and enforces them; writing the tag alone does nothing. For anything beyond a couple of required-field checks, pulling in a validation library is the pragmatic choice — hand-rolling if field == "" checks for every field of every request type scales badly once your API has more than a few endpoints.
Middleware
Middleware wraps http.Handler — chain them for logging, auth, CORS, etc.
// Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
// Auth middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
writeError(w, http.StatusUnauthorized, "missing token")
return
}
// validate token...
next.ServeHTTP(w, r)
})
}
// CORS middleware
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// Chain middleware
func chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
// Usage
mux.Handle("/api/", chain(apiHandler, loggingMiddleware, corsMiddleware, authMiddleware))
The func(http.Handler) http.Handler shape is the entirety of Go’s middleware convention — there’s no special middleware type or interface beyond “a function that takes a handler and returns a wrapping handler.” That simplicity is deliberate and it’s why Go middleware composes so cleanly with the standard library’s own http.Handler interface: any of these functions works with net/http directly, with chi, with gorilla/mux, or with no router at all, because they’re not tied to any framework’s specific middleware API. The one gotcha worth knowing: chain() applies middleware in the order it’s listed, but because each wraps the next, execution at request time actually happens in that same listed order (loggingMiddleware first, then corsMiddleware, then authMiddleware) — get the list backwards and, say, your auth check runs before CORS preflight handling, which breaks browser preflight OPTIONS requests that shouldn’t need a token.
Context
Use context.Context to pass request-scoped values and handle cancellation:
type contextKey string
const userKey contextKey = "user"
// Set in middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := validateToken(r.Header.Get("Authorization"))
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Read in handler
func profileHandler(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userKey).(*User)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
writeJSON(w, http.StatusOK, user)
}
contextKey being its own named string type rather than a plain string isn’t decoration — it’s working around a real footgun in context.WithValue. Context values are looked up by interface equality, and two different packages both using the plain string "user" as a key would silently collide, with one package’s context.WithValue(ctx, "user", ...) overwriting or being shadowed by another’s. Defining an unexported type per package (type contextKey string) means Go’s type system itself prevents that collision — a contextKey("user") from your package can never equal a contextKey("user") from a different package’s own private type, even though they print the same. This is the standard, and really the only safe, way to use context values for anything beyond a single, tightly-scoped module.
Database with GORM
go get gorm.io/gorm gorm.io/driver/postgres
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type User struct {
gorm.Model
Name string `gorm:"not null"`
Email string `gorm:"unique;not null"`
}
func initDB() (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable",
os.Getenv("DB_HOST"),
os.Getenv("DB_USER"),
os.Getenv("DB_PASS"),
os.Getenv("DB_NAME"),
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
db.AutoMigrate(&User{})
return db, nil
}
// CRUD
db.Create(&user)
db.First(&user, id)
db.Where("email = ?", email).First(&user)
db.Save(&user)
db.Delete(&user, id)
GORM trades some of Go’s usual explicitness for productivity, and it’s worth knowing where that line is. db.AutoMigrate(&User{}) will create or alter tables to match your struct automatically, which is genuinely convenient in development but risky to run unattended against a production database — it can add columns and indexes but won’t always do the safe, reversible thing for column type changes or renames, so most teams that start with AutoMigrate end up switching to a dedicated migration tool (like golang-migrate or atlas) once the schema needs to evolve carefully. If you want closer-to-the-metal control from day one — explicit SQL, no magic struct-to-table mapping — sqlx over the standard database/sql package is the usual alternative, at the cost of writing your own scan/mapping code that GORM would otherwise generate.
Dependency injection via struct
type UserHandler struct {
db *gorm.DB
}
func (h *UserHandler) GetAll(w http.ResponseWriter, r *http.Request) {
var users []User
h.db.Find(&users)
writeJSON(w, http.StatusOK, users)
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var user User
json.NewDecoder(r.Body).Decode(&user)
h.db.Create(&user)
writeJSON(w, http.StatusCreated, user)
}
// Wire up
h := &UserHandler{db: db}
mux.HandleFunc("GET /users", h.GetAll)
mux.HandleFunc("POST /users", h.Create)
This struct-with-methods pattern is Go’s idiomatic substitute for the dependency-injection frameworks other languages use — there’s no @Injectable() decorator or DI container resolving a dependency graph at startup; you just construct UserHandler{db: db} explicitly and pass it where it’s needed. That explicitness is a deliberate Go design value (nothing happens by “magic” or reflection you can’t trace by reading the code), and it scales fine for small-to-medium services. Where it gets unwieldy is when you have a dozen handlers all needing the same five or six dependencies (db, logger, cache, config…) — at that point, most Go codebases introduce a shared “app” or “server” struct holding all the common dependencies once, rather than threading each one through every handler’s constructor individually.
JWT Authentication
go get github.com/golang-jwt/jwt/v5
import "github.com/golang-jwt/jwt/v5"
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
func generateToken(userID uint) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
}
func validateToken(tokenStr string) (*jwt.MapClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &jwt.MapClaims{},
func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return jwtSecret, nil
},
)
if err != nil || !token.Valid {
return nil, err
}
claims, _ := token.Claims.(*jwt.MapClaims)
return claims, nil
}
The type assertion inside the parser callback — t.Method.(*jwt.SigningMethodHMAC) — is doing real security work, not just type-checking for its own sake. JWTs carry their signing algorithm in the token header, and if you skip this check and just trust whatever algorithm the token claims to use, a known class of attack becomes possible: an attacker crafts a token specifying the none algorithm, or (in libraries vulnerable to this specific confusion) switches from RS256 to HS256 and signs the forged token using your public RSA key as if it were an HMAC secret. Explicitly asserting the signing method matches what you expect, before trusting anything else about the token, is the standard mitigation — this isn’t Go-specific defensive boilerplate, it’s the same rule every JWT library’s security advice repeats.
Error Handling Pattern
type AppError struct {
Code int
Message string
Err error
}
func (e *AppError) Error() string { return e.Message }
type HandlerFunc func(w http.ResponseWriter, r *http.Request) error
func handle(fn HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
writeError(w, appErr.Code, appErr.Message)
} else {
log.Printf("internal error: %v", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
}
}
// Usage — return errors instead of writing responses inline
mux.HandleFunc("GET /users/{id}", handle(func(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
return &AppError{Code: 400, Message: "invalid id"}
}
var user User
if result := db.First(&user, id); result.Error != nil {
return &AppError{Code: 404, Message: "user not found"}
}
return writeJSON(w, 200, user)
}))
This pattern exists because Go’s standard http.HandlerFunc signature has no return value — handlers write directly to the http.ResponseWriter and communicate failure by convention (calling writeError and then returning), which means it’s entirely possible to forget the return after an error path and fall through into writing a second, conflicting response. Wrapping handlers in handle() and giving them the signature func(w, r) error moves error handling to one central place: every handler just returns an *AppError (or any error) instead of manually calling writeError and remembering to bail out, and handle() guarantees exactly one response gets written per request. It’s a small amount of boilerplate that eliminates an entire category of “wrote to the response twice” bugs that are otherwise easy to introduce as handlers grow more branches.
Graceful Shutdown
func main() {
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Println("Starting on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
}
Skip this and a rolling deploy or pod restart becomes a source of dropped requests: without graceful shutdown, SIGTERM kills the process immediately, severing any in-flight requests mid-response — a real problem for anything running behind a load balancer or in Kubernetes, where deployments routinely send SIGTERM to old instances while new ones spin up. server.Shutdown(ctx) instead stops accepting new connections immediately but lets requests already being handled finish, up to the timeout passed in ctx (30 seconds here) — after which it gives up and closes remaining connections anyway, which is why the timeout should be a little longer than your slowest expected request, not left unbounded.
Docker Deployment
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Final image is ~10MB with no runtime dependencies.
The two-stage build (golang:1.22-alpine to compile, FROM scratch for the final image) is what gets you there, and each part matters. CGO_ENABLED=0 disables cgo, forcing a fully static binary with no dependency on the system’s C library — without it, the binary links dynamically against glibc and simply won’t run in a scratch image, which has no C library at all. FROM scratch then means the final image is only your binary — no shell, no package manager, no OS utilities, nothing an attacker could use if they found a way to execute arbitrary commands inside the container. That minimalism is also why Go containers are unusually well-suited to distroless or scratch-based deployment compared to Node.js or Python, whose runtimes need their interpreter, standard library, and often a full OS userland shipped alongside the application code.