API Versioning in Go and the Strategy That Ages Best
Compare URL, header, media type and date-based API versioning in Go, with one handler serving two views, a frozen v1 test and Sunset headers.

A new version of an API is a promise to run two APIs, which is a cost, so the first rule of versioning is to need it as rarely as possible. Most changes do not need one. Adding a field, adding an endpoint, accepting an extra optional parameter and loosening a validation rule are all safe for a client that ignores what it does not recognise. A version is for the changes an old, correct client cannot survive: removing or renaming a field, changing a type, changing the meaning of a value, tightening validation.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
Once you do need one, the choices are where the version goes, how long old ones live and how you keep two of them from becoming two codebases. This article compares the placements, then builds a Go service where one handler serves two versions of the same resource and a test freezes the first. It complements the JSON API cornerstone and the compatibility rules in REST API design. Everything ran on Go 1.25.6.
The ways to carry a version
In the URL path: /v1/users/7. It is visible in logs, browsable, cacheable by URL without special rules, trivial to route in net/http and easy to explain on a support call. The usual objection, that the version is not part of a resource's identity, is correct in theory and has almost no practical weight. Google's API design guidance uses a major version in the path for this reason.
In a request header: API-Version: 2, or a custom media type such as Accept: application/vnd.example.v2+json. It keeps URLs stable and is the purist's answer. It is also invisible in a browser, awkward to try with a plain link, easy to forget in a client, and it forces Vary on every cacheable response so that a shared cache does not hand a v1 body to a v2 client. The media type form is correct HTTP content negotiation and rarely worth the tooling friction.
In a query parameter: ?version=2. Simple to try and simple to cache, and it blends versioning with filtering. It is the weakest of the options, because query strings get stripped, normalised and ignored by intermediaries more than paths do.
By date: the client sends a date, 2026-03-01, or the account is pinned at signup to the date it first called. The server applies every change since that date as a transformation. This is how Stripe and GitHub do it (GitHub's X-GitHub-Api-Version header takes a date). It allows very fine-grained evolution with no large "v3" migrations, and it is expensive: the server carries a chain of per-change transformations indefinitely. It suits a company whose API is the product and which has a team to run it.
No versions, only evolution: add and never remove. It works for internal APIs with a handful of known clients, where you can change both sides in one release. For anything public it eventually runs into a change that cannot be additive, and then you have a versioning problem with no versioning mechanism.
My default for a new public API is the path major version plus a strict additive policy inside it, and a header for the exceptional case. It is the least surprising for clients, and it is the cheapest to operate. The rest of this article assumes that.
One model, one view per version
The common failure of versioning is copying: v1 handlers, v2 handlers, two sets of tests, and a bug fix that has to be applied twice and is applied once. The way to avoid it is to put the version where it belongs, at the edge of the system, in the representation.
package main
// User is the one internal model. Versions are views of it, never copies of the handlers.
type User struct {
ID int
FirstName string
LastName string
Email string
Plan string
}
var users = map[int]User{
1: {1, "Ada", "Lovelace", "[email protected]", "pro"},
2: {2, "Alan", "Turing", "[email protected]", "free"},
}
// v1 exposed a single "name" field and no plan.
type UserV1 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// v2 split the name and added the plan.
type UserV2 struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Plan string `json:"plan"`
}
func viewV1(u User) UserV1 { return UserV1{u.ID, u.FirstName + " " + u.LastName, u.Email} }
func viewV2(u User) UserV2 { return UserV2{u.ID, u.FirstName, u.LastName, u.Email, u.Plan} }There is one User with the internal shape: first and last name, email, plan. The first version of the API published a single name and no plan. A later requirement split the name and exposed the plan, which is a breaking change for any client that reads name, so it ships as v2. Each version is a type with its own JSON tags, and each has a function from the internal model: viewV1 joins the names, viewV2 passes them through. The storage, the business logic, the authorisation and the tests of those things exist once.
Routing and resolving the version
package main
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
type version string
const (
V1 version = "1"
V2 version = "2"
)
var supported = map[version]bool{V1: true, V2: true}
// Latest is the default for clients that name no version. Changing it is a breaking change, so it stays V1 until announced.
const defaultVersion = V1
// sunsets holds retirement dates for versions that are going away.
var sunsets = map[version]time.Time{V1: time.Date(2027, 6, 30, 0, 0, 0, 0, time.UTC)}
// resolve picks the version from the URL prefix first, then the API-Version header, then the default.
func resolve(r *http.Request) (version, bool) {
if rest, ok := strings.CutPrefix(r.URL.Path, "/v"); ok {
num, _, _ := strings.Cut(rest, "/")
v := version(num)
return v, supported[v]
}
if h := r.Header.Get("API-Version"); h != "" {
return version(h), supported[version(h)]
}
return defaultVersion, true
}
func Handler() http.Handler {
mux := http.NewServeMux()
get := func(w http.ResponseWriter, r *http.Request) {
v, ok := resolve(r)
if !ok {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(400)
w.Write([]byte(`{"title":"Unsupported API version","status":400}` + "\n"))
return
}
id, _ := strconv.Atoi(r.PathValue("id"))
u, found := users[id]
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("API-Version", string(v))
w.Header().Set("Vary", "API-Version")
if t, ok := sunsets[v]; ok {
w.Header().Set("Sunset", t.Format(http.TimeFormat))
w.Header().Set("Link", `</docs/migrate-v1-to-v2>; rel="sunset"`)
}
w.Header().Set("Content-Type", "application/json")
switch v {
case V1:
json.NewEncoder(w).Encode(viewV1(u))
case V2:
json.NewEncoder(w).Encode(viewV2(u))
}
}
// Both URL styles reach the same handler; the version is data, not a separate code path.
mux.HandleFunc("GET /v1/users/{id}", get)
mux.HandleFunc("GET /v2/users/{id}", get)
mux.HandleFunc("GET /v3/users/{id}", get)
mux.HandleFunc("GET /users/{id}", get)
return mux
}Three behaviors matter here.
The handler is shared; the version is data. The same function is registered for /v1/users/{id} and /v2/users/{id}, and resolve reads the version from the path, falls back to the API-Version header and then to a default. After that a switch picks the view. The pattern scales to a larger service: a middleware that resolves the version once and stores it in the request context, and handlers that call a per-version presenter.
Unknown versions are a 400, not a guess. /v3/users/1 and API-Version: 9 get an explicit problem response. Silently serving the nearest version would be a worse failure, because a client with a typo would never notice.
The default is pinned, and old on purpose. A request that names no version gets defaultVersion, which is V1 and stays there. Defaulting to the newest looks like the friendly choice and is a trap: the day v2 ships, every client that never specified a version changes behavior without a deploy of its own. The default moves only when you announce it, with a date, and the conservative move is to require a version on new keys entirely. Stripe-style accounts pinned at first use avoid the problem by recording the version once.
The Vary: API-Version header is set whenever the header can influence the response, so shared caches key on it. The API-Version response header echoes the version actually served, which is useful in logs and in support tickets: a client that thinks it is on v2 and is getting v1 can see that straight away. Go canonicalises header names, so it appears as Api-Version in the output below; HTTP header names are case-insensitive, and any correct client handles it.
What the server returned
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strings"
)
func call(h http.Handler, label, path string, hdr map[string]string) {
req := httptest.NewRequest("GET", path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var keep []string
for _, k := range []string{"Api-Version", "Sunset", "Vary"} {
if v := rec.Header().Get(k); v != "" {
keep = append(keep, k+": "+v)
}
}
sort.Strings(keep)
fmt.Printf("%-26s %d %s\n headers: %s\n", label, rec.Code, strings.TrimSpace(rec.Body.String()), strings.Join(keep, " | "))
}
func main() {
h := Handler()
call(h, "GET /v1/users/1", "/v1/users/1", nil)
call(h, "GET /v2/users/1", "/v2/users/1", nil)
call(h, "GET /v3/users/1", "/v3/users/1", nil)
call(h, "GET /users/1 (no version)", "/users/1", nil)
call(h, "GET /users/1 API-Version:2", "/users/1", map[string]string{"API-Version": "2"})
call(h, "GET /users/1 API-Version:9", "/users/1", map[string]string{"API-Version": "9"})
}GET /v1/users/1 200 {"id":1,"name":"Ada Lovelace","email":"[email protected]"}
headers: Api-Version: 1 | Sunset: Wed, 30 Jun 2027 00:00:00 GMT | Vary: API-Version
GET /v2/users/1 200 {"id":1,"first_name":"Ada","last_name":"Lovelace","email":"[email protected]","plan":"pro"}
headers: Api-Version: 2 | Vary: API-Version
GET /v3/users/1 400 {"title":"Unsupported API version","status":400}
headers:
GET /users/1 (no version) 200 {"id":1,"name":"Ada Lovelace","email":"[email protected]"}
headers: Api-Version: 1 | Sunset: Wed, 30 Jun 2027 00:00:00 GMT | Vary: API-Version
GET /users/1 API-Version:2 200 {"id":1,"first_name":"Ada","last_name":"Lovelace","email":"[email protected]","plan":"pro"}
headers: Api-Version: 2 | Vary: API-Version
GET /users/1 API-Version:9 400 {"title":"Unsupported API version","status":400}
headers:The v1 response has name: "Ada Lovelace" and no plan, the shape it has had since launch, and carries the Sunset header with the retirement date, 30 June 2027. The v2 response splits the name and adds the plan. The unversioned request, with no header, gets exactly the v1 body and the sunset date. With API-Version: 2 the same URL returns v2. /v3/ and API-Version: 9 return 400.
Note the interplay in the unversioned case: a client with no version gets a response that says its version will be retired. That is the right outcome, since it is the clients that never chose a version who are least likely to read a changelog. The warning lives in a header they might log, and in the Link header pointing to the migration guide.
Freezing the old version
package main
import (
"net/http/httptest"
"strings"
"testing"
)
func body(t *testing.T, path string) string {
t.Helper()
rec := httptest.NewRecorder()
Handler().ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
return strings.TrimSpace(rec.Body.String())
}
// The v1 contract is frozen. If this test changes, a client somewhere breaks.
func TestV1StaysExactlyAsPublished(t *testing.T) {
const want = `{"id":1,"name":"Ada Lovelace","email":"[email protected]"}`
if got := body(t, "/v1/users/1"); got != want {
t.Fatalf("v1 changed:\n got %s\nwant %s", got, want)
}
}
func TestV2AddsFieldsWithoutRemovingAny(t *testing.T) {
got := body(t, "/v2/users/1")
for _, f := range []string{`"id"`, `"email"`, `"first_name"`, `"last_name"`, `"plan"`} {
if !strings.Contains(got, f) {
t.Errorf("v2 is missing %s: %s", f, got)
}
}
}
func TestUnversionedRequestsGetTheDefaultNotTheNewest(t *testing.T) {
if got, want := body(t, "/users/1"), body(t, "/v1/users/1"); got != want {
t.Fatalf("default drifted: %s", got)
}
}=== RUN TestV1StaysExactlyAsPublished
--- PASS: TestV1StaysExactlyAsPublished (0.00s)
=== RUN TestV2AddsFieldsWithoutRemovingAny
--- PASS: TestV2AddsFieldsWithoutRemovingAny (0.00s)
=== RUN TestUnversionedRequestsGetTheDefaultNotTheNewest
--- PASS: TestUnversionedRequestsGetTheDefaultNotTheNewest (0.00s)
PASS
ok example.test/lab 1.270sThe first test compares the exact bytes of the v1 response to a literal. It is a golden test, deliberately blunt: if someone changes how names are joined, adds a field "just in case", or reorders the struct, it fails, and the diff is the review. Do the same for every version you promise to keep. Without it, the commonest breakage is not a planned v2 but an unplanned edit to v1 while changing the model.
The second test checks that v2 only adds: every v1-era field is still present. The third pins that the unversioned request equals the v1 request. When the default is ever changed, this is the test that has to be edited on purpose.
For larger APIs, make this systematic. Store recorded responses per version as files and compare in CI, or generate the OpenAPI document for each version and diff it against the previous release with a tool that flags breaking changes (OpenAPI for Go services). The goal is that a breaking change is always a decision a person made, never an accident.
Retiring a version
Running a version forever is not a plan. A retirement needs a timeline and signals.
- Announce the date early, in the changelog, by email to registered developers, and in the documentation. For public APIs, twelve months is common; for internal ones, a quarter may do.
- Send it on every response. The
Sunsetheader (RFC 8594) carries the date, and aLinkheader withrel="sunset"points to the migration guide. The newerDeprecationheader (RFC 9745) marks a version or endpoint as deprecated before the sunset. Clients and gateways can alert on either. - Measure who still uses it. Count requests per version and per API key. The per-key list is how you email the three customers still on v1 instead of guessing. The practical details are in deprecating endpoints.
- Brown out before you remove. In the weeks before the date, return the error for a short window each day. A client that ignored every notice learns about the retirement from a failing test, not from an outage.
- After the date, return
410 Gonewith a problem document that links to the migration guide, rather than a 404 that looks like a bug.
Pitfalls
Versioning the whole API for one endpoint. Version the resource that changed if you can, and keep the rest stable. Many v2 APIs are v1 with a few endpoints altered, and clients are forced to relearn everything. Per-resource versions are harder to document; weigh that.
Minor versions. Do not expose v1.3. Clients cannot do anything useful with it, and additive changes are by definition compatible. A changelog is the right place for the small stuff.
Behavior changes that look compatible. Changing the sort order, the default page size, the rounding of a decimal or the timezone of a timestamp will break clients just as surely as removing a field, and no schema diff will notice. Treat observable behavior as part of the contract and test it (backward compatibility).
Sharing structs between versions. If UserV1 is a type alias of UserV2, a change to one changes both. Give each version its own type, as above. It looks repetitive and it is the only thing that stops an edit from leaking backwards.
Versioning the client library separately and forgetting. A generated client pins a version of the API, so release it alongside the spec (generating a client from an OpenAPI document).
Forgetting webhooks and events. Payloads you send are also an API. A webhook sent to a v1 subscriber should keep the v1 shape; version the event schema as well (webhook design).
When this is not the right answer
For an internal service with a few callers you control, skip versioning and coordinate deploys, using additive changes and a short deprecation window. For an API sold as a product with thousands of integrators, consider date-based versions with a transformation layer, accepting the operational cost. For GraphQL, which has its own approach (schema evolution with @deprecated), the path-version advice does not apply. Everywhere else, /v1/, a thin view per version, a golden test and a Sunset header carry a long way. The HTTP semantics behind the headers are in RFC 9110, and the routing used here is documented in the net/http package.


