Introduction
For years (test), Go developers have reached for third-party routing libraries like Gorilla Mux, Chi, or Gin when building web applications. The standard library's http.ServeMux was considered too basic, lacking essential features like HTTP method matching and path parameters. However, Go 1.22 brings two enhancements to the net/http package's router: method matching and wildcards, fundamentally changing the routing landscape in Go.
This comprehensive guide explores how Go's enhanced net/http package now provides all the routing capabilities most applications need, potentially eliminating the need for external routing dependencies.
The Evolution: From Basic to Powerful
Before Go 1.22
Prior to Go 1.22, the http.ServeMux was extremely limited. It could only:
Match URL paths based on prefix patterns
Route requests regardless of HTTP method
Handle static paths without parameters
This meant developers had to write boilerplate code for even basic REST operations:
func handlePost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGET {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/posts/")
id := strings.TrimSuffix(path, "/")
}
func main() {
http.HandleFunc("/posts/", handlePost)
http.ListenAndServe(":8080", nil)
}
The Game Changer: Go 1.22+
The new routing features almost exclusively affect the pattern string passed to the two net/http.ServeMux methods Handle and HandleFunc. The syntax has been enhanced to support:
HTTP Method Routing: Specify methods directly in patterns
Path Parameters: Extract dynamic values from URLs using wildcards
Enhanced Pattern Matching: More sophisticated routing rules
Core Features of Enhanced Routing
1. Method-Based Routing
That means we can set a route as GET /hello and it will automatically only accept GET requests and return HTTP 405 otherwise. The syntax is straightforward:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
http.ListenAndServe(":8080", mux)
}
Important notes about method routing:
There should exactly a single space between the method and the path
If no method is specified, the handler accepts all methods
The router automatically returns 405 Method Not Allowed for mismatched methods
2. Path Parameters with Wildcards
Wildcards allow you to define variable parts of an URL path in a number of different ways. Go 1.22 introduces several wildcard patterns:
Basic Wildcards
mux.HandleFunc("GET /products/{id}", func(w http.ResponseWriter, r *http.Request) {
productID := r.PathValue("id")
fmt.Fprintf(w, "Product ID: %s", productID)
})
Multiple Wildcards
mux.HandleFunc("GET /users/{userID}/posts/{postID}", func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userID")
postID := r.PathValue("postID")
fmt.Fprintf(w, "User: %s, Post: %s", userID, postID)
})
Catch-All Wildcards
The last wildcard in a pattern can optionally match all remaining path segments by having its name end in ...:
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
filePath := r.PathValue("path")
fmt.Fprintf(w, "File path: %s", filePath)
})
3. Pattern Precedence Rules
When route patterns overlap, Go's servemux needs to decide which pattern takes precedence so it can dispatch the request to the appropriate handler. The rule for this is very neat and succinct: the most specific route pattern wins.
Examples of precedence:
mux.HandleFunc("GET /posts/latest", handleLatest)
mux.HandleFunc("GET /posts/{id}", handlePost)
mux.HandleFunc("GET /posts/{id}/edit", handleEdit)
4. Exact Path Matching
What if we only want to allow EXACT matches? Well, we can now do that using {$} at the end of the route:
mux.HandleFunc("GET /users/{$}", listUsers)
mux.HandleFunc("GET /users/{id}", getUser)
Practical Examples
Building a RESTful API
Here's a complete example of a RESTful API using only net/http:
package main
import (
"encoding/json"
"fmt"
"net/http"
"sync"
)
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
type TaskStore struct {
mu sync.RWMutex
tasks map[string]Task
}
func main() {
store := &TaskStore{
tasks: make(map[string]Task),
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tasks", store.listTasks)
mux.HandleFunc("POST /api/tasks", store.createTask)
mux.HandleFunc("GET /api/tasks/{id}", store.getTask)
mux.HandleFunc("PUT /api/tasks/{id}", store.updateTask)
mux.HandleFunc("DELETE /api/tasks/{id}", store.deleteTask)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
})
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
func (s *TaskStore) listTasks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks)
}
func (s *TaskStore) createTask(w http.ResponseWriter, r *http.Request) {
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
s.mu.Lock()
task.ID = fmt.Sprintf("%d", len(s.tasks)+1)
s.tasks[task.ID] = task
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(task)
}
func (s *TaskStore) getTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
s.mu.RLock()
task, exists := s.tasks[id]
s.mu.RUnlock()
if !exists {
http.Error(w, "Task not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (s *TaskStore) updateTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var updates Task
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
s.mu.Lock()
task, exists := s.tasks[id]
if !exists {
s.mu.Unlock()
http.Error(w, "Task not found", http.StatusNotFound)
return
}
task.Title = updates.Title
task.Completed = updates.Completed
s.tasks[id] = task
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(task)
}
func (s *TaskStore) deleteTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
s.mu.Lock()
_, exists := s.tasks[id]
if !exists {
s.mu.Unlock()
http.Error(w, "Task not found", http.StatusNotFound)
return
}
delete(s.tasks, id)
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
Sub-routing and API Versioning
Sub-routing allows us to group common routes. While ServeMux doesn't have built-in sub-router support like some third-party libraries, you can achieve similar functionality:
func main() {
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", v1ListUsers)
v1.HandleFunc("GET /users/{id}", v1GetUser)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", v2ListUsers)
v2.HandleFunc("GET /users/{id}", v2GetUser)
v2.HandleFunc("GET /users/{id}/profile", v2GetUserProfile)
mux := http.NewServeMux()
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", v1))
mux.Handle("/api/v2/", http.StripPrefix("/api/v2", v2))
http.ListenAndServe(":8080", mux)
}
Middleware Pattern
While ServeMux doesn't have built-in middleware support, you can implement it using the standard handler pattern:
type Middleware func(http.Handler) http.Handler
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func applyMiddleware(handler http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users", listUsers)
handler := applyMiddleware(mux, Logger, CORS)
http.ListenAndServe(":8080", handler)
}
Migration Considerations
When to Use Standard Library
The enhanced ServeMux is ideal when:
Building simple to moderate complexity APIs
Minimizing dependencies is a priority
Standard routing patterns suffice
You don't need advanced features like route groups or complex middleware chains
When to Consider Third-Party Routers
Routers like gorilla/mux still provide more capabilities than the standard library. Consider alternatives when you need:
Regular expression routing
Advanced middleware management
Route grouping with shared middleware
Request/response helpers
More sophisticated parameter validation
The reference implementation for this proposal matches requests about as fast as the current ServeMux on Julien Schmidt's static benchmark. The Go team prioritized correctness and maintainability over raw performance, understanding that for typical servers, that usually access some storage backend over the network, I'd guess the matching time is negligible.
Best Practices
1. Pattern Organization
mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)
2. Use Specific Patterns
Just because Go's servemux supports overlapping routes, it doesn't mean that you should use them! Keep patterns clear and avoid unnecessary overlaps.
3. Explicit Method Specification
Always specify HTTP methods for better API clarity:
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("/users", handleUsers)
4. Error Handling
The enhanced router automatically handles 405 Method Not Allowed, but you should still handle 404s and other errors appropriately:
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
Gotchas and Edge Cases
1. Go Version in go.mod
Enhanced routing patterns introduced in 1.22 do not work in 1.23 unless go mod has go version explicitly declared. Always ensure your go.mod specifies the correct version:
module myapp
go 1.22
2. Pattern Conflicts
There is a potential edge case where you have two overlapping route patterns but neither one is obviously more specific than the other. The server will panic at startup if conflicts are detected:
mux.HandleFunc("GET /posts/new/{id}", handler1)
mux.HandleFunc("GET /posts/{author}/latest", handler2)
3. Trailing Slashes
Be aware of how trailing slashes affect routing:
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("GET /users/", listAllUsers)
mux.HandleFunc("GET /users/{$}", exactMatch)
Conclusion
Go 1.22's enhanced net/http package represents a significant milestone in the language's evolution. These features let you express common routes as patterns instead of Go code, dramatically simplifying web application development.
For many applications, the standard library now provides all the routing functionality needed, eliminating dependencies and reducing complexity. While third-party routers still have their place for advanced use cases, the gap has narrowed considerably.
The Go team's approach—studying existing solutions, extracting the most-used features, and integrating them thoughtfully—demonstrates their commitment to making Go an excellent choice for building production systems. Whether you're starting a new project or considering a migration, Go's enhanced routing capabilities deserve serious consideration.
Additional Resources