Skip to main content

How to Build a Go Router From Scratch

Gregory Schier Headshot Gregory Schier • 13 min read

An HTTP router (also called multiplexer) is responsible for listening to HTTP requests and invoking the appropriate handler based on matching criteria like HTTP method or URL.

Go provides a very simple router called ServeMux but it's so basic that most developers opt to use a third-party replacement like gorilla/mux. For any real-world project, I'd likely do the same. However, today we're going to learn a few things by building our own! 😎

What We're Building#

There are a few things we're going to want our router to do:

  1. 404 Handler: Serve a 404 response for non-matching requests
  2. Matching: match on URL path and HTTP method and invoke route handler
  3. Parameters: extract dynamic URL parameters like /users/(?P<id>\d+)
  4. Panic Recovery: Catch Go panics and respond with a 500

Here's a snippet showcasing all the functionality we need.

Go Snippet
r := NewRouter()

r.Route("GET", "/", homeRoute)
r.Route("POST", "/users", createUserRoute)
r.Route("GET", "/users/(?P<ID>\d+)", getUserRoute)
r.Route("GET", "/panic", panicRoute)

http.ListenAndServe("localhost:8000", r)

Basic Routing#

To start, we'll build a router that simply returns a 404 response for every request.

We want our router to handle every HTTP request that comes into the web server, which we can do by passing it into Go's http.ListenAndServe method. ListenAndServe's second argument is an http.Handler, which is responsible for handling every incoming request. To make this possible, our router will need to implement the Handler interface.

Handler only declares a single method ServeHTTP so let's create a struct to match it.

Go Snippet
type Router struct {}

func (sr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Return 404 for every request
    http.NotFound(w, r)
}

And, just like that, we have a router type that can be used anywhere an http.Handler is accepted. Let's see what it looks like in a runnable program.

main.go
package main

import "net/http"

func main() {
    r := &Router{}
    http.ListenAndServe(":8000", r)
}

// ~~~~~ Router ~~~~~ //

type Router struct{}

func (sr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    http.NotFound(w, r)
}

If we run this program using go run main.go from the command line, we can open localhost:8000 in our web browser and verify it responds with "404 page not found."

Matching Routes#

There isn't much use for a router that always returns a 404, but now we have a strong foundation to build on. The next step is to modify our router to store a list of routes that can be matched against.

For every incoming request, we'll need to do the following:

  1. Extract HTTP method and URL path from the request
  2. Check if any routes exist that match the method and path
  3. Invoke the route if there is a match
  4. Return a 404 if no match is found

So, we'll need to store three things for every route. (1) The HTTP method for the route, (2) the path for the route, and (3) the handler function to call if we find a match.

Let's make another struct called RouteEntry to store these three things together.

Go Snippet
type RouteEntry struct {
    Path    string
    Method  string
    Handler http.HandlerFunc
}

We also need to update Router to store a list of RouteEntry. To improve the experience of using our router, we'll add a helper function called Route to do the work. The route function will create a new RouteEntry and add it to the list of routes.

Go Snippet
type Router struct {
    routes []RouteEntry
}

func (rtr *Router) Route(method, path string, handlerFunc http.HandlerFunc) {
    e := RouteEntry{
        Method:      method,
        Path:        path,
        HandlerFunc: handlerFunc,
    }
    rtr.routes = append(rtr.routes, e)
}

And finally, we'll need to write the logic inspect the incoming request and find which route matches.

There are two obvious places to put the matching logic—either in Router itself or in the RouteEntry. Either of these locations would work, but making RouteEntry responsible for matching seems sensible, since it stores the criteria to match on.

So let's add a Match method to the RouteEntry struct. Since we're matching based on information on the request we'll make request an argument. And, to indicate a successful match, we'll have it return a boolean.

Go Snippet
func (re *RouteEntry) Match(r *http.Request) bool {
    if r.Method != re.Method {
        return false // Method mismatch
    }

    if r.URL.Path != re.Path {
        return false // Path mismatch
    }

    return true
}

