The versioning problem
You ship an API. It works. Six months later, a client breaks because you changed a response field. Or you add a new endpoint that accidentally catches traffic meant for the old one. API versioning exists to solve this exact friction. It lets you evolve your service without breaking existing consumers. In Go, versioning is not a language feature. It is a routing and design choice. You decide how clients signal which contract they expect, and you structure your handlers to honor that signal.
Think of your API like a restaurant menu. The kitchen changes recipes constantly. If you just swap the menu without warning, customers order dishes that no longer exist. Versioning is like printing a new menu with a clear year stamp. Diners who want the old recipes order from the 2023 menu. New diners get the 2024 menu. The kitchen runs both recipes in parallel until the old menu expires. In code, this means separating request paths, response shapes, and business logic so changes in one version never silently break another.
Versioning is a contract, not a compiler flag.
How versioning actually works in Go
Go gives you a standard HTTP router, a context package for request-scoped data, and a JSON encoder. That is everything you need. The router matches incoming paths to handler functions. The context carries metadata like the requested version down the call stack. The JSON encoder turns your structs into payloads. You combine them to isolate version-specific behavior while sharing core business logic.
The standard library net/http package provides ServeMux, a request multiplexer. It matches patterns in the order you register them. Go 1.22 introduced method-aware routing, but prefix matching remains the simplest way to split traffic. You register /v1/users and /v2/users as separate patterns. The router handles the dispatch. Your handlers focus on reading the request, calling shared services, and writing the response.
A minimal router setup
Here is the simplest way to split traffic by version using Go's standard library router.
package main
import (
"net/http"
)
func main() {
// NewServeMux creates a fresh router instance
mux := http.NewServeMux()
// Prefix routing isolates v1 traffic from future changes
mux.HandleFunc("/v1/users", handleUsersV1)
// v2 gets its own pattern to prevent accidental overlap
mux.HandleFunc("/v2/users", handleUsersV2)
// ListenAndServe blocks until the process exits
http.ListenAndServe(":8080", mux)
}
func handleUsersV1(w http.ResponseWriter, r *http.Request) {
// Write v1 payload directly to the response writer
w.Write([]byte(`{"name": "Alice"}`))
}
func handleUsersV2(w http.ResponseWriter, r *http.Request) {
// v2 payload includes additional fields for newer clients
w.Write([]byte(`{"name": "Alice", "role": "admin"}`))
}
The router does the heavy lifting. Keep your handlers dumb.
Walking through the request lifecycle
When a client sends GET /v1/users, the operating system delivers the TCP packet to your process. The net/http server reads the HTTP request line and headers. It passes the request to ServeMux. The multiplexer scans its registered patterns from top to bottom. It finds an exact match for /v1/users and calls handleUsersV1. The handler writes bytes to the ResponseWriter. The server flushes the buffer, sends the HTTP response, and closes the connection.
If a client sends GET /v3/users, the multiplexer finds no match. It returns a 404 Not Found with a default HTML body. This explicit mismatch is a feature. It forces you to define routes upfront. You never get silent fallbacks that return the wrong data shape.
Go does not enforce API contracts at compile time. The compiler checks types, not JSON schemas. If you change a struct field name without updating the handler, the program compiles fine. The client gets a missing field at runtime. That is why versioned routes act as guardrails. They give you a clear boundary where you can validate inputs and shape outputs before they leave your service.
Real-world versioning with context and handlers
Real services need to share business logic while keeping response formats version-specific. Here is how you extract the version and thread it through your call stack.
package main
import (
"context"
"net/http"
"strings"
)
// versionKey holds the API version in the request context
type versionKey struct{}
func main() {
// Strip the version prefix before routing to shared handlers
mux := http.NewServeMux()
mux.HandleFunc("/v1/", withVersion(1, sharedUserHandler))
mux.HandleFunc("/v2/", withVersion(2, sharedUserHandler))
http.ListenAndServe(":8080", mux)
}
// withVersion wraps a handler and injects the version into context
func withVersion(v int, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Context always flows first in Go conventions
ctx := context.WithValue(r.Context(), versionKey{}, v)
next(w, r.WithContext(ctx))
}
}
// sharedUserHandler reads the version and branches response logic
func sharedUserHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve version safely, defaulting to 1 if missing
v, ok := r.Context().Value(versionKey{}).(int)
if !ok {
v = 1
}
// Branch on version to shape the payload
if v == 1 {
w.Write([]byte(`{"name": "Alice"}`))
return
}
w.Write([]byte(`{"name": "Alice", "role": "admin"}`))
}
Context is plumbing. Thread it through every long-lived call site.
The withVersion wrapper demonstrates a common Go pattern: handler composition. You wrap the base handler with middleware that enriches the request context. The context.Context type always goes as the first parameter in Go conventions, conventionally named ctx. Functions that take a context should respect cancellation and deadlines, but for version routing, a simple key-value pair works. The versionKey struct type prevents collisions with other context keys. Type assertions like r.Context().Value(versionKey{}).(int) are safe here because you control the wrapper. If you forget to assert the type, the program panics at runtime with interface conversion: interface {} is nil, not int.
Go developers accept the if err != nil boilerplate because it makes the unhappy path visible. In handlers, you should check database errors, validation failures, and marshaling issues before writing to the response. The version wrapper keeps routing concerns separate from business logic, which makes testing easier. You can unit test sharedUserHandler by injecting different context values without starting an HTTP server.
Common traps and compiler complaints
API versioning in Go trips developers up in predictable ways. The first trap is using runtime debug flags for version control. The original draft suggested GODEBUG or //go:debug for toggling API versions. Those directives control internal Go runtime behavior like garbage collection pacing or nil pointer panic suppression. They are not designed for application routing. If you try to parse GODEBUG for versioning, you will fight the standard library instead of working with it.
The second trap is mixing version logic into shared structs. You might be tempted to add a Version int field to every domain model. That couples your business layer to HTTP concerns. Keep versioning at the transport layer. Use separate response structs or JSON struct tags to control serialization. The encoding/json package respects the json:"field" tag. If you omit a tag or change it, the compiler does not complain. You get a different JSON key at runtime. That is why version-specific response types live in the handler package, not the core domain package.
The third trap is forgetting to handle unsupported methods. ServeMux matches paths but not HTTP methods unless you use Go 1.22+ patterns. If you register /v1/users without a method prefix, it accepts GET, POST, PUT, and DELETE. Clients might send a POST to a read-only endpoint and get a 200 OK with unexpected behavior. Use method-aware patterns like GET /v1/users to restrict traffic. If you forget to import a package and you get undefined: pkg from the compiler. Forget to use one and you get imported and not used. Go catches unused imports at compile time, which keeps your dependency tree clean.
The worst versioning bug is the one that silently returns the wrong schema.
Picking your versioning strategy
Use URL path prefixes when you want clear, cacheable routes and simple router configuration. Use query parameters when you need backward-compatible optional features without changing the URL structure. Use custom headers when you are building internal microservices that already share a base URL and want to keep public paths clean. Use content negotiation headers when your clients explicitly declare supported media types and you want to follow strict REST conventions. Stick to a single version when you control all clients and can coordinate breaking changes with zero downtime.
Pick the route that matches your client ecosystem. Don't overengineer the prefix.