Go 1.22 added path parameters to net/http. You no longer need a third-party router for basic routing.
Estimated Reading Time : 7m
What changed
Before Go 1.22, http.ServeMux only supported fixed paths and prefix matching. If you wanted to extract an ID from /users/42, you had to parse the URL yourself or reach for a third-party router like gorilla/mux or chi.
Go 1.22 added two features to the default mux:
- Path parameters —
{name}placeholders in route patterns - Method matching — prefix routes with
GET,POST, etc.
Basic usage
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "User ID: %s", id)
})
http.ListenAndServe(":8080", mux)
{id} captures the path segment and r.PathValue("id") retrieves it. The value is always a string — you parse it to the type you need.
Method matching
You can now prefix a pattern with an HTTP method:
mux.HandleFunc("GET /posts", listPosts)
mux.HandleFunc("POST /posts", createPost)
mux.HandleFunc("GET /posts/{id}", getPost)
mux.HandleFunc("PUT /posts/{id}", updatePost)
mux.HandleFunc("DELETE /posts/{id}", deletePost)
A POST to /posts won’t match the GET /posts handler. Before Go 1.22, you’d handle all methods in one handler and switch on r.Method.
Without a method prefix, the pattern matches all methods — the same behavior as before.
Wildcard matching
A {name...} parameter with a trailing ... matches the rest of the path:
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
filePath := r.PathValue("path")
// /files/images/photo.jpg → filePath = "images/photo.jpg"
fmt.Fprintf(w, "File: %s", filePath)
})
This is useful for file servers, proxy routes, or any path with variable depth.
Precedence
When multiple patterns could match, the most specific one wins:
mux.HandleFunc("GET /posts/{id}", getPost) // matches /posts/42
mux.HandleFunc("GET /posts/latest", getLatest) // matches /posts/latest
/posts/latest matches the literal route, not the parameterized one. The rule is: exact segments beat wildcards.
If two patterns overlap and neither is more specific, ServeMux panics at registration time — you’ll catch it immediately, not at runtime.
A practical example
A minimal REST API:
package main
import (
"encoding/json"
"net/http"
"sync"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
var (
mu sync.Mutex
users = map[string]User{}
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
user, ok := users[r.PathValue("id")]
mu.Unlock()
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
})
mux.HandleFunc("POST /users/{id}", func(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user.ID = r.PathValue("id")
mu.Lock()
users[user.ID] = user
mu.Unlock()
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
})
mux.HandleFunc("DELETE /users/{id}", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
delete(users, r.PathValue("id"))
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8080", mux)
}
No dependencies, no router library, just the standard library.
Do you still need a third-party router?
For many applications, no. The new ServeMux covers the common cases: path parameters, method matching, and wildcard segments.
You’d still reach for chi or similar if you need:
- Middleware chaining —
ServeMuxdoesn’t have built-in middleware composition - Route groups — prefixing a set of routes with a common path
- Regex constraints —
{id:[0-9]+}style parameter validation - Named routes — generating URLs from route names
But for straightforward APIs and web services, the standard library is now enough.