API Versioning
7 minute read
This guide explains how to add versioning to your API. Versioning lets you change your API without breaking existing clients.
Why Version APIs?
You need API versioning when:
- You remove or change fields — Breaks existing clients
- You add required fields — Old clients don’t send them
- You change behavior — Clients expect the old way
- You want to test new features — Test with some users first
- Clients upgrade slowly — Different clients use different versions
Versioning Methods
Rivaas Router supports four ways to detect versions:
1. Header-Based Versioning (Recommended)
The version goes in an HTTP header:
curl -H 'API-Version: v2' https://api.example.com/users
Good for:
- Public APIs
- RESTful services
- Modern web applications
Why it’s good:
- URLs stay clean
- Works with CDN caching
- Easy to route
- Standard practice
2. Query Parameter Versioning
The version goes in the URL query:
curl 'https://api.example.com/users?version=v2'
Good for:
- Developer testing
- Internal APIs
- Simple clients
Why it’s good:
- Easy to test in browsers
- Simple to document
- No header handling needed
3. Path-Based Versioning
The version goes in the URL path:
curl https://api.example.com/v2/users
Good for:
- Very different API versions
- Simple routing
- When you want version visible
Why it’s good:
- Most visible
- Works with all HTTP clients
- Easy infrastructure routing
4. Accept Header Versioning
The version goes in the Accept header (content negotiation):
curl -H 'Accept: application/vnd.myapi.v2+json' https://api.example.com/users
Good for:
- Hypermedia APIs
- Multiple content types
- Strict REST compliance
Why it’s good:
- Follows HTTP standards
- Supports content negotiation
- Used by major APIs
Getting Started
Basic Setup
Here’s how to set up versioning:
package main
import (
"net/http"
"rivaas.dev/router"
)
func main() {
r := router.New(
router.WithVersioning(
// Choose your version detection method
router.WithHeaderVersioning("API-Version"),
// Set default version (when client doesn't specify)
router.WithDefaultVersion("v2"),
// Optional: Only allow these versions
router.WithValidVersions("v1", "v2", "v3"),
),
)
// Create version 1 routes
v1 := r.Version("v1")
v1.GET("/users", listUsersV1)
// Create version 2 routes
v2 := r.Version("v2")
v2.GET("/users", listUsersV2)
http.ListenAndServe(":8080", r)
}
Using Multiple Methods
You can enable multiple detection methods. The router checks them in order:
r := router.New(
router.WithVersioning(
router.WithHeaderVersioning("API-Version"), // Primary
router.WithQueryVersioning("version"), // For testing
router.WithPathVersioning("/v{version}/"), // Legacy support
router.WithAcceptVersioning("application/vnd.myapi.v{version}+json"),
router.WithDefaultVersion("v2"),
),
)
Check order (first match wins):
- Custom detector (if you made one)
- Accept header
- Path parameter
- HTTP header
- Query parameter
- Default version
Version Detection Methods
Header-Based
Configure:
router.WithHeaderVersioning("API-Version")
Clients use:
curl -H 'API-Version: v2' https://api.example.com/users
Query Parameter
Configure:
router.WithQueryVersioning("version")
Clients use:
curl 'https://api.example.com/users?version=v2'
Path-Based
Configure:
router.WithPathVersioning("/v{version}/")
Routes work with or without path version:
// Accessed as /v2/users or /users (with header/query)
r.Version("v2").GET("/users", handler)
Clients use:
curl https://api.example.com/v2/users
Accept Header
Configure:
router.WithAcceptVersioning("application/vnd.myapi.v{version}+json")
Clients use:
curl -H 'Accept: application/vnd.myapi.v2+json' https://api.example.com/users
Custom Detector
For complex logic, make your own detector:
router.WithCustomVersionDetector(func(req *http.Request) string {
// Your custom logic
if isLegacyClient(req) {
return "v1"
}
return extractVersionSomehow(req)
})
Migration Patterns
Share Business Logic
Keep business logic the same, change only the response format:
// Business logic (shared between versions)
func getUserByID(id string) (*User, error) {
// Database query, business rules, etc.
return &User{ID: id, Name: "Alice"}, nil
}
// Version 1 handler
func listUsersV1(c *router.Context) {
users, _ := getUsersFromDB()
// V1 format: flat structure
c.JSON(200, map[string]any{
"users": users,
})
}
// Version 2 handler
func listUsersV2(c *router.Context) {
users, _ := getUsersFromDB()
// V2 format: with metadata
c.JSON(200, map[string]any{
"data": users,
"meta": map[string]any{
"total": len(users),
"version": "v2",
},
})
}
Handle Breaking Changes
Example: Making email field required
Version 1 (original):
type UserV1 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // Optional
}
Version 2 (breaking change):
type UserV2 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"` // Required now
}
func createUserV2(c *router.Context) {
var user UserV2
if err := c.Bind(&user); err != nil {
c.JSON(400, map[string]string{
"error": "validation failed",
"detail": "email is required in API v2",
})
return
}
// Create user...
}
Version-Specific Middleware
Apply different middleware to different versions:
v1 := r.Version("v1")
v1.Use(legacyAuthMiddleware)
v1.GET("/users", listUsersV1)
v2 := r.Version("v2")
v2.Use(jwtAuthMiddleware) // Different auth method
v2.GET("/users", listUsersV2)
Change Data Structure
Example: Flat to nested structure
// V1: Flat structure
type UserV1 struct {
ID int `json:"id"`
Name string `json:"name"`
City string `json:"city"`
Country string `json:"country"`
}
// V2: Nested structure
type UserV2 struct {
ID int `json:"id"`
Name string `json:"name"`
Address struct {
City string `json:"city"`
Country string `json:"country"`
} `json:"address"`
}
// Helper to convert
func convertV1ToV2(v1 UserV1) UserV2 {
v2 := UserV2{
ID: v1.ID,
Name: v1.Name,
}
v2.Address.City = v1.City
v2.Address.Country = v1.Country
return v2
}
Deprecation Strategy
Mark Versions as Deprecated
Tell the router when a version should stop working:
r := router.New(
router.WithVersioning(
// Mark v1 as deprecated with end date
router.WithDeprecatedVersion(
"v1",
time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC),
),
// Track version usage
router.WithVersionObserver(
router.WithOnDetected(func(version, method string) {
// Record metrics
metrics.RecordVersionUsage(version, method)
}),
router.WithOnMissing(func() {
// Client didn't specify version
log.Warn("client using default version")
}),
router.WithOnInvalid(func(attempted string) {
// Client used invalid version
metrics.RecordInvalidVersion(attempted)
}),
),
),
)
Deprecation Headers
The router automatically adds headers for deprecated versions:
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/docs/migration>; rel="deprecation"
These tell clients when the version will stop working.
Deprecation Timeline
6 months before end:
- Announce in release notes
- Add deprecation header
- Write migration guide
- Contact major users
3 months before end:
- Add sunset header with date
- Email active users
- Monitor usage (should go down)
- Offer help with migration
1 month before end:
- Send final warnings
- Return 410 Gone for deprecated endpoints
- Link to migration guide
After end date:
- Remove old version code
- Always return 410 Gone
- Keep migration documentation
Best Practices
1. Use Semantic Versioning
- Major (v1, v2, v3): Breaking changes
- Minor (v2.1, v2.2): New features, backward compatible
- Patch (v2.1.1): Bug fixes only
2. Know When to Version
Don’t version for:
- Bug fixes
- Performance improvements
- Internal refactoring
- Adding optional fields
- Making validation less strict
Do version for:
- Removing fields
- Changing field types
- Making optional field required
- Major behavior changes
- Changing error codes
3. Keep Backward Compatibility
// Good: Add optional field
type UserV2 struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // New, optional
}
// Bad: Remove field (breaks clients)
type UserV2 struct {
ID int `json:"id"`
// Name removed - BREAKING CHANGE!
}
4. Document Version Differences
Keep clear documentation for each version:
## API Versions
### v2 (Current)
- Added email field (optional)
- Added address nested object
- Added PATCH support for partial updates
### v1 (Deprecated - Ends 2025-12-31)
- Original API
- Only GET/POST/PUT/DELETE
- Flat structure only
5. Organize Routes by Version
Group version routes together:
v1 := r.Version("v1")
{
v1.GET("/users", listUsersV1)
v1.GET("/users/:id", getUserV1)
v1.POST("/users", createUserV1)
}
v2 := r.Version("v2")
{
v2.GET("/users", listUsersV2)
v2.GET("/users/:id", getUserV2)
v2.POST("/users", createUserV2)
v2.PATCH("/users/:id", updateUserV2) // New in v2
}
6. Validate Versions
Reject invalid versions early:
router.WithVersioning(
router.WithValidVersions("v1", "v2", "v3", "beta"),
router.WithVersionObserver(
router.WithOnInvalid(func(attempted string) {
log.Warn("invalid API version", "version", attempted)
}),
),
)
7. Test All Versions
func TestAPIVersions(t *testing.T) {
r := setupRouter()
tests := []struct{
version string
path string
want int
}{
{"v1", "/users", 200},
{"v2", "/users", 200},
{"v3", "/users", 200},
{"v99", "/users", 404}, // Invalid
}
for _, tt := range tests {
req := httptest.NewRequest("GET", tt.path, nil)
req.Header.Set("API-Version", tt.version)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, tt.want, w.Code)
}
}
Real-World Examples
Stripe-Style (Date-Based Versions)
r := router.New(
router.WithVersioning(
router.WithHeaderVersioning("Stripe-Version"),
router.WithDefaultVersion("2024-11-20"),
router.WithValidVersions(
"2024-11-20",
"2024-10-28",
"2024-09-30",
),
),
)
// Version by date
v20241120 := r.Version("2024-11-20")
v20241120.GET("/charges", listCharges)
GitHub-Style (Accept Header)
r := router.New(
router.WithVersioning(
router.WithAcceptVersioning("application/vnd.github.v{version}+json"),
router.WithDefaultVersion("v3"),
),
)
// Usage: Accept: application/vnd.github.v3+json
Further Reading
- RFC 7231 - Content Negotiation
- RFC 8594 - Sunset Header
- Semantic Versioning
- Microsoft API Versioning Guidelines
Summary
API versioning helps you:
- Make changes without breaking clients
- Support old and new clients at the same time
- Control when to remove old versions
- Track which versions clients use
Choose header-based versioning for most cases. Use query parameters for testing. Document your changes clearly. Give clients time to migrate before removing old versions.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.