How to Handle Routes in Go with net/http

Web
Handle routes in Go by mapping URL patterns to functions using http.HandleFunc and starting the server with http.ListenAndServe.

The gap between function and URL

You have a function that calculates the price of a coffee based on size and milk type. You want the world to call it by visiting localhost:8080/coffee. You write the function, you start the server, but nothing happens when you hit the URL. The gap between "function" and "URL" is routing.

In Go, routing isn't a separate library you bolt on. It's built into the standard library, and it works differently than the framework routers you might know from Python or JavaScript. There is no @app.route decorator. There is no central router file with regex patterns. The net/http package provides a simple pattern matcher that maps URL paths to handler functions. You register patterns, you start the server, and the server dispatches requests to your handlers.

Routing bridges your code to the network.

How the standard library router works

The router in net/http is a pattern matcher. You give it a string pattern and a handler. When a request arrives, the router walks through your registered patterns and picks the longest match. The pattern syntax is minimal. There are no dynamic segments like :id or regex groups. Patterns are either exact matches or prefix matches.

If the pattern ends with a slash, it matches the path and any sub-paths. If you register /api/, the router matches /api/, /api/users, and /api/users/123. If the pattern does not end with a slash, it matches exactly. If you register /health, it only matches /health. A request to /health/check will not match.

The router also handles trailing slash redirects. If you register /api/ and a client hits /api, the router automatically redirects to /api/. This behavior catches developers off guard when they register /api expecting it to match sub-paths. It won't. The slash at the end signals that the path is a directory prefix.

Patterns are prefixes, not regex. Match the path or match nothing.

Minimal example

Here's the smallest working server. It registers one route and starts listening.

package main

import (
	"fmt"
	"net/http"
)

// main starts the HTTP server and registers a single route.
func main() {
	// Registers the root path to the anonymous handler function.
	// The pattern "/" matches the root URL exactly.
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Writes the response body directly to the client.
		// Fprintf formats the string and writes to the ResponseWriter.
		fmt.Fprintf(w, "Hello from %s", r.URL.Path)
	})

	// Starts the server on port 8080.
	// Passing nil tells the server to use the DefaultServeMux.
	http.ListenAndServe(":8080", nil)
}

The http.HandleFunc call adds an entry to a global registry. The http.ListenAndServe call starts accepting connections and dispatching them. The nil argument is the key. It tells the server to use the default registry. If you pass a custom http.Handler, the server uses that instead.

Pass nil to use the global registry. Pass a handler to use your own.

Walkthrough: what happens at runtime

When you call http.HandleFunc, you aren't starting the server. You're updating a package-level variable called DefaultServeMux. This variable is an instance of http.ServeMux, which implements the http.Handler interface. Every call to HandleFunc adds a pattern and handler to this map.

When http.ListenAndServe runs, it creates a http.Server struct. If you passed nil, the server sets its handler to DefaultServeMux. The server then binds to the port and starts a loop accepting TCP connections. For each connection, it spawns a goroutine to handle the request.

The goroutine reads the HTTP request, extracts the method and path, and calls the ServeHTTP method on the handler. The ServeMux looks up the path in its map. It finds the longest matching pattern and calls the associated handler function. Your function writes to the http.ResponseWriter, which sends the bytes back over the network.

The http.ResponseWriter is an interface with two main methods. Write sends the response body. Header returns a map for setting response headers. Headers must be sent before the body. The first call to Write or WriteHeader flushes the headers. If you try to set a header after writing the body, the change is ignored.

Every *http.Request carries a context.Context. You access it via r.Context(). This context is cancelled when the client disconnects or the server times out. Always pass r.Context() to downstream calls so they can stop work when the request ends. This prevents goroutine leaks and wasted resources.

Context is plumbing. Run it through every long-lived call site.

Realistic example: struct handlers and state

Real code usually involves state and multiple routes. Functions can't hold state, but structs can. The http.Handler interface requires a ServeHTTP method. Any type with that method can be a handler. Structs let you bundle data with behavior.

Here's a handler that carries an API version string and responds with JSON.

package main

import (
	"encoding/json"
	"net/http"
)

// API holds state for the handler.
type API struct {
	// Version stores the API version string.
	Version string
}

// ServeHTTP handles requests for the API.
// The receiver name a follows Go convention: short, matching the type.
func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// Check the request method to enforce API constraints.
	if r.Method != http.MethodGet {
		// http.Error writes a response with the given status and text.
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	// Encode the response as JSON.
	data := map[string]string{"version": a.Version}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(data)
}

// main registers the struct handler and starts the server.
func main() {
	// Create an instance of the handler with state.
	api := &API{Version: "1.0.0"}

	// Register the handler for the /api path.
	// The pattern /api/ matches /api/ and any sub-paths.
	http.Handle("/api/", api)

	// Start the server.
	http.ListenAndServe(":8080", nil)
}

The http.Handle function takes a pattern and an http.Handler. It doesn't wrap functions. If you pass a function to http.Handle, the compiler rejects the program with cannot use handlerFunc (type func(http.ResponseWriter, *http.Request)) as type http.Handler in argument. Use http.HandleFunc for functions, or wrap your function with http.HandlerFunc(handlerFunc).

http.HandlerFunc is an adapter type. It implements http.Handler by calling the wrapped function. This is how HandleFunc works under the hood. You can use this adapter manually if you need to pass a function where a handler is expected.

Accept interfaces, return structs. Handlers are often structs carrying dependencies.

Pitfalls and compiler errors

Trailing slashes control sub-path matching. If you register /api, the router only matches /api. A request to /api/users returns 404. If you register /api/, the router matches /api/ and everything under it. The router also redirects /api to /api/ when you register the slash version. This redirect is automatic and can confuse clients that don't follow redirects. Register the slash if you want children.

The compiler enforces the handler interface strictly. If you define a struct with a ServeHTTP method but forget the pointer receiver, the type doesn't implement http.Handler. The compiler complains with API does not implement http.Handler (ServeHTTP method has pointer receiver). Use a pointer receiver for handlers that need to modify state, or a value receiver if the handler is immutable.

Goroutine leaks happen when a handler spawns a goroutine that outlives the request. If the goroutine waits on a channel or context that never resolves, it stays alive. Always pass r.Context() to background work and check for cancellation. The worst goroutine bug is the one that never logs.

The DefaultServeMux is global. Routes registered in init() or main() affect the whole program. This is convenient for small apps but makes testing harder. You can create a new http.NewServeMux() to isolate routes.

// main creates an isolated mux to avoid global state.
func main() {
	// NewServeMux creates a fresh router instance.
	// This keeps routes local to this function.
	mux := http.NewServeMux()

	// Register routes on the local mux.
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	// Pass the mux to ListenAndServe.
	// The server uses this mux instead of the global default.
	http.ListenAndServe(":8080", mux)
}

Trailing slashes matter. Register the slash if you want sub-paths.

When to use what

Use http.HandleFunc when you have a simple function and want to register it quickly without boilerplate.

Use http.Handle when your handler is a struct or a custom type that implements http.Handler, allowing you to carry state or dependencies.

Use a custom http.ServeMux when you need to group routes or avoid the global DefaultServeMux for better testability.

Use a third-party router like chi or gorilla/mux when you need path parameters, regex patterns, or middleware chains that the standard library doesn't provide.

The standard library router does exactly what it says. No magic, no hidden middleware.

Where to go next