The standard library now has all you need for advanced routing in Go.

Advanced HTTP Routing in Go: Everything You Need from the Standard Library
Since Go 1.22 was released, the net/http
package has evolved into a comprehensive routing solution that eliminates the need for third-party dependencies. However, mastering advanced features like middleware, subrouting, path parameters, HTTP methods, and context passing can be challenging. In this article, we'll explore how to implement each of these features using only the Go standard library.
Path Parameters
Adding path parameters to routes is similar to other frameworks like Gorilla Mux or Chi. You simply wrap the path component you want to parameterize in braces with the parameter name inside.
// Define a route with a path parameter
mux.HandleFunc("/item/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "Item ID: %s", id)
})
With our parameter defined, we can extract it inside our handler using the PathValue
method of the request type. When you send requests to this endpoint, it will return the captured path component.
Important Notes About Path Parameters
Version Requirement: Path parameters require Go 1.22 and must be specified in your go.mod
file. Earlier versions won't have access to this feature.
Conflicting Paths and Precedence: Be aware of conflicting paths. Go determines the correct path based on precedence ordering where "most specific wins."
// These routes work fine - Go knows /item/latest is more specific
mux.HandleFunc("/item/{id}", handleItem)
mux.HandleFunc("/item/latest", handleLatest)
// These routes will cause a panic - both are equally specific
mux.HandleFunc("/posts/{category}", handleCategory) // ❌ Conflicts
mux.HandleFunc("/posts/{id}", handlePost) // ❌ Conflicts
Go will detect conflicts and panic when registering conflicting paths, which prevents requests from being routed to the wrong handler.
Method-Based Routing
Before version 1.22, handling different HTTP methods required checking the request method inside the HTTP handler. Now it's much simpler - just define the method at the start of the matcher string.
// Handle POST requests only
mux.HandleFunc("POST /monster", createMonsterHandler)
// Handle other HTTP methods
mux.HandleFunc("PUT /monster/{id}", updateMonsterHandler)
mux.HandleFunc("GET /monster/{id}", getMonsterHandler)
mux.HandleFunc("DELETE /monster/{id}", deleteMonsterHandler)
Method Routing Rules
- If a path has no explicit method defined, it handles any methods that haven't been explicitly defined for that path
- To limit an endpoint to specific methods, you must explicitly define them
- Method definitions require a single space after the method name
- Like path parameters, method-based routing requires Go 1.22
// This handles PATCH requests specifically
mux.HandleFunc("PATCH /monster/{id}", patchMonsterHandler)
// This handles any method that isn't PATCH for the same path
mux.HandleFunc("/monster/{id}", genericMonsterHandler)
Host-Based Routing
You can perform routing based on hostname rather than just path by specifying the host domain in your route definition.
// Handle requests for a specific host
mux.HandleFunc("dreamsofcode.foo/api/monsters", handleMonsters)
You can test this locally with curl by passing in the host header:
curl -H "Host: dreamsofcode.foo" http://localhost:8080/api/monsters
For production use, you'll want to set up proper DNS records and SSL certificates. The example uses a .foo
domain from Porkbun with Let's Encrypt SSL certificates.
Middleware Implementation
Middleware might seem lacking at first glance, but this is where the beauty of the net/http
package really shines. Let's implement a simple logging middleware:
package middleware
import (
"log"
"net/http"
"time"
)
func Logging(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))
})
}
The http.Handler
type is an interface that describes any type with a ServeHTTP
method. Our middleware function accepts an http.Handler
and returns an http.Handler
, allowing us to wrap the original handler with additional functionality.
Capturing Response Status
To also log the HTTP status code, we need to create a wrapper for the response writer:
type WrappedWriter struct {
http.ResponseWriter
StatusCode int
}
func (w *WrappedWriter) WriteHeader(statusCode int) {
w.StatusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &WrappedWriter{
ResponseWriter: w,
StatusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.StatusCode, time.Since(start))
})
}
Middleware Chaining
As your middleware stack grows, your code can become unwieldy. Middleware chaining helps organize multiple middleware functions:
type Middleware func(http.Handler) http.Handler
func CreateStack(middlewares ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
Now you can use it like this:
// Instead of this nested mess:
// handler = middleware.Logging(middleware.Auth(middleware.CORS(router)))
// Use this clean syntax:
stack := middleware.CreateStack(
middleware.Logging,
middleware.Auth,
middleware.CORS,
)
handler = stack(router)
Subrouting
Subrouting enables you to split routing logic across multiple routers. This is particularly useful for API versioning and organizing routes by functionality.
API Versioning Example
// Create a main router
mainRouter := http.NewServeMux()
// Create a V1 API router
v1Router := http.NewServeMux()
v1Router.HandleFunc("GET /monsters", getMonsters)
v1Router.HandleFunc("POST /monsters", createMonster)
v1Router.HandleFunc("GET /monsters/{id}", getMonster)
// Mount the V1 router under the /v1 prefix
mainRouter.Handle("/v1/", http.StripPrefix("/v1", v1Router))
Middleware with Subrouters
Subrouters are also useful for applying different middleware to different route groups:
// Public routes (no authentication required)
publicRouter := http.NewServeMux()
publicRouter.HandleFunc("GET /monsters", getMonsters)
// Admin routes (authentication required)
adminRouter := http.NewServeMux()
adminRouter.HandleFunc("POST /monsters", createMonster)
adminRouter.HandleFunc("DELETE /monsters/{id}", deleteMonster)
// Apply middleware only to admin routes
mainRouter.Handle("/", publicRouter)
mainRouter.Handle("/admin/", middleware.EnsureAdmin(
http.StripPrefix("/admin", adminRouter),
))
Context for Passing Data
The context
package allows you to pass data down through your routing stack. This is particularly useful for passing user information from authentication middleware to handlers.
Setting Context Values in Middleware
package middleware
import (
"context"
"net/http"
)
type contextKey string
const UserIDKey contextKey = "userID"
func IsAuthenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract user ID from authorization header (simplified)
authHeader := r.Header.Get("Authorization")
userID := extractUserID(authHeader) // Your auth logic here
// Add user ID to context
ctx := context.WithValue(r.Context(), UserIDKey, userID)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
Retrieving Context Values in Handlers
func handleProtectedResource(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintf(w, "Hello, user %s!", userID)
}
Key Takeaways
The Go 1.22 standard library now provides everything you need for advanced HTTP routing:
- Path Parameters: Use
{param}
syntax andr.PathValue()
- Method Routing: Prefix routes with HTTP methods
- Host Routing: Specify hostnames in route patterns
- Middleware: Leverage the
http.Handler
interface for clean middleware chains - Subrouting: Use
http.StripPrefix()
for mounting sub-routers - Context: Pass data through the request pipeline using
context.WithValue()
All of these features work together seamlessly and require only Go 1.22+ specified in your go.mod
file. While third-party packages may still be useful for specific use cases, the standard library now covers the vast majority of routing needs for modern Go applications.
Useful Links
- Project Code Repository
- Newsletter: Become a better developer in 4 minutes
- Porkbun Domain Registration - Use code
APPDEVFOO5
for $5 domains
The evolution of Go's HTTP routing capabilities demonstrates the language's commitment to providing powerful tools in the standard library while maintaining simplicity and performance.