Now, all the router needs to do is loop over all the routes that it has and check to see if one of them matches.

Go Snippet
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    for _, e := range rtr.routes {
        match := e.Match(r)
        if !match {
            continue
        }

        // We have a match! Call the handler, and return
        e.HandlerFunc.ServeHTTP(w, r)
        return
    }

    // No matches, so it's a 404
    http.NotFound(w, r)
}

To make sure this all works, we can add a simple route to handle the home page /.

Go Snippet
r := &Router{}
r.Route("GET", "/", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("The Best Router!"))
})

When we put it altogether and run it with go run main.go, we can verify that it works by navigating to localhost:8000/ in a web browser. You should see it respond with "The Best Router!" as expected. Any other path will still return the 404 response correctly.

main.go
Expand all 65 Lines
package main

import "net/http"

func main() {
    r := &Router{}

    r.Route(http.MethodGet, "/about", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("The Best Router!"))
    })

    http.ListenAndServe(":8000", r)
}

// ~~~~~ Router ~~~~~ //

type Router struct {
    routes []RouteEntry
}

func (rtr *Router) Route(method, path string, handlerFunc http.HandlerFunc) {
    e := RouteEntry{
        Method:      method,
        Path:        path,
        HandlerFunc: handlerFunc,
    }
    rtr.routes = append(rtr.routes, e)
}

func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    for _, e := range rtr.routes {
        match := e.Match(r)
        if !match {
            continue
        }

        // We have a match! Call the handler, and return
        e.HandlerFunc.ServeHTTP(w, r)
        return
    }

    // No matches, so it's a 404
    http.NotFound(w, r)
}

// ~~~~~ RouteEntry ~~~~~ //

type RouteEntry struct {
    Path        string
    Method      string
    HandlerFunc http.HandlerFunc
}

func (ent *RouteEntry) Match(r *http.Request) bool {
    println(r.Method, r.URL.Path)
    if r.Method != ent.Method {
        return false // Method mismatch
    }

    if r.URL.Path != ent.Path {
        return false // Path mismatch
    }

    return true
}

Extracting Route Parameters#

We now have a router that is actually fairly useful! 🥳 There are just a couple features left to add to take it to the next level.

Most API's that deal with creating, reading, updating, and deleting (CRUD) data need to define routes with dynamic parameters. For example, a route that URL to fetch a user by ID might look like /users/10 where 10 is the ID of the user. In our current router, we'd have to define a fixed route for every possible user ID we'd ever want to fetch, which is not practical. What we actually need is a way to define a route with a dynamic part /users/?. That's what we're going to implement now.

To perform dynamic matching, we're going to use regular expressions—the epitome of dynamic matching.

Accessing Parameters#

Before we dive into regular expressions, though, let's begin by talking about how the extracted parameters will be accessed by the route handlers. After all, a fetchUserRoute will need to be able to pull the ID out of the URL to fetch the correct user.

Luckily, Go provides a mechanism to store short-lived data on the request object called context. Using this mechanism, the router can add the parameters to the request context for the handler to read when it's invoked.

Here's an example of how a handler might access a parameter. Note that, since accessing things on the request context is a bit cumbersome, we'll also create a helper function to reduce duplication.

Go Snippet
r.Route("GET", `/hello/(?P<Message>\w+)`, func(w http.ResponseWriter, r *http.Request) {
    message := URLParam(r, "Message")
    w.Write([]byte("Hello " + message))
})

// URLParam extracts a parameter from the URL by name
func URLParam(r *http.Request, name string) string {
    ctx := r.Context()

    // ctx.Value returns an `interface{}` type, so we
    // also have to cast it to a map, which is the 
    // type we'll be using to store our parameters.
    params := ctx.Value("params").(map[string]string)
    return params[name]
}

Okay, so we now have a plan for accessing the parameters. Now we can move on to the fun part—using regular expressions. 💪🏻

