Versioning, Deprecation, and Maintenance
Once an API has real clients, it can't just change shape overnight — every field renamed or endpoint removed is a broken integration somewhere. Versioning is how you evolve an API without breaking the clients depending on its current behavior, and deprecation is how you retire old versions responsibly instead of abruptly.
What Counts as a Breaking Change
Not every change requires a new version. A useful rule of thumb:
- Non-breaking (safe to ship in place): adding a new optional field to a response, adding a new endpoint, adding a new optional query parameter, relaxing a validation rule.
- Breaking (requires a new version): removing or renaming a field, changing a field's type or meaning, removing an endpoint, tightening validation on existing input, changing the default value of something clients already rely on.
If an existing, well-behaved client would need to change its code for your update to keep working, it's a breaking change — no exceptions for changes that "should" be fine.
Versioning Strategies
Path-based versioning
The most common and most visible approach: embed the version directly in the URL.
GET /v1/products/42
GET /v2/products/42
Simple to route (a version prefix maps to a different set of handlers or even a different service entirely), easy for clients to see which version they're on just by reading a log line, and trivially cacheable per version. The downside is that a "resource" now technically has multiple URLs across versions, which some REST purists dislike — in practice, this rarely causes real problems.
Header-based versioning
Encode the version in a custom header or via content negotiation on the Accept header:
GET /products/42 HTTP/1.1
Accept: application/vnd.example.v2+json
This keeps the URL itself stable across versions, which some API designers prefer philosophically, but it's less visible in logs and harder for a developer to test manually in a browser or with a quick curl command without remembering the right header.
Which to choose
Path-based versioning is the pragmatic default for most public APIs — it's what clients expect, it's trivial to route in Go (a /v1 vs /v2 prefix on the mux, or even a separate http.ServeMux per version mounted under a shared root), and its visibility is a feature, not a bug, when debugging.
v1 := http.NewServeMux()
v1.HandleFunc("GET /products/{id}", v1GetProduct)
v2 := http.NewServeMux()
v2.HandleFunc("GET /products/{id}", v2GetProduct)
root := http.NewServeMux()
root.Handle("/v1/", http.StripPrefix("/v1", v1))
root.Handle("/v2/", http.StripPrefix("/v2", v2))
Deprecating a Version
When /v1 needs to go away eventually, tell clients before you turn it off, using standard, machine-readable signals rather than only a changelog entry someone might miss:
func deprecated(sunset time.Time, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
w.Header().Set("Sunset", sunset.Format(http.TimeFormat))
w.Header().Set("Link", `</v2/products>; rel="successor-version"`)
next.ServeHTTP(w, r)
})
}
Deprecation: true(from the IETF draft of the same name) signals that this endpoint is on its way out.Sunset(RFC 8594) gives the exact date the endpoint will stop working.Linkwithrel="successor-version"points automated tooling — and curious developers — straight at the replacement.
Wrapping the entire v1 mux with this middleware means every request against the old version carries these headers automatically, without touching individual handlers.
Request:
GET /v1/products/42 HTTP/1.1
Response:
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 01 Oct 2026 00:00:00 GMT
Link: </v2/products>; rel="successor-version"
Content-Type: application/json
{"id":42,"name":"Keyboard","price":49.99}
The endpoint still works — deprecation is a warning, not an immediate removal — but every response now tells the caller exactly when that stops being true.
Semantic Versioning for the API Contract
Even without a /v1//v2 URL split, it helps to think about API changes the way you'd think about a library's semver: a major version bump for breaking changes, minor for backward-compatible additions, patch for bug fixes that don't change the contract at all. This vocabulary is useful in changelogs and release notes even if the URL itself only ever shows the major version (/v1) — clients rarely care about the difference between a minor and patch release, but they care a great deal about majors.
A Practical Deprecation Timeline
A reasonable default policy, adjusted to your API's actual usage patterns:
- Announce: publish the new version, document the differences, and start sending
Deprecation/Sunsetheaders on the old one — give this stage weeks to months depending on how many integrators depend on you. - Warn actively: for high-value clients you can identify (via API keys), reach out directly, not just via headers nobody parses.
- Sunset: after the announced date, the old version returns
410 Goneinstead of serving real data — never silently change behavior or remove the endpoint without a clear terminal response.
func sunset(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w,
"this API version has been retired; see /v2",
http.StatusGone)
})
}
Maintenance Practices That Prevent Surprises
- Keep a changelog that's actually read — link it from your OpenAPI docs (previous chapter) so it's discoverable from the same place developers already look.
- Contract tests against real client expectations — a test suite that asserts response shapes stay stable catches accidental breaking changes before release, not after a client files a bug report.
- Feature-flag risky changes where possible, so a change can roll out to a subset of traffic and roll back instantly if something's wrong (covered in more depth in a later chapter on feature flags).
- Never reuse a version number — once
/v2has shipped and been used, don't redefine what/v2means later; cut a/v3instead, even if/v2had a short lifetime.
Frequently Asked Questions
Do I need to bump the version for every single change I make to an API? No, and treating every change as version-worthy is how teams end up with a dozen barely-different versions nobody wants to maintain. Stick to the rule of thumb from the top of this chapter: additive, optional changes ship in place, and only changes that would force an existing client to update its code deserve a new version.
Why does path-based versioning win out over header-based versioning here, when plenty of API design guides prefer headers?
Both work, but path-based versioning is chosen as the pragmatic default because it's visible where it matters most: in server logs, in a browser address bar, in a quick curl command during an incident at 2 a.m. Header-based versioning is philosophically tidier — the URL for a resource never changes — but that tidiness costs you the ability to eyeball which version a request hit without inspecting headers, which matters more in practice than it sounds.
What actually happens to clients still calling /v1 after the sunset date — does it just silently keep working?
No — that's exactly the failure mode this chapter's deprecation timeline is designed to prevent. Once the announced sunset date passes, the old version should return a clear, terminal 410 Gone rather than quietly changing behavior or vanishing without explanation, so a broken integration fails loudly and obviously instead of mysteriously.
Is it ever okay to bring back a version number once it's been retired, say reusing /v2 for something new after it's sunset?
No — this is one of the few hard rules in the chapter. Once /v2 has shipped and real clients have used it, its meaning is permanently spoken for; a later, incompatible change gets /v3 instead, even if /v2 only lived for a few months. Reusing a number breaks the one guarantee versioning exists to provide: that a given version number always means the same contract.
How do the Deprecation and Sunset headers help if most client code doesn't even parse them?
They're not only for automated tooling — though a well-behaved client or monitoring dashboard absolutely can key off them, which is real value on its own. Just as importantly, they create a paper trail: every single response from the deprecated version carries a machine-readable timestamp of when it dies, so there's never an argument later about whether clients were warned or for how long.
Versioning and deprecation are ultimately about trust: an API that changes without warning teaches its integrators to distrust every future release. Predictable, well-signaled change is what lets an API keep evolving for years without accumulating clients too afraid to upgrade. Next, we'll look at prebuilt solutions and boilerplates that can jump-start a new Go API project from day one.