When an API grows beyond a single team, it gets clients that rely on a stable contract. Change a field or remove an endpoint, and someone else's code breaks. Versioning solves this: the old contract lives under /api/v1, the new one under /api/v2.
Where to put the version number
There are several places to record the API version: in the URL, in a header, in a query parameter. The simplest and most obvious is the URL path:
/api/v1/orders
/api/v2/orders
The version is visible in the browser, in logs, in curl — with no extra effort. The format: the letter v and an integer (v1, v2, v3). Minor variants like v1.2 or dates like /api/2024 are not used: they complicate routing and confuse clients.
The /api prefix is mandatory for all business endpoints. Operational endpoints (/health, /metrics) sit outside /api and without a version — they are not part of the public contract.
A version in a query parameter (?version=2) is also not used: it's easy to lose during proxying and caching.
Basic router structure with chi
In Go with the chi library, versions are mounted via r.Route:
r := chi.NewRouter()
r.Get("/health", healthHandler)
r.Get("/metrics", metricsHandler)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware, tracingMiddleware)
r.Route("/orders", ordersRouterV1)
r.Route("/products", productsRouterV1)
r.Route("/customers", customersRouterV1)
})
All middleware (authorization, tracing) is attached once at the version level — not duplicated in every resource.
What is a breaking change
Not every change requires a new version. The key distinction: will the change break existing client code?
Changes that require a new version (breaking):
- Remove or rename an endpoint
- Remove or rename a field in the response (
customerId→clientId) - Change a field's type (
total: string→total: int64) - Remove a value from an enumeration (
OrderStatus.DRAFTdisappears) - Change an endpoint's HTTP method
- Make a new request field required
- Tighten validation (
maxLength: 200→maxLength: 50) - Remove or rename a query parameter
Changes made within the current version (non-breaking):
- Add a new optional field to the response
- Add a new endpoint
- Add a new value to an enumeration
- Add an optional query parameter
- Relax validation (
maxLength: 50→maxLength: 200) - Add a new error code
A common mistake is creating v2 just to add one optional field. That's wasted work: adding metadata to OrderResponse is non-breaking — just add the field to v1.
Supporting v1 and v2 in parallel
When a breaking change really is needed, a new version is created. The old one keeps working until clients migrate:
r.Route("/api/v1", func(r chi.Router) {
r.Route("/orders", ordersRouterV1)
})
r.Route("/api/v2", func(r chi.Router) {
r.Route("/orders", ordersRouterV2)
})
Internally, v1 and v2 can use the same business logic layer — the difference is only in the DTOs and transformations:
func listOrdersV1(w http.ResponseWriter, r *http.Request) {
orders := svc.ListOrders(r.Context())
writeJSON(w, http.StatusOK, toOrderListResponseV1(orders))
}
func listOrdersV2(w http.ResponseWriter, r *http.Request) {
orders := svc.ListOrders(r.Context())
writeJSON(w, http.StatusOK, toOrderListResponseV2(orders))
}
The svc.ListOrders logic is shared; only the result-transformation functions change.
The client and adding fields
A properly written client ignores unknown fields in the response. Go's standard encoding/json does this by default — which is the correct behavior:
type OrderResponse struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
Total int64 `json:"total"`
// if the server adds a new field, this code won't break
}
var resp OrderResponse
json.Unmarshal(body, &resp) // unknown fields are silently ignored
Don't use DisallowUnknownFields() in client code: it makes the client fragile — any server extension will break it.
Likewise for enumerations — unknown values should be handled as unknown rather than crashing:
type OrderStatus string
const (
StatusNew OrderStatus = "NEW"
StatusConfirmed OrderStatus = "CONFIRMED"
StatusUnknown OrderStatus = ""
)
func parseStatus(s string) OrderStatus {
switch OrderStatus(s) {
case StatusNew, StatusConfirmed:
return OrderStatus(s)
default:
return StatusUnknown // an unknown value is not an error
}
}
How to declare a version deprecated
Once v2 is in production and clients can migrate, v1 is marked deprecated via HTTP headers. Clients that watch the headers will see the warning long before the shutdown:
func deprecatedMiddleware(sunset, successorURL 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", sunset)
w.Header().Set("Deprecation", "true")
w.Header().Set("Link", `<`+successorURL+`>; rel="successor-version"`)
next.ServeHTTP(w, r)
})
}
}
r.Route("/api/v1", func(r chi.Router) {
r.Use(deprecatedMiddleware(
"Sat, 01 Jan 2027 00:00:00 GMT",
"https://api.example.com/api/v2",
))
r.Route("/orders", ordersRouterV1)
})
The Sunset header contains the shutdown date. Link with rel="successor-version" points to the replacement.
In short
- The version goes in the URL path:
/api/v1/orders. The format isv+ an integer. - The
/apiprefix is mandatory for business endpoints./healthand/metricsare outside/api, without a version. - A new version is created only for a breaking change: removing/renaming a field or endpoint, changing a type, tightening validation.
- Adding an optional field, a new endpoint, a new enum value is non-breaking and is done in the current version.
- v1 and v2 can share one business logic layer; the difference is in DTOs and transformations.
encoding/jsonignores unknown fields by default.DisallowUnknownFields()in the client is bad practice.- A deprecated version is marked with
Sunset,Deprecation, andLinkheaders.
What to read next
- URL and resources — chi route structure.
- Errors RFC 9457 — how to extend error codes without a breaking change.
- Rate limiting and deprecation — the
Sunsetheader in more detail.