Matching with Regexp#

As you might have noticed in the previous snippet, we're going to be storing parameters in a map[string]string, where each key in the map is the parameter name and the value is the value extracted from the URL. Conveniently, regular expressions have named groups that are perfect for this use case. In Go, we can match these named groups using the FindStringSubmatch method.

Go Snippet
r := regexp.MustCompile(
     `/books/(?P<AuthorID>\d+)/(?P<BookID>\d+)`,
)

match := r.FindStringSubmatch("/books/123/456")
if match == nil {
    return
}

fmt.Println(match)           // [123, 456]
fmt.Println(r.SubexpNames()) // [AuthorID, BookID]

Storing URL Params#

Now that we know how to match regular expression groups, we can update our RouteEntry struct's matching logic to use them. To do this, first we'll need change the Path property from a string to a Regexp type. Then, we'll need to update the Match method logic.

Go Snippet
type RouteEntry struct {
    Path        *regexp.Regexp
    Method      string
    HandlerFunc http.HandlerFunc
}

func (ent *RouteEntry) Match(r *http.Request) map[string]string {
    match := ent.Path.FindStringSubmatch(r.URL.Path)
    if match == nil {
        return nil // No match found
    }

    // Create a map to store URL parameters in
    params := make(map[string]string)
    groupNames := ent.Path.SubexpNames()
    for i, group := range match {
        params[groupNames[i]] = group
    }

    return params
}

Notice that we also changed the signature of Match to return the parameters map instead of a boolean.

The last thing we need to do is update our router logic to add the parameters to the request context after finding a match.

Go Snippet
for _, e := range rtr.routes {
    params := e.Match(r)
    if params == nil {
        continue // No match found
    }

    // Create new request with params stored in context
    ctx := context.WithValue(r.Context(), "params", params)
    e.HandlerFunc.ServeHTTP(w, r.WithContext(ctx))
    return
}

If we put it all together, we can test it out!

main.go
Expand all 89 Lines
package main

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

func main() {
    r := &Router{}

    r.Route(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("The Best Router!"))
    })

    r.Route(http.MethodGet, `/hello/(?P<Message>\w+)`, func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello " + URLParam(r, "Message")))
    })

    http.ListenAndServe(":8000", r)
}

// ~~~~~ RouteEntry ~~~~~ //

type RouteEntry struct {
    Path        *regexp.Regexp
    Method      string
    HandlerFunc http.HandlerFunc
}

func (ent *RouteEntry) Match(r *http.Request) map[string]string {
    match := ent.Path.FindStringSubmatch(r.URL.Path)
    if match == nil {
        return nil // No match found
    }

    // Create a map to store URL parameters in
    params := make(map[string]string)
    groupNames := ent.Path.SubexpNames()
    for i, group := range match {
        params[groupNames[i]] = group
    }

    return params
}

// ~~~~~ Router ~~~~~ //

type Router struct {
    routes []RouteEntry
}

func (rtr *Router) Route(method, path string, handlerFunc http.HandlerFunc) {
    // NOTE: ^ means start of string and $ means end. Without these,
    //   we'll still match if the path has content before or after
    //   the expression (/foo/bar/baz would match the "/bar" route).
    exactPath := regexp.MustCompile("^" + path + "$")

    e := RouteEntry{
        Method:      method,
        Path:        exactPath,
        HandlerFunc: handlerFunc,
    }
    rtr.routes = append(rtr.routes, e)
}

func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    for _, e := range rtr.routes {
        params := e.Match(r)
        if params == nil {
            continue // No match found
        }

        // Create new request with params stored in context
        ctx := context.WithValue(r.Context(), "params", params)
        e.HandlerFunc.ServeHTTP(w, r.WithContext(ctx))
        return
    }

    http.NotFound(w, r)
}

// ~~~~~ Helpers ~~~~~ //

func URLParam(r *http.Request, name string) string {
    ctx := r.Context()
    params := ctx.Value("params").(map[string]string)
    return params[name]
}

