You write http.HandleFunc("/hello", hello) and it just works. Then you read the docs and meet Handler, HandlerFunc, ServeHTTP and ServeMux, and nothing feels obvious anymore. This article removes the fog, one proof at a time.
Why I'm writing this
I spent months trying to understand how Go's http.HandleFunc really works. I read the docs, read articles, and asked friends, and I still couldn't connect the pieces. I only make progress when I can prove an idea to myself, so what finally helped was building small experiments until each claim held up. This article is that path, shortened, and every idea comes with something you can run.
The whole idea in four sentences. Go's server only knows how to call one thing: the ServeHTTP method of an http.Handler. Your plain function has no such method. http.HandlerFunc is a function type that does have that method, and the method simply calls the function. HandleFunc wraps your function in that type so you never have to write the boilerplate yourself.
Why this is confusing in the first place
Three names look almost identical and mean three different things. Most of the confusion in the Go community comes from mixing them up, so let's pin them down first.
http.Handleris an interface. Anything with aServeHTTP(ResponseWriter, *Request)method. This is the only thing the server knows how to call.http.HandlerFuncis a type. A function type,func(ResponseWriter, *Request), that has aServeHTTPmethod attached. It is the adapter.http.HandleFuncis a function. A convenience that wraps your function inHandlerFuncand registers it on the default router.
Only the last one differs by a single letter from the one before it, and that is exactly why it trips people up. Keep these three in your head as we go.
What happens when a request arrives
Before talking about adapters, here is the journey of one request. Watch the dot: it is the request moving through the server.
Notice the last line of the diagram. ServeMux, the router, is itself just an http.Handler. It receives a request, decides which registered handler should take it, and calls that handler's ServeHTTP. Everything is one interface, all the way down. That small design decision is what makes middleware, testing and routers composable.
The interface, in one glance
// net/http: this is the entire contract
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}One method. If your type has it, the server can use it. That's the whole requirement.
Routing without HandleFunc
Let's feel the pain the helper removes. Imagine HandleFunc and HandlerFunc did not exist. You still have the Handler interface, so you have two honest options.
Option A: one big handler with a switch
type app struct{}
func (app) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/hello":
fmt.Fprintln(w, "Hello!")
case "/about":
fmt.Fprintln(w, "About us")
default:
http.NotFound(w, r)
}
}
func main() {
log.Fatal(http.ListenAndServe(":8080", app{}))
}It works, but every new route edits the same function. Method checks, path parameters and 404 handling pile up in one place, and you are slowly writing your own router.
Option B: one struct per route, with a router
type helloHandler struct{}
func (helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello!")
}
type aboutHandler struct{}
func (aboutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "About us")
}
func main() {
mux := http.NewServeMux()
mux.Handle("/hello", helloHandler{})
mux.Handle("/about", aboutHandler{})
log.Fatal(http.ListenAndServe(":8080", mux))
}This is cleaner, but look at the ceremony: an empty struct and a method declaration, just to say "run this function". Ten routes means ten empty structs. It is correct Go and it is exhausting.
Option B at scale: the type explosion
Option B looks harmless with two routes. Now build something closer to a real API:
type listUsers struct{}
type createUser struct{}
type getUser struct{}
type deleteUser struct{}
type health struct{}
func (listUsers) ServeHTTP(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (createUser) ServeHTTP(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (getUser) ServeHTTP(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (deleteUser) ServeHTTP(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (health) ServeHTTP(w http.ResponseWriter, r *http.Request) { /* ... */ }
mux.Handle("GET /users", listUsers{})
mux.Handle("POST /users", createUser{})
mux.Handle("GET /users/{id}", getUser{})
mux.Handle("DELETE /users/{id}", deleteUser{})
mux.Handle("GET /health", health{})Five routes, five type declarations, five method declarations. And the cause is a hard rule: a type can have only one ServeHTTP method. One type means one behaviour, so every route that behaves differently needs its own type. A real service has dozens of routes, which means dozens of empty types that exist only to carry a method name.
The problem, stated plainly
- Option A keeps one type but crams every route into one growing function.
- Option B separates the routes but costs one type and one method per route.
- Neither lets you organise by resource. You'd like one
UserHandlertype that owns everything about users: list, create, get, delete. But a type has a singleServeHTTP, so those four behaviours can't live on it as four methods. Your user logic gets scattered across four unrelated types, and the only workaround is aswitchinsideUserHandler.ServeHTTP, which is Option A again in miniature.
Now notice the absurd part. Each of those handler bodies is already a function with exactly the signature of ServeHTTP. We only wrap them in types because the interface insists on a method. What we really want is a way to say: "this plain function should count as a Handler."
Enter HandlerFunc: the fix
That is exactly the question the standard library asked: "My routes are already functions with the exact shape of ServeHTTP. Can I make a function satisfy an interface?" The answer is yes, and it takes four lines.
In Go, only types can have methods, and any named type can, including a function type. So the library defines a function type and gives it the method you were about to write by hand. Think of a travel power adapter: your plug (the function) has the right pins but the wrong shape for the wall socket (the interface). The adapter changes the shape without changing the electricity.
This is the actual code from the standard library, and it is shorter than most people expect:
// A function type...
type HandlerFunc func(ResponseWriter, *Request)
// ...with a method that just calls itself.
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}Read the method body again: f(w, r). The receiver f is your function, so the method calls it. There is no magic and no reflection. This pattern is even called out in Effective Go as an example of how types, not just structs, can implement interfaces.
The same five-route API, rescued
mux.HandleFunc("GET /users", listUsers) // func listUsers(w, r)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
mux.HandleFunc("GET /health", health)Five plain functions, zero extra types. The behaviour moved out of the type and into a function value, and one type, HandlerFunc, can hold any number of different functions because the function is just data stored inside it. Instead of one type per behaviour, you get one type that carries the behaviour as a value.
Bonus: one controller per resource
There is a second payoff, and in real projects it matters even more. Because HandleFunc accepts any function with the handler signature, a method on your own type qualifies too. So one type can own a whole resource, with one method per route:
type UserHandler struct {
store UserStore // shared dependency, available to every method
}
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) { /* uses h.store */ }
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) { /* ... */ }
func (h *UserHandler) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /users", h.List)
mux.HandleFunc("POST /users", h.Create)
mux.HandleFunc("GET /users/{id}", h.Get)
mux.HandleFunc("DELETE /users/{id}", h.Delete)
}The expression h.List is a method value: a plain func(w, r) with h already bound to it. That is exactly the shape HandleFunc wants, so it wraps it in HandlerFunc like any other function. Compare the type count: the struct-per-route approach needed one type for every route, while this needs one type for every resource. Your types now mirror your domain (UserHandler, OrderHandler), not your URL table.
And if you want a shared interface across controllers, you can define a tiny one for registration. It has nothing to do with ServeHTTP:
type routable interface {
Routes(mux *http.ServeMux)
}
mux := http.NewServeMux()
for _, c := range []routable{
&UserHandler{store: users},
&OrderHandler{store: orders},
} {
c.Routes(mux)
}This is the structure most production Go services settle on: one type per resource holding its dependencies, one method per route, and a single place that wires them into the router. None of it is possible if each route must be its own ServeHTTP type.
So what does http.HandleFunc actually do?
Just three hops. The package-level function forwards to the default router, which wraps your function and registers it as a regular Handler.
HandleFunc registers the same thing Handle does, after one conversion. HandlerFunc(f) is the entire trick.// Simplified from net/http (Go 1.22)
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
mux.register(pattern, HandlerFunc(handler)) // the adapter
}Compare that with Option B above: HandlerFunc(handler) is the empty struct and the method, collapsed into a single conversion. Same behaviour, no boilerplate. That is the reason behind the design. (register is internal. Handle ends up in the same place, so HandleFunc(p, f) behaves exactly like Handle(p, HandlerFunc(f)). Older Go versions literally called mux.Handle(pattern, HandlerFunc(handler)).)
The same program, finished:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello!")
})
mux.HandleFunc("/about", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "About us")
})
log.Fatal(http.ListenAndServe(":8080", mux))
}Don't take my word for it: four experiments
Reading about this only goes so far. Each experiment below proves one claim and takes under a minute. Run them with go run and curl.
Experiment 1: a bare function is not a Handler
f := func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "hi") }
var h http.Handler = f // compile error
// cannot use f (variable of type func(w http.ResponseWriter, r *http.Request))
// as http.Handler value in variable declaration: func(w http.ResponseWriter, r *http.Request)
// does not implement http.Handler (missing method ServeHTTP) (wording varies slightly by Go version)The compiler confirms it: the function has no ServeHTTP. Now wrap it:
var h http.Handler = http.HandlerFunc(f) // compiles
fmt.Printf("%T\n", h) // http.HandlerFuncExperiment 2: build your own HandlerFunc
If this is truly all there is, you can write it yourself and the server won't care:
type MyHandlerFunc func(http.ResponseWriter, *http.Request)
func (f MyHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f(w, r)
}
func hello(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "Hello from my own adapter") }
func main() {
log.Fatal(http.ListenAndServe(":8080", MyHandlerFunc(hello)))
}Run it and curl localhost:8080. You just reimplemented the standard library's adapter in four lines.
Experiment 3: call ServeHTTP yourself, no server needed
// add "net/http/httptest" to your import block
req := httptest.NewRequest("GET", "/hello", nil)
rec := httptest.NewRecorder()
http.HandlerFunc(hello).ServeHTTP(rec, req) // you are the server now
fmt.Println(rec.Body.String())This is also why Go HTTP handlers are so easy to unit test: a handler is just something with ServeHTTP, so you can call it directly with a fake request and a recorder.
Experiment 4: handlers wrapping handlers (middleware)
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 took %v", r.Method, r.URL.Path, time.Since(start))
})
}
mux.Handle("/hello", logging(http.HandlerFunc(hello)))Here HandlerFunc appears again, this time letting you write middleware as a plain closure. Because every layer is an http.Handler, layers stack in any order. This is the payoff of the one-method interface.
"Why not just use my own type?"
A fair question, and Experiment 2 already proved the answer is: you can. HandlerFunc has no special powers. The server only checks that a value has a ServeHTTP method, and it can't tell your type from the standard library's. So why does HandlerFunc exist at all?
- It saves everyone from rewriting the same four lines. If every project, library and middleware defined its own adapter, you would be converting between near-identical types all day. One shared adapter is a shared convention.
- The helpers are built around it.
HandleFunctakes a plainfunc(w, r)and converts it for you. That convenience only exists because the standard library owns the adapter type. - Its signature is the ecosystem's default. Middleware like
loggingabove returnshttp.HandlerFunc, and tests callhttp.HandlerFunc(h).ServeHTTP(...). Everyone expects that shape.
A detail worth being precise about: defining one function type of your own, like MyHandlerFunc in Experiment 2, does not cause the type explosion from Option B. You declare it once and reuse it for every route, exactly like the standard one. The explosion comes from using a struct (or any separate named type) for each route's behaviour. Your own function type simply gives you the same benefit as HandlerFunc, minus the built-in HandleFunc helper and the shared convention.
Your own type becomes the better choice when the standard shape isn't enough. The two common cases:
Case 1: your handlers need to return errors
type appHandler func(http.ResponseWriter, *http.Request) error
func (f appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := f(w, r); err != nil {
log.Println(err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
mux.Handle("/hello", appHandler(hello)) // hello returns errorSame adapter idea, different function signature. Now error handling lives in one place instead of being repeated in every handler. Note the use of Handle here, not HandleFunc, because appHandler is not the plain func(w, r) shape.
Case 2: your handlers carry dependencies
type api struct {
db *sql.DB
}
func (a api) ServeHTTP(w http.ResponseWriter, r *http.Request) { /* uses a.db */ }
mux.Handle("/users", api{db: db})A function can't hold state of its own, but a struct can. Often you don't even need a full Handler: a method value such as mux.HandleFunc("/users", a.listUsers) gives you a plain function that closes over a, so HandleFunc works again.
Rule of thumb: use HandleFunc or HandlerFunc when the standard signature is enough. Define your own type with ServeHTTP when you need a different signature or state that the standard one can't carry.
A mental model that sticks
| Question | Answer |
|---|---|
| What does the server call? | handler.ServeHTTP(w, r), always. |
| Why can't I pass a function directly? | A bare function has no methods, so it doesn't satisfy http.Handler. |
What is HandlerFunc? | A function type with a ServeHTTP method that calls itself. |
What is HandleFunc? | A helper equivalent to Handle(pattern, HandlerFunc(f)). |
When do I use Handle instead? | When you already have a value with a ServeHTTP method, such as a struct holding dependencies. |
Modern routing and one honest warning
Since Go 1.22, the standard ServeMux understands HTTP methods and path parameters, so many projects no longer need a third-party router. The adapter works exactly the same way. One gotcha: these patterns only take effect when your go.mod declares go 1.22 or newer. With an older go line, "GET /items/{id}" is not treated as a method and wildcard pattern, and the route quietly returns 404.
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "item %s\n", id)
})And the warning: http.HandleFunc and ListenAndServe(addr, nil) use a single global router, DefaultServeMux. Any imported package can register routes on it, which is why many teams prefer to create their own with http.NewServeMux() in real services.
Recap
Go didn't hide a framework inside net/http. It defined a one-method interface, noticed that functions are the most natural way to write handlers, and added a four-line adapter so functions could join in. Once you see that HandlerFunc(f) is a type conversion and not a function call, the rest of the package stops being mysterious.
If you learn best by proving things, as I do, take the four experiments above and break them on purpose. Delete the method, change the signature, read the compiler errors. That is where the understanding actually forms.
Docs and references
- net/http: type Handler, the interface
- net/http: type HandlerFunc, the adapter
- net/http: func HandleFunc and ServeMux.HandleFunc
- net/http: type ServeMux, pattern rules and matching
- net/http/httptest, for testing handlers without a server
- Routing Enhancements for Go 1.22 (Go blog)
- Effective Go: Interfaces and other types
- Go specification: Method declarations and Function types
- A Tour of Go: Interfaces
- Go by Example: HTTP Server
- Source: net/http/server.go, where all of this lives
- Alex Edwards: Making and using HTTP middleware