Back to Blog

The net/http package is now all you need

Dreams of Code Logo
Dreams of Code
May 16, 2025
The net/http package is now all you need

Advanced HTTP Routing in Go 1.22: Beyond the Basics

Since Go 1.22 was released, the net/http package is now all you need for advanced HTTP routing. But knowing how to use the HTTP ServeMux type can be rather elusive, especially for advanced features such as middleware, subrouting, path parameters, HTTP methods and passing through context.

In this comprehensive guide, we're going to look at how to implement each of these features using only the Go standard library.

Path Parameters

The first implementation we're going to look at is path parameters. To add a path parameter to a route is pretty similar to other frameworks, such as Gorilla Mux or Chi, and involves wrapping the path component you want to parametize in braces with the name of the parameter inside.

go
package main

import (
    "fmt"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    
    // 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)
    })
    
    http.ListenAndServe(":8080", mux)
}

In this example, we've added the path parameter of id to our /item path. With our parameter defined, we can then pull this out inside of our handler by using the PathValue method of the request type.

If we run this code and send the following requests, our endpoint is now returning the last path component to us, which is what's being captured in the id path parameter:

bash
curl http://localhost:8080/item/123
# Returns: Item ID: 123

curl http://localhost:8080/item/abc
# Returns: Item ID: abc

Important Version Requirement

One thing to note, however, is that in order to have access to path parameters, you'll need to make sure you're using Go 1.22 and that you have Go 1.22 specified in your go.mod file. Any earlier versions won't have access to this feature.

go
// go.mod
module your-project

go 1.22

Path Conflicts and Precedence

Whilst setting up path parameters is rather simple, there is one caveat to be aware of: conflicting paths and precedence. For example, here I have two routes that conflict with one another:

go
mux.HandleFunc("/item/{id}", handleItem)
mux.HandleFunc("/item/latest", handleLatest)

Despite this, however, if I send a request to the path ending in /latest, it'll still be routed to the correct handler, even though both of the registered paths match. This works because Go determines which path is correct based on a precedence ordering of most specific wins. In our case, that's the path that ends in /latest.

In rare cases, however, it's difficult for Go to determine which is the more specific path. Take the following two paths as an example:

go
mux.HandleFunc("/posts/{category}", handleCategory)
mux.HandleFunc("/posts/{id}", handlePost)

If I sent a request to /posts/latest, which one would resolve? In this case, they're both as specific as each other, each having one path parameter. If we try and run this code, however, Go will detect the conflict and panic when we try to register our paths. Ultimately, this is a good thing, as it prevents any requests being routed to the wrong handler.

HTTP Method Routing

The next feature for advanced HTTP routing is the ability to easily handle different HTTP methods. Before version 1.22, this was done by having to perform a check on the request inside of the HTTP handler. Whilst it worked, it was pretty tedious.

Now, however, it's pretty easy. All we have to do is define the method at the start of the matcher string:

go
func main() {
    mux := http.NewServeMux()
    
    // Method-specific routing
    mux.HandleFunc("POST /monsters", createMonster)
    mux.HandleFunc("GET /monsters/{id}", getMonster)
    mux.HandleFunc("PUT /monsters/{id}", updateMonster)
    mux.HandleFunc("DELETE /monsters/{id}", deleteMonster)
    
    http.ListenAndServe(":8080", mux)
}

func createMonster(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Creating a new monster")
}

func getMonster(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "Getting monster with ID: %s", id)
}

By adding the POST method to the start of the path, the createMonster handler will only be invoked for requests that contain a POST method. Method-based routing can also be set up for the other HTTP methods, such as PUT, GET, DELETE, PATCH and OPTIONS.

Method Fallback Behavior

If a path has no explicit method defined, then it will handle any methods that haven't been explicitly defined for that path. For example:

go
mux.HandleFunc("PUT /monsters/{id}", updateMonster)
mux.HandleFunc("/monsters/{id}", handleAnyOtherMethod)

Here I have two entries to the /monsters/{id} endpoint. The first is set up to explicitly handle a PUT request. However, the second has no explicit method defined, and therefore will be routed to for any HTTP method that isn't a PUT. In this example, you can see the handler is being called for GET, POST, DELETE and even PATCH.

To limit an endpoint to a specific method, you'll need to explicitly define it:

go
mux.HandleFunc("GET /monsters/{id}", getMonster)
// Now only GET requests will work, others will return "Method Not Allowed"

