A production-capable HTTP server in Go needs zero external dependencies to get started.
The simplest possible server
package main
import (
"fmt"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, world!")
}
func main() {
http.HandleFunc("/hello", helloHandler)
http.ListenAndServe(":8080", nil)
}
nil as the second argument to ListenAndServe means "use the default ServeMux," the package-level router http.HandleFunc registers against implicitly.
Method-based routing and path parameters (Go 1.22+)
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "user id: %s", id)
})
mux.HandleFunc("POST /users", createUserHandler)
http.ListenAndServe(":8080", mux)
Before Go 1.22, the standard ServeMux couldn't route by HTTP method or extract path parameters, which is why third-party routers like gorilla/mux or chi were so popular. Go 1.22 added both directly to the standard library's mux.
The http.Handler interface underneath it all
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
http.HandleFunc is just a small adapter (http.HandlerFunc) that lets a plain function satisfy this interface. Anything implementing ServeHTTP directly, like a custom struct with configuration baked in, works equally well as a handler.
Setting up a real server, not just ListenAndServe
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
srv.ListenAndServe()
Using an explicit http.Server struct, rather than the package-level http.ListenAndServe shortcut, is the production-appropriate pattern, since it lets you set timeouts and supports graceful shutdown, covered in a later question.
Related Resources
Request cancellation is one of the most practically important uses of context.Context in real Go services.
The request's built-in context
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // automatically cancelled if the client disconnects
result, err := slowDatabaseQuery(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return // client already gone, don't bother writing a response
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
If the client closes the connection (say, a user navigates away mid-request), r.Context()'s Done() channel closes automatically. Any downstream function that respects context cancellation, like a database driver checking ctx.Done(), can stop its work immediately instead of finishing a query nobody will ever read the result of.
Bounding a request with a timeout
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
result, err := slowDatabaseQuery(ctx)
if err != nil {
http.Error(w, "request timed out or failed", http.StatusGatewayTimeout)
return
}
json.NewEncoder(w).Encode(result)
}
This caps how long slowDatabaseQuery is allowed to run, regardless of how slow the underlying database happens to be at that moment, protecting the handler (and the server's limited resources) from a single slow dependency.
Why this cancellation is automatic
Go's HTTP server monitors the underlying connection and cancels the request's context the moment it detects the client is gone. Your handler never needs to poll for this explicitly. Passing that same context down through every layer of your call stack — database calls, outbound HTTP requests, anything accepting a context.Context — is what makes this cancellation take effect throughout the whole request, not just at the handler's top level.
Related Resources
encoding/json is reflection-driven, which is convenient but has a few sharp edges worth knowing.
Basic marshal and unmarshal
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
u := User{ID: 1, Name: "Alice"}
b, _ := json.Marshal(u)
fmt.Println(string(b)) // {"id":1,"name":"Alice"}
var u2 User
json.Unmarshal([]byte(`{"id":2,"name":"Bob"}`), &u2)
fmt.Println(u2.Name) // "Bob"
Streaming directly to/from HTTP, without a []byte buffer
func getUserHandler(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "Alice"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user) // writes directly to the response, no intermediate []byte
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// ... use u ...
}
Encoder/Decoder avoid allocating a full intermediate []byte for the whole payload, which matters for large request/response bodies.
Unexported fields are invisible to JSON
type Account struct {
Balance float64 `json:"balance"`
secret string // unexported: never appears in Marshal output, never populated by Unmarshal
}
Reflection, which encoding/json relies on entirely, can't see unexported fields — this is a common source of confusion when a field mysteriously never shows up in serialized output.
A common gotcha: unmarshaling into an interface
var data map[string]interface{}
json.Unmarshal([]byte(`{"count": 5}`), &data)
fmt.Printf("%T\n", data["count"]) // float64, not int!
encoding/json always decodes JSON numbers into float64 when the target type is interface{}, since JSON itself doesn't distinguish integer and floating-point number types. This surprises people expecting an int back.
Related Resources
Go 1.22's ServeMux improvements genuinely changed the calculus for whether a project needs a third-party router at all.
What ServeMux can do natively since Go 1.22
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
mux.HandleFunc("DELETE /users/{id}", deleteUserHandler)
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extracts the {id} segment
// ...
}
Method-specific routes and path parameters were the two biggest reasons people historically reached for gorilla/mux or chi — both are now built in.
What third-party routers still add
// chi example: route groups and middleware chains
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/users/{id}", getUserHandler)
r.Post("/users", createUserHandler)
})
Grouping routes under a shared prefix with a shared middleware stack (r.Route("/api/v1", ...)) is still noticeably more convenient in a router like chi than composing it manually with the standard library's mux.
Composing middleware manually with the stdlib mux
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 took %v", r.Method, r.URL.Path, time.Since(start))
})
}
handler := loggingMiddleware(mux)
http.ListenAndServe(":8080", handler)
This works fine for a single, global middleware chain, but grouping different middleware per route group takes more manual wiring than a router with built-in support for it.
The practical decision
For a small service with a handful of routes and simple middleware needs, the standard library alone is genuinely sufficient today. For a larger API with many route groups, each needing different middleware — auth on some routes, not others — a router like chi still reduces real boilerplate. It does this without pulling in a full web framework's opinions about everything else.
Related Resources
Go has no special middleware syntax. It's just functions wrapping http.Handler values, which is simple enough to implement from scratch with no framework.
The core pattern
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // "after" code runs once this returns
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
An auth middleware that can short-circuit the chain
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !isValidToken(token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // never calls next.ServeHTTP — the chain stops here
}
next.ServeHTTP(w, r)
})
}
Not calling next.ServeHTTP is how a middleware rejects a request early, without any special "abort" mechanism needed.
Chaining several middleware together
finalHandler := http.HandlerFunc(getUserHandler)
wrapped := loggingMiddleware(authMiddleware(finalHandler))
http.ListenAndServe(":8080", wrapped)
Execution order: loggingMiddleware's "before" code, then authMiddleware's "before" code, then finalHandler, then authMiddleware's "after" code (if any), then loggingMiddleware's "after" code — nested exactly like the function calls suggest.
A small helper to make chaining more readable
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
}
wrapped := chain(finalHandler, loggingMiddleware, authMiddleware)
This is a common small utility many projects write themselves, rather than pulling in a router library purely for middleware chaining syntax.