Every API that survives long enough faces the same moment: a requirement lands that cannot be expressed as an additive change. A field needs a different meaning, a validation rule needs to get stricter, a money amount needs to stop being a float. Somewhere out there, clients you have never seen are parsing your responses with assumptions you no longer share. Versioning is how you change the contract anyway — and most of the work is not choosing where to put the version number, but deciding what “breaking” means and managing the exit of the old contract.
Here we cover what actually matters in practice: which changes are truly breaking, the trade-offs between path, header, and date-based versioning, the deprecation lifecycle that keeps old clients alive while they migrate, and working Go code for version routing, deprecation headers, and a contract test that pins the old behavior against silent drift.
What counts as breaking
A change is breaking if it can make a previously working client fail, and the list is longer than most teams expect. Removing a response field is the obvious case, but renaming a field is removal plus addition. Changing a field’s type — total from float dollars to integer cents — breaks every client that does arithmetic on it. Making a formerly optional request field required breaks clients that never sent it. Tightening validation on an existing endpoint breaks clients relying on the loose behavior, and changing an error format or status codes can break clients that branch on them.
Additive changes are usually safe: new optional request fields, new response fields, new endpoints, new enum values only if clients were never told the enum was closed. The discipline that makes evolution cheap is boring: make no change that a reasonable consumer of the old documentation could not tolerate. Strict parsers, exhaustive switch statements over enum values, and assumptions like “this list is never empty” are all breakage vectors, and your changelog will not save anyone who relied on them.
When you cannot avoid a breaking change, you have exactly two options: negotiate with every client at once (feasible for internal services with two consumers, impossible otherwise), or run two contracts side by side for a while. Versioning is the machinery for the second option.
Where the version number goes
Three schemes dominate, and the differences are more operational than aesthetic.
Path versioning (/v1/orders, /v2/orders) is the most common. It is trivially visible in logs, dashboards, access control rules, and load balancer config; caching layers treat the versions as distinct resources automatically; and a browser can hit it. The cost is that the version is welded into every URL, so clients change URIs when they migrate, and you either duplicate routes or write prefix-routing logic. That cost is real but modest, and the debuggability usually wins, which is why it is the pragmatic default for REST APIs.
Header versioning puts the version in a request header — either a dedicated one or a parameterized media type in Accept. URLs stay stable forever, which is theoretically cleaner: the resource is the same, only the representation changes. In practice it is harder to see and test — you cannot paste a versioned URL into a browser, cache keys must include the header, and a missing header silently means “default version,” which turns into a support ticket generator. It works best when a gateway or client library owns header injection, not individual developers.
Date-based versioning (a calendar date as the version identifier, selected by header) inverts the usual numbered scheme: instead of incrementing on every breaking change, the API publishes dated snapshots of the entire contract, and clients pin one. Migration windows become calendar-driven and predictable, and there is no sprawl of v17 endpoints. The price is that you maintain a real compatibility layer between snapshots, and clients on old dates accumulate like sediment.
There is also the option of refusing to version at all: some large platform APIs ship no v2 and promise that everything is additive, with deprecated fields supported indefinitely. That is legitimate, but it converts every future design constraint into a permanent one, and most teams underestimate how long “forever” is in API terms. Pick a versioning scheme while the API is young; retrofitting one onto clients that hardcode unversioned paths is itself a breaking change.
Version routing in Go
Path versioning needs no framework. Each version gets its own mux, and a thin wrapper strips the version prefix and dispatches. The Go 1.22+ ServeMux supports method and wildcard patterns, which is enough:
package main
import (
"log"
"net/http"
)
func main() {
v1 := http.NewServeMux()
v1.HandleFunc("GET /orders/{id}", getOrderV1)
v2 := http.NewServeMux()
v2.HandleFunc("GET /orders/{id}", getOrderV2)
mux := http.NewServeMux()
mux.Handle("/v1/", stripAnd(v1, "/v1"))
mux.Handle("/v2/", stripAnd(v2, "/v2"))
log.Fatal(http.ListenAndServe(":8080", mux))
}
func stripAnd(h http.Handler, prefix string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.StripPrefix(prefix, h).ServeHTTP(w, r)
})
}
func getOrderV1(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"` + r.PathValue("id") + `","total_usd":4200}`))
}
func getOrderV2(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"` + r.PathValue("id") + `","total":420000,"currency":"USD"}`))
}
The important detail is that getOrderV1 is a complete, frozen implementation — not a flag-checked branch inside a shared handler. Shared handler code with if version == "v1" scattered through it accretes until nobody can tell what each version promises. Duplicating the small stuff and extracting only genuinely shared domain logic keeps each version readable and independently testable. The response bodies here also show a deliberate breaking change: v1 reported total_usd as a dollar amount, v2 reports integer cents with an explicit currency, because a currency-less number is how rounding bugs ship.
The deprecation lifecycle
Publishing v2 is the easy half. The hard half is retiring v1 without a support incident, and that is a communication problem with a mechanical component. Kubernetes is a useful reference point for the policy shape: deprecated API versions are announced, keep functioning across multiple releases with client warnings, and are only removed by a new release — never patched out from under a running cluster. The deprecation migration guide documents years of lead time per API group, and even then removals cause migration scrambles. Give your API more warning than you think it needs.
On the mechanical side, HTTP has a standard field for the retirement itself. The Sunset header (RFC 8594) carries an HTTP-date after which a resource is expected to become unresponsive. Clients are told to treat it as a hint, and the RFC is explicit that it marks the decommissioning stage — while the API still works but is merely not recommended, other mechanisms apply. Pair it with a Link header pointing at your migration documentation using the sunset link relation, and respond with 410 Gone (not 404) after the date passes, so clients can distinguish “retired” from “never existed.”
package main
import (
"net/http"
"time"
)
// sunsetAt annotates every response from a version approaching retirement.
func sunsetAt(retired time.Time, docs string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Sunset", retired.UTC().Format(http.TimeFormat))
w.Header().Set("Link", `<`+docs+`>; rel="sunset"; type="text/html"`)
next.ServeHTTP(w, r)
})
}
}
Wrap the v1 mux with this middleware and every legacy response carries its own expiration notice. The other half is observability on your side: emit a metric labeled by version on every request, and you get an accurate picture of remaining v1 traffic — including the long tail of forgotten cron jobs and partner integrations that surface only after you set the Sunset date. Decide the retirement cutoff from that traffic curve, not from a calendar promise.
Pinning the old contract with a test
The subtlest failure mode of dual-version operation is drift: someone “cleans up” shared response-building code and v1’s output changes shape with no test failing. A contract test that pins the old response exactly — treating it as frozen, not just “still works” — catches this class of regression for the cost of one test file:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestV1OrderShapeUnchanged(t *testing.T) {
v1 := http.NewServeMux()
v1.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"` + r.PathValue("id") + `","total_usd":4200}`))
})
srv := httptest.NewServer(http.StripPrefix("/v1", v1))
defer srv.Close()
resp, err := http.Get(srv.URL + "/v1/orders/42")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var body struct {
ID string `json:"id"`
TotalUSD int64 `json:"total_usd"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode failed: %v", err)
}
if body.ID != "42" || body.TotalUSD != 4200 {
t.Fatalf("v1 contract changed: %+v", body)
}
}
This is the single-consumer version of consumer-driven contract testing, where each client publishes the interactions it depends on and your CI verifies the provider against all of them. If your v1 audience includes external partners, that model scales the same idea: the contracts, not your assumptions, define what must keep working until the Sunset date.
Wrapping up
The version number is the least interesting part of API versioning. What keeps a live API healthy is a sharp definition of breaking change, a routing structure where old versions stay complete and frozen, deprecation signals (Sunset, Link, version-tagged metrics) that let clients and your own dashboards drive the retirement, and contract tests that make drift impossible to merge. Start with path versioning and additive-only discipline, add header-based versioning only if a real need appears, and treat every breaking change as the start of a deprecation project rather than a deploy.