One Tuesday your API’s most popular endpoint works fine. Two Tuesdays later, a mobile app that has been calling it since 2023 starts crashing — nobody changed a line of code on either side. The endpoint silently changed its response shape, the old clients never got a chance to adapt, and now you are shipping an emergency patch to users who have not updated their app in eight months.
Every API that other people build against has a contract, and every contract needs a story about what happens when it must change. API versioning is that story. In this post we walk through how to expose versions in URLs, headers, and media types; how to deprecate a version without breaking the clients that depend on it; and the implementation techniques — version-negotiating middleware in Go, compatibility testing, per-version documentation — that turn deprecation from a fire drill into a calendar entry.
Where to Put the Version: Three Options, One Default
There are exactly three common ways to signal which version of an API a client wants, and each has trade-offs worth understanding before you default to the popular one.
Path versioning (/v1/orders) is the most visible option. The version is right there in the URL, trivially routable, trivially cached — CDNs treat /v1/ and /v2/ as different resources because they are different paths — and trivially logged. The cost is aesthetic and architectural: the version is not semantically part of the resource, so purists object, and every route registration now carries version machinery. The practical objection is that changing versions means changing URLs, which means clients re-resolve, re-authorize, and re-cache everything.
Header versioning (X-API-Version: 2026-10-01 or API-Version: 3) keeps URLs stable across versions and makes version selection explicit and programmable. The costs are subtler: the version is invisible in logs and browser address bars, caching layers must include the header in cache keys or serve the wrong payload to the wrong clients, and a client that forgets the header needs a well-defined default behavior.
Media type versioning bakes the version into the Accept header (Accept: application/vnd.myapp.v3+json), which is arguably the most HTTP-correct approach — content negotiation is what the header is for. It is also the least used in practice, because custom media types confuse client developers, complicate tooling, and make quick manual testing with curl a chore.
The pragmatic default for public APIs is path versioning, precisely because visibility beats elegance: support forums, error reports, and access logs all become self-describing. Header-based date versions (the style popularized by major payments APIs — though plenty of engineering teams have converged on it independently) are the strongest choice for APIs with rapid, calendar-driven release cadence, because a date communicates at a glance how old a pinned version is. Whatever you pick, pick once: migrating between versioning schemes later is itself a breaking change, and it breaks your most important clients — the ones that already exist.
What Counts as Breaking
Half of all versioning pain comes from teams whose definition of “breaking” is too narrow. A breaking change is anything that can cause a correct existing client to become incorrect. The obvious cases:
- Removing a field from a response
- Renaming a field (this is removal plus addition)
- Changing a field’s type — integer to string, string to enum
- Adding a new required request field
- Changing a URL structure or an error code’s meaning
- Removing or renaming an endpoint
The cases teams miss are more interesting. Adding a new field to a response is usually safe — until a client built with a strict deserializer (fail on unknown properties is a common default in Java and .NET stacks) starts throwing on your innocuous addition. Adding a value to an enum is breaking for the same reason: any client with an exhaustive switch statement over enum values now has an unhandled case. Loosening validation — say, accepting a previously rejected string format — breaks clients that relied on the server rejecting it as a de facto error signal. Even a change from 201 to 202 as a success code breaks clients checking response.status === 201.
The working rule: clients are adapted to what you return, not to what you document. Anything a correct client could have observed and depended on is contract. This is why versionless APIs work only under strict discipline — the moment a team adds a field without checking every documented client SDK’s deserialization behavior, the “no breaking changes ever” promise is already broken, just not yet detected.
Version-Negotiating Middleware in Go
For a Go service, the cleanest implementation pattern keeps version negotiation in middleware and version-specific behavior in handlers. The middleware resolves the requested version once — from URL, header, or query parameter — stores it on the request context, and routes or rejects accordingly.
package api
import (
"context"
"net/http"
"regexp"
"strconv"
)
type versionKey struct{}
// Supported major versions, newest first.
var supportedVersions = []int{3, 2}
var versionPattern = regexp.MustCompile(`^/v(\d+)/`)
func VersionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
version := 0
if m := versionPattern.FindStringSubmatch(r.URL.Path); m != nil {
version, _ = strconv.Atoi(m[1])
}
// Fall back to the API-Version header for non-versioned paths.
if version == 0 {
if h := r.Header.Get("API-Version"); h != "" {
version, _ = strconv.Atoi(h)
}
}
if version == 0 {
version = supportedVersions[0] // default: latest
}
ok := false
for _, v := range supportedVersions {
if v == version {
ok = true
break
}
}
if !ok {
writeError(w, http.StatusBadRequest,
"unsupported version "+strconv.Itoa(version))
return
}
if deprecatedUntil, deprecated := deprecationDate(version); deprecated {
// RFC 9745: Deprecation carries a timestamp (@).
w.Header().Set("Deprecation", "@1785542400") // 2026-08-01, matches deprecationDate below
w.Header().Set("Sunset", deprecatedUntil) // RFC 8594, e.g. "Sat, 01 Aug 2026 00:00:00 GMT"
w.Header().Set("Link", `<https://api.example.com/docs/migrations/v3>; rel="deprecation"`)
}
ctx := context.WithValue(r.Context(), versionKey{}, version)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func VersionFrom(ctx context.Context) int {
return ctx.Value(versionKey{}).(int)
}
func deprecationDate(version int) (string, bool) {
if version == 2 {
return "Sat, 01 Aug 2026 00:00:00 GMT", true
}
return "", false
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(`{"error":` + strconv.Quote(msg) + `}`))
}
Handlers then branch on the resolved version only where the contract actually differs — usually a minority of endpoints:
func handleGetOrder(w http.ResponseWriter, r *http.Request) {
order, err := store.Order(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "order not found")
return
}
switch VersionFrom(r.Context()) {
case 2:
// v2 contract: flat customer name, order totals as float.
writeJSON(w, order.ToV2Shape())
default:
// v3 contract: nested customer object, minor units (int64).
writeJSON(w, order.ToV3Shape())
}
}
Two implementation notes save real pain. First, keep the version-specific transformations in dedicated ToV2Shape-style methods next to the domain type rather than scattering field-mapping logic through handlers — when v2 finally retires, deletion becomes mechanical. Second, treat version resolution as a single choke point. If handlers read the URL or headers directly, you will eventually have two endpoints resolving versions differently, and that divergence will surface as a bug report from exactly one client, for exactly one endpoint, unreproducibly.
Deprecation Is a Feature You Ship
Deprecating an API version is not an announcement; it is a feature with a lifecycle, telemetry, and an exit criterion. The pieces, in order:
Announce with machine-readable signals, not just blog posts. RFC 8594 defines the Sunset header — an HTTP date telling clients exactly when a version stops working. Combined with Deprecation and a Link header pointing at the migration guide (all three shown in the middleware above), clients can parse deprecation programmatically and alert their own owners. A documented, machine-readable schedule separates professional APIs from hobbies.
Measure who is still listening. The exit criterion for a deprecated version is a telemetry question: how many distinct clients called it in the last 7 days, and which credential or app ID were they using? Log the resolved version on every request (the middleware above makes this one field). The team that sunsets a version by date without usage telemetry is either pleasantly surprised or catastrophically surprised, and the difference is whether they counted first.
Contact the holdouts, not the crowd. Aggregate usage by API key or app ID, and reach out directly to the top accounts still pinned to the old version. Announcements reach the people who read announcements — the holdouts, by definition, do not. A short deprecation window with direct outreach outperforms a long window with only a changelog entry.
Decide what 410 looks like. After the sunset date, return 410 Gone with a JSON body explaining the situation and linking the migration guide — not a bare 404, which reads as “this endpoint never existed” and sends developers debugging in the wrong direction. Many teams also run a soft-shutdown period: return the 410 for a small percentage of traffic first, ramp up over days, so a forgotten internal consumer surfaces as elevated error rates rather than an outage.
The RFC 9457 error format is a good vehicle for these responses — its type field gives every versioning error a stable, documentable identity:
func writeProblem(w http.ResponseWriter, status int,
typ, title, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
writeJSON(w, map[string]string{
"type": typ,
"title": title,
"status": strconv.Itoa(status),
"detail": detail,
})
}
// On sunset date, version middleware rejects with:
// writeProblem(w, http.StatusGone,
// "https://api.example.com/problems/version-sunset",
// "API version retired",
// "API v2 was retired on 2026-08-01. See https://api.example.com/docs/migrations/v3")
Versioning Isn’t Just Routes
Three adjacent concerns get skipped and come back to bite:
Compatibility tests as CI gates. Maintain a golden-contract test suite per supported version: representative requests recorded with expected response shapes, run on every pull request. A response-field change that violates the v2 contract fails CI with a message pointing at the contract file, not at a customer’s crash report three weeks later. Consumer-driven contract testing takes this further — clients publish their expectations, and your pipeline verifies the provider still satisfies them.
Versioned documentation, versioned forever. If you keep only current docs, every client still pinned to v2 is debugging against docs that no longer describe their API. Docs for deprecated versions need no updates — they need freezing. Static-site generators handle this well: publish a snapshot per version and link it from the deprecation notice.
Defaults are contracts too. The middleware above defaults unversioned requests to the latest version. That default is itself part of the contract: the day you ship v4, every headerless client silently switches major versions. Safer alternatives are defaulting to the oldest supported version, or requiring an explicit version (reject unversioned requests with a helpful error). Whichever you choose, document it as prominently as the versioning scheme itself — the clients most likely to hit the default are the least likely to have read the docs.
The Whole Playbook on One Screen
Pick a versioning mechanism once and never change it — path segments for visibility, date-based headers for fast release cadences. Treat any observable change as breaking, including new response fields and enum values, and enforce that definition with golden-contract tests in CI rather than hope. Resolve versions in one middleware choke point and put the resolved version in logs. Deprecate with machine-readable headers (Deprecation, Sunset per RFC 8594), measure per-client usage of old versions, contact holdouts directly, and end with 410 Gone plus a link to the migration guide instead of a bare 404.
None of this is complicated; all of it is discipline. The teams that version well are not the ones with the cleverest negotiation scheme — they are the ones that decided, once, that existing clients are stakeholders, and built the telemetry and tests to keep that promise observable. Start with the middleware and the contract tests this week; the Sunset headers can wait until you actually need to retire something, and by then you will already know who is listening.