Important Notes

  • When defining a method for your path, it requires a single space after it. Anything more than a single space will cause the route to no longer match.
  • As was the case with path parameters, method-based routing also requires Go 1.22 to be specified inside of the project's go.mod file. If an earlier version of Go is specified, then your expected endpoints will return a 404.

Hostname-Based Routing

The next advanced routing feature is to perform handling based on a hostname, rather than just a path. We can achieve this by passing in the host domain that we want the router to handle:

go
func main() {
    mux := http.NewServeMux()
    
    // Hostname-specific routing
    mux.HandleFunc("dreamsofcode.foo/api/monsters", handleMonsters)
    
    http.ListenAndServe(":8080", mux)
}

In this case, I'm setting this to be dreamsofcode.foo which will handle any requests sent to that host. We can test this locally with curl by passing in the host header:

bash
curl -H "Host: dreamsofcode.foo" http://localhost:8080/api/monsters

Middleware Implementation

The next feature is actually the one that was most requested: middleware, and how to add it with http.ServeMux. On initial thoughts it may seem that this feature is lacking, but this is where the beauty of the net/http package really shines.

Let's look at how to do this by first adding a simple logging middleware:

go
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.Println(r.Method, r.URL.Path, time.Since(start))
    })
}

To create middleware, we first create a new function that accepts an http.Handler as its parameter and returns an http.Handler as its result. The http.Handler type is actually an interface in the standard library, which describes any type that has the function ServeHTTP. These are the building blocks of HTTP routing when it comes to Go.

Inside of our middleware function, we can then return a new http.HandlerFunc. This type allows us to easily wrap a closure, conforming it to the http.Handler interface.

Now we need to add it into our routing stack:

go
package main

import (
    "net/http"
    "your-project/middleware"
)

func main() {
    mux := http.NewServeMux()
    
    mux.HandleFunc("GET /monsters", getMonsters)
    mux.HandleFunc("POST /monsters", createMonster)
    
    // Wrap the entire router with logging middleware
    wrappedMux := middleware.Logging(mux)
    
    http.ListenAndServe(":8080", wrappedMux)
}

As the http.ServeMux router conforms to the http.Handler interface, we can parse it as the argument to our middleware function. This essentially creates a new router that is wrapped in the logging middleware.

Enhanced Logging with Status Codes

One improvement we can make is to also log the HTTP status of the response as well. However, if we try to do this, we run into an issue. The http.ResponseWriter type doesn't provide us a method to read the HTTP status code. Fortunately, because this is an interface, there is a way to expose it:

go
package middleware

import (
    "log"
    "net/http"
    "time"
)

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.Println(r.Method, r.URL.Path, wrapped.StatusCode, time.Since(start))
    })
}

Inside of our middleware package, we create a new type called a WrappedWriter, which itself extends an http.ResponseWriter, but also contains a status code property. We then implement the WriteHeader method in order to intercept and capture the given HTTP status code.

Middleware Chaining

When building API services, you'll often use multiple pieces of middleware in your stack. As this stack starts to grow, your code will look less like Go and more like Lisp:

go
// This gets unwieldy quickly
wrappedMux := middleware.CORS(middleware.Auth(middleware.Logging(mux)))

To tidy this up, we can use something called middleware chaining, which turns our code into something more readable:

go
package middleware

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, if we head over to our main function, we can refactor our previously wrapped code using this CreateStack function:

go
func main() {
    mux := http.NewServeMux()
    
    mux.HandleFunc("GET /monsters", getMonsters)
    mux.HandleFunc("POST /monsters", createMonster)
    
    stack := middleware.CreateStack(
        middleware.Logging,
        middleware.CORS,
        middleware.Auth,
    )
    
    wrappedMux := stack(mux)
    
    http.ListenAndServe(":8080", wrappedMux)
}

Subrouting

The next feature to implement is subrouting, which enables us to split our routing logic across multiple routers. Let's start with this example, which is a simple router that has some paths already configured to perform CRUD operations on a monster resource:

go
func main() {
    // Create a subrouter for monster operations
    monsterRouter := http.NewServeMux()
    monsterRouter.HandleFunc("GET /monsters", getMonsters)
    monsterRouter.HandleFunc("POST /monsters", createMonster)
    monsterRouter.HandleFunc("GET /monsters/{id}", getMonster)
    monsterRouter.HandleFunc("PUT /monsters/{id}", updateMonster)
    monsterRouter.HandleFunc("DELETE /monsters/{id}", deleteMonster)
    
    // Main router with versioning
    mainRouter := http.NewServeMux()
    mainRouter.Handle("/v1/", http.StripPrefix("/v1", monsterRouter))
    
    http.ListenAndServe(":8080", mainRouter)
}

