Every API you ship is a promise. Clients hardcode your URLs, parse your field names, and schedule work around your response shapes. Then requirements arrive: a field must change type, a payment provider forces a new flow, an internal model gets a breaking refactor. How you handle that moment — whether clients experience a smooth migration or a fire drill — comes down to the versioning strategy you chose, probably years earlier.
Versioning is not a feature you add when things break. It is a policy decision that shapes routing, caching, documentation, and client onboarding from day one. This post walks through the two dominant strategies — path-based and header-based versioning, plus the calendar-date variant that a few major platforms have converged on — and then covers the part most teams get wrong: deprecating and retiring old versions gracefully.
Path versioning: the version lives in the URL
The most visible approach puts the version directly in the resource path: /v1/orders, /v2/orders. It is the strategy you see most often in public REST APIs, and its popularity is earned. The version is obvious in logs, in browser devtools, in a curl command someone pastes into a bug report. Routing to different versions is trivial — it is just part of the path. And because the version is part of the cache key that CDNs and reverse proxies already use, cache isolation between versions comes for free.
In Go’s standard library net/http router (with the pattern syntax available since Go 1.22), a versioned API looks like this:
package main
import (
"encoding/json"
"net/http"
)
type OrderV1 struct {
ID string `json:"id"`
Total int `json:"total"` // cents
}
type OrderV2 struct {
ID string `json:"id"`
TotalCents int64 `json:"total_cents"`
Currency string `json:"currency"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
o := OrderV1{ID: r.PathValue("id"), Total: 4200}
writeJSON(w, o)
})
mux.HandleFunc("GET /v2/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
o := OrderV2{ID: r.PathValue("id"), TotalCents: 4200, Currency: "EUR"}
writeJSON(w, o)
})
http.ListenAndServe(":8080", mux)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
The trade-off is that a path version is coarse. It applies to the whole API surface, so a breaking change to one obscure endpoint forces a full v2, duplicating every handler whether or not it changed. Teams that go down this path often end up maintaining parallel handler trees that are 95 percent identical.
Header versioning: the version lives in the negotiation
Header-based versioning keeps URLs stable and moves the version into request metadata. There are two common flavors. The first is a dedicated header, which is what GitHub uses: clients send X-GitHub-Api-Version: 2022-11-28 and the GitHub REST API docs document exactly which calendar version each breaking change belongs to. The second is content negotiation through the Accept header, where the version rides along as a media type parameter — elegant in theory, awkward in practice because many HTTP clients, proxies, and monitoring tools treat Accept as opaque and mangle custom parameters.
Header versioning has real advantages. URLs stay clean and bookmarkable forever. You can version at a finer granularity — one header value can govern one resource family while another governs a different one. And routing by header is a middleware concern, so your handler tree stays single and shared:
package main
import (
"net/http"
"strings"
)
// versionRouter dispatches on a custom version header and stamps
// the resolved version into the request context for handlers to read.
func versionRouter(v1, v2 http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch strings.TrimSpace(r.Header.Get("Api-Version")) {
case "2024-06-01":
v2.ServeHTTP(w, r)
case "", "2023-01-01":
v1.ServeHTTP(w, r)
default:
http.Error(w, "unsupported Api-Version", http.StatusBadRequest)
}
})
}
The costs: the version is invisible in a URL, which makes debugging and log triage harder (“which version produced this response?”). Cache keys need explicit configuration to include the header, or a CDN will happily serve a v2 response to a v1 client. And every SDK, every integration example, every runbook has to explain “set this header” before the first request succeeds — a real onboarding tax. Stripe mitigates this with account-level version pinning: each account is pinned to the API version that was current when the account was created, and upgrades are explicit, per-account actions. That moves the version out of every request but adds state your platform must manage.
Calendar dates beat small integers
If you go with explicit versions, consider calendar dates (2022-11-28, 2024-06-01) instead of v1, v2, v3. Both GitHub and the Kubernetes API conventions lean this way, and the reasoning is practical: a date communicates cadence and age at a glance. Nobody argues about whether the next version should be v7 or v8, and a client pinned to 2023-01-01 visibly telegraphs “this integration is two years stale” without consulting a changelog. Integers feel infinite; dates come with an implicit clock, which is exactly the pressure a healthy API needs.
Deprecation is a protocol, not a blog post
Picking a versioning scheme is the easy half. The half that determines whether clients trust you is retirement. The worst pattern in API maintenance is the silent break — flipping behavior on an unversioned endpoint because “nobody uses that anymore.” The best pattern is machine-readable deprecation, and there are now two standards for it.
RFC 9745 defines the Deprecation response header: a structured-field timestamp declaring that the resource is deprecated as of that date. RFC 8594 defines the Sunset header: an HTTP-date declaring when the resource is expected to become unresponsive. They pair naturally — Deprecation announces “stop building on this,” Sunset announces the deadline. The Link header with rel="successor-version" points clients at the replacement. Together they let an SDK scan responses, log every deprecation it sees, and file tickets automatically:
package main
import (
"net/http"
"strconv"
"time"
)
// deprecate marks a handler as deprecated and advertises its successor.
func deprecate(deprecatedAt time.Time, sunsetAt time.Time, successor string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "@"+strconv.FormatInt(deprecatedAt.Unix(), 10))
if !sunsetAt.IsZero() {
w.Header().Set("Sunset", sunsetAt.UTC().Format(http.TimeFormat))
}
if successor != "" {
w.Header().Set("Link", successor+`; rel="successor-version"`)
}
next.ServeHTTP(w, r)
})
}
After the sunset date passes, do not silently 404. Return 410 Gone with a body that names the successor. A 404 says “this never existed”; a 410 says “this existed, you missed the deadline, and here is where to go” — a categorically better error for the engineer reading it at 2 a.m.:
package main
import (
"encoding/json"
"net/http"
"time"
)
func goneAfter(sunset time.Time, successor string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if time.Now().After(sunset) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusGone)
json.NewEncoder(w).Encode(map[string]string{
"error": "this API version was retired",
"sunset": sunset.UTC().Format(time.RFC3339),
"successor": successor,
})
return
}
next.ServeHTTP(w, r)
})
}
A few operational rules make deprecation work in practice. Emit deprecation headers on every affected response, not just on a documentation page — clients cannot react to what they never see. Log every request that hits a deprecated route with the client identity, because that list is your migration punch list and it tells you when the traffic has actually drained to zero. Announce sunset dates in human channels too — changelogs, email, dashboard banners — but treat the header as the source of truth, since headers reach every client while dashboards reach only the diligent. And give a long, boring runway: quarters, not weeks.
Choosing without regret
For a public API with a diverse client base, path versioning with calendar dates is the lowest-friction choice: it is impossible to misunderstand, caches correctly by default, and debugs well. For a platform with managed SDKs and account state — or an internal API where URLs must stay stable across many services — header versioning with account-level pinning buys finer granularity at the cost of onboarding friction. Whichever you pick, wire up Deprecation and Sunset headers from the start. Deprecation headers cost an afternoon to add and are nearly impossible to retrofit once clients exist, because the clients that need them are the ones you can no longer change.