Recovering from Panics#

Adding dynamic URL parameters increased the usefulness of our router tremendously! In fact, I wouldn't hesitate to use what we've made so far in a toy project. There's just one more small thing that we should add to prevent bad things from happening in production, which is panic recovery.

Currently, if one of our route handlers panics, our server will return an empty response—not the best experience for users. So, we'll add a few lines of code to catch these panics and return an appropriate 500 (internal server error) status code.

Go Snippet
func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if r := recover(); r != nil {
            log.Println("ERROR:", r) // Log the error
            http.Error(w, "Uh oh!", http.StatusInternalServerError)
        }
    }()

    // ...
}

To test that it works, we can add a special /panic route that will trigger this recovery logic.

Go Snippet
r.Route("GET", "/panic", func(w http.ResponseWriter, r *http.Request) {
    panic("something bad happened!")
})

Go ahead, give it a try by running the code and navigating to localhost:8000/panic in a web browser! You should see the server respond with "Uh oh!"

main.go
Expand all 101 Lines
package main

import (
    "context"
    "log"
    "net/http"
    "regexp"
)

func main() {
    r := &Router{}

    r.Route(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("The Best Router!"))
    })

    r.Route(http.MethodGet, `/hello/(?P<Message>\w+)`, func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello " + URLParam(r, "Message")))
    })

    r.Route(http.MethodGet, "/panic", func(w http.ResponseWriter, r *http.Request) {
        panic("something bad happened!")
    })

    http.ListenAndServe(":8000", r)
}

// ~~~~~ RouteEntry ~~~~~ //

type RouteEntry struct {
    Path        *regexp.Regexp
    Method      string
    HandlerFunc http.HandlerFunc
}

func (ent *RouteEntry) Match(r *http.Request) map[string]string {
    match := ent.Path.FindStringSubmatch(r.URL.Path)
    if match == nil {
        return nil // No match found
    }

    // Create a map to store URL parameters in
    params := make(map[string]string)
    groupNames := ent.Path.SubexpNames()
    for i, group := range match {
        params[groupNames[i]] = group
    }

    return params
}

// ~~~~~ Router ~~~~~ //

type Router struct {
    routes []RouteEntry
}

func (rtr *Router) Route(method, path string, handlerFunc http.HandlerFunc) {
    // NOTE: ^ means start of string and $ means end. Without these,
    //   we'll still match if the path has content before or after
    //   the expression (/foo/bar/baz would match the "/bar" route).
    exactPath := regexp.MustCompile("^" + path + "$")

    e := RouteEntry{
        Method:      method,
        Path:        exactPath,
        HandlerFunc: handlerFunc,
    }
    rtr.routes = append(rtr.routes, e)
}

func (rtr *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if r := recover(); r != nil {
            log.Println("ERROR:", r)
            http.Error(w, "Uh oh!", http.StatusInternalServerError)
        }
    }()

    for _, e := range rtr.routes {
        params := e.Match(r)
        if params == nil {
            continue // No match found
        }

        // Create new request with params stored in context
        ctx := context.WithValue(r.Context(), "params", params)
        e.HandlerFunc.ServeHTTP(w, r.WithContext(ctx))
        return
    }

    http.NotFound(w, r)
}

// ~~~~~ Helpers ~~~~~ //

func URLParam(r *http.Request, name string) string {
    ctx := r.Context()
    params := ctx.Value("params").(map[string]string)
    return params[name]
}

Wrap-Up#

This tutorial demonstrated how to build a router from scratch using nothing but the standard libraries included in Go, in less than 100 lines of code! I would still recommend using gorilla/mux for production use-cases, as it handles things like URL decoding, trailing-slash redirects, performance much better.

However, hopefully you now have a basic understanding of how routers work, and would be able to read through the source code of gorilla/mux and see many similarities.


Awesome, thanks for the feedback! 🤗

How did you like the article?