Let's say we receive a ticket that specifies that this API should be versioned, under a /v1 path prefix. Instead of adding this prefix to each path manually, we can use another router to achieve this.

To do so, we call the Handle function on our new router, passing in the /v1/ path prefix. In order for this to work, it needs to have the trailing slash as well. For the handler, we need to use the http.StripPrefix function to remove the /v1 from the path before it's sent to our next router.

Now when we run this code and send curl requests to the /v1/monsters endpoints:

bash
curl http://localhost:8080/v1/monsters
curl -X POST http://localhost:8080/v1/monsters
curl http://localhost:8080/v1/monsters/123

We can see that our requests are being handled as expected.

Middleware with Subrouters

As well as nesting paths, subrouters are also useful when it comes to middleware. For example, we can have two different routers, one that's intended to be used by anybody, and the other containing routes that are restricted to admins only:

go
func main() {
    // Public routes
    publicRouter := http.NewServeMux()
    publicRouter.HandleFunc("GET /monsters", getMonsters)
    
    // Admin routes
    adminRouter := http.NewServeMux()
    adminRouter.HandleFunc("POST /monsters", createMonster)
    adminRouter.HandleFunc("DELETE /monsters/{id}", deleteMonster)
    
    // Main router
    mainRouter := http.NewServeMux()
    mainRouter.Handle("/", publicRouter)
    mainRouter.Handle("/admin/", http.StripPrefix("/admin", 
        middleware.EnsureAdmin(adminRouter)))
    
    http.ListenAndServe(":8080", mainRouter)
}

In order to require authorization to our admin routes, we can add the handler to our router, wrapping the admin routes in the EnsureAdmin middleware. Our GET request doesn't require an admin credential, but our POST requests do.

Passing Data Through Context

The last feature we'll look at is how to pass data down through your routing stack. For example, let's say we have an isAuthenticated middleware that will pull out and validate the user's information from an authorization header. Let's improve this middleware by making the user ID available to any downstream handlers.

We can achieve this by making use of the context type from the context package:

go
package middleware

import (
    "context"
    "net/http"
    "strings"
)

type contextKey string

const UserIDKey contextKey = "userID"

func IsAuthenticated(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Authorization header required", http.StatusUnauthorized)
            return
        }
        
        // Extract user ID from auth header (simplified example)
        userID := strings.TrimPrefix(authHeader, "Bearer ")
        
        // Add user ID to context
        ctx := context.WithValue(r.Context(), UserIDKey, userID)
        r = r.WithContext(ctx)
        
        next.ServeHTTP(w, r)
    })
}

Every HTTP request has an associated context, which we can easily extend and override. We first define a unique key that we can use to both set and get this value from our request's context. Then we call the WithValue method of the context package, taking in the request's context, our new key, and our user ID value.

This method returns a new child context, which contains the key-value pair inside. We need to assign this new context value to our request using the WithContext method, passing in our new context value.

Now we need to pull the user ID out of the context in the handler function:

go
func getMonster(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    
    // Extract user ID from context
    userID, ok := r.Context().Value(middleware.UserIDKey).(string)
    if !ok {
        http.Error(w, "User ID not found in context", http.StatusInternalServerError)
        return
    }
    
    fmt.Fprintf(w, "Getting monster %s for user %s", id, userID)
}

Because context.Context stores values in a type-unsafe manner, we need to perform a type cast. We can verify that this type cast was okay by capturing a second return value, which is a boolean describing whether or not the cast was successful.

Now if we run this code and send a curl request with an authorization header:

bash
curl -H "Authorization: Bearer user123" http://localhost:8080/monsters/456

Our handler is able to access the user ID contained due to the middleware extracting the value.

Conclusion

With that, we've managed to implement advanced routing capabilities by only using the standard library. The Go 1.22 net/http package now includes:

  • Path parameters with automatic extraction
  • Method-based routing with explicit HTTP verb handling
  • Hostname-based routing for multi-tenant applications
  • Flexible middleware patterns with easy chaining
  • Subrouting capabilities for modular route organization
  • Context passing for sharing data between middleware and handlers

For some things, you may still want to consider using a third-party package like Gorilla Mux, Chi, or Gin. However, if you decide you want to stick with the standard library, the net/http package now has everything you need to build robust HTTP APIs.

Further Reading

Get Support