Go's http.HandleFunc, demystified: the tiny trick behind every route

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.Handler is an interface. Anything with a ServeHTTP(ResponseWriter, *Request) method. This is the only thing the server knows how to call.
  • http.HandlerFunc is a type. A function type, func(ResponseWriter, *Request), that has a ServeHTTP method attached. It is the adapter.
  • http.HandleFunc is a function. A convenience that wraps your function in HandlerFunc and 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.

Clienthttp.ServerServeMuxYour handlerGET /helloparses the requestmatches "/hello".ServeHTTP(w, r)Every hop speaks the same language: http.Handler
Figure 1. The router itself is a Handler too. It receives the request, picks a child Handler, and passes it on.

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 UserHandler type that owns everything about users: list, create, get, delete. But a type has a single ServeHTTP, 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 a switch inside UserHandler.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.

http.Handlerneeds aServeHTTP(w, r)✕✓HandlerFunc(...)func(w, r)your plain function+ ServeHTTP calls f(w, r)A bare function does not fit. Wrapped in HandlerFunc, it does.
Figure 2. The plug never changes. It just gets a type that carries the missing method.

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.

One type per route5 routes = 5 types + 5 ServeHTTP methodsOne adapter for all routes5 routes = 5 functions + 1 typetype listUsers structServeHTTPtype createUser structServeHTTPtype getUser structServeHTTPtype deleteUser structServeHTTPtype health structServeHTTPfunc listUsers(w, r)func createUser(w, r)func getUser(w, r)func deleteUser(w, r)func health(w, r)HandlerFuncone type,one ServeHTTP
Figure 3. Methods attach to types, so separate behaviours as structs mean separate types. A function value lets one type carry them all.

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.

http.HandleFuncpackage-level helpermux.HandleFuncon DefaultServeMuxmux.register(p,HandlerFunc(f))the only real work: one type conversion
Figure 4. 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.HandlerFunc

Experiment 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. HandleFunc takes a plain func(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 logging above returns http.HandlerFunc, and tests call http.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 error

Same 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

QuestionAnswer
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