ZetCode

Go bunrouter

last modified October 30, 2026

In this article we show how to create HTTP routes in Go with bunrouter. A route associates an HTTP method and a URL path with a handler function, and a router selects the handler for each incoming request.

bunrouter is a small, fast HTTP router that supports route parameters, wildcards, route groups, middleware and custom handlers for missing routes and methods. It is compatible with the standard net/http package: the router itself implements http.Handler.

All examples were compiled and run with Go 1.27.0 and bunrouter v1.0.23. The servers listen on ports 8091 to 8095; the first two both use 8091 because they are alternative versions of the same program, so start one at a time.

Installing bunrouter

The router is a module dependency, so it is added to go.mod with go get.

$ mkdir bunrouter-demo
$ cd bunrouter-demo
$ go mod init com.zetcode/router
go: creating new go.mod: module com.zetcode/router
$ go get github.com/uptrace/bunrouter@v1.0.23
go: added github.com/uptrace/bunrouter v1.0.23

The first router

bunrouter.New returns a router. Routes are registered with methods such as GET and POST, and the handler signature receives the response writer and a bunrouter.Request.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/uptrace/bunrouter"
)

func main() {

    router := bunrouter.New()

    router.GET("/", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintln(w, "index")
        return nil
    })

    log.Println("listening on http://localhost:8091")
    log.Fatal(http.ListenAndServe(":8091", router))
}

The router is passed directly to http.ListenAndServe, because it implements http.Handler. The handler returns nil after writing the response.

$ go run main.go
2026/10/30 20:06:20 listening on http://localhost:8091
$ curl -i localhost:8091/
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: text/plain; charset=utf-8

index

Route parameters

A colon introduces a named parameter that matches one path segment. A star introduces a wildcard that matches the rest of the path, including slashes. Both values are read with req.Param.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/uptrace/bunrouter"
)

func main() {

    router := bunrouter.New()

    router.GET("/users/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintf(w, "user %s\n", req.Param("id"))
        return nil
    })

    router.GET("/files/*path", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintf(w, "file %s\n", req.Param("path"))
        return nil
    })

    log.Println("listening on http://localhost:8091")
    log.Fatal(http.ListenAndServe(":8091", router))
}

The parameter value is always a string, so numeric IDs have to be converted. The wildcard value does not include the leading slash. A request with a trailing slash is redirected permanently to the registered path, and the router does not run the handler twice.

$ curl localhost:8091/users/42
user 42
$ curl localhost:8091/files/a/b/c.txt
file a/b/c.txt
$ curl -i localhost:8091/users/42/
HTTP/1.1 301 Moved Permanently
Content-Type: text/html; charset=utf-8
Location: /users/42
Content-Length: 44

Default 404 and 405 responses

A path that matches no route produces the standard 404 response. A path that matches a route with a different method produces 405, and the body is empty by default.

$ curl -i localhost:8091/about
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 19

404 page not found
$ curl -i -X POST localhost:8091/
HTTP/1.1 405 Method Not Allowed
Content-Length: 0

Custom not-found and method handlers

Both defaults can be replaced with options passed to bunrouter.New.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/uptrace/bunrouter"
)

func main() {

    router := bunrouter.New(
        bunrouter.WithNotFoundHandler(notFound),
        bunrouter.WithMethodNotAllowedHandler(notAllowed),
    )

    router.GET("/hello", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintln(w, "hello")
        return nil
    })

    log.Println("listening on http://localhost:8092")
    log.Fatal(http.ListenAndServe(":8092", router))
}

func notFound(w http.ResponseWriter, req bunrouter.Request) error {

    w.WriteHeader(http.StatusNotFound)
    fmt.Fprintf(w, "not found: %s\n", req.URL.Path)
    return nil
}

func notAllowed(w http.ResponseWriter, req bunrouter.Request) error {

    w.WriteHeader(http.StatusMethodNotAllowed)
    fmt.Fprintf(w, "method %s not allowed\n", req.Method)
    return nil
}

The handlers write the status code themselves; the router does not add one. The request is the extended bunrouter.Request, so the path and the method are available in the usual way.

$ curl -i localhost:8092/about
HTTP/1.1 404 Not Found
Content-Length: 18
Content-Type: text/plain; charset=utf-8

not found: /about
$ curl -i -X POST localhost:8092/hello
HTTP/1.1 405 Method Not Allowed
Content-Length: 24
Content-Type: text/plain; charset=utf-8

method POST not allowed

JSON requests and responses

bunrouter.JSON sets the content type to application/json and encodes the value. A JSON request body is decoded with the standard encoding/json package.

main.go
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"

    "github.com/uptrace/bunrouter"
)

type Product struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Price int    `json:"price"`
}

var products = map[int]Product{
    1: {ID: 1, Name: "pen", Price: 250},
    2: {ID: 2, Name: "notebook", Price: 480},
}

func main() {

    router := bunrouter.New()

    router.GET("/products/:id", getProduct)
    router.POST("/products", createProduct)

    log.Println("listening on http://localhost:8093")
    log.Fatal(http.ListenAndServe(":8093", router))
}

func getProduct(w http.ResponseWriter, req bunrouter.Request) error {

    id, err := strconv.Atoi(req.Param("id"))
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return nil
    }

    p, ok := products[id]
    if !ok {
        http.Error(w, "product not found", http.StatusNotFound)
        return nil
    }

    return bunrouter.JSON(w, p)
}

func createProduct(w http.ResponseWriter, req bunrouter.Request) error {

    var p Product

    if err := json.NewDecoder(req.Body).Decode(&p); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return nil
    }

    p.ID = len(products) + 1
    products[p.ID] = p

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    return json.NewEncoder(w).Encode(p)
}

The get handler returns 404 for an unknown ID and 400 for an ID that is not a number. The create handler sets the status to 201 before encoding, so the header must be set first.

$ curl localhost:8093/products/1
{"id":1,"name":"pen","price":250}
$ curl -i localhost:8093/products/9
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 18

product not found
$ curl -i -X POST -H 'Content-Type: application/json' \
    -d '{"name":"ruler","price":150}' localhost:8093/products
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 36

{"id":3,"name":"ruler","price":150}

Query and form parameters

The embedded *http.Request provides the usual accessors. URL.Query reads the query string, and ParseForm plus FormValue read a submitted form.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/uptrace/bunrouter"
)

func main() {

    router := bunrouter.New()

    router.GET("/search", search)
    router.POST("/login", login)

    log.Println("listening on http://localhost:8093")
    log.Fatal(http.ListenAndServe(":8093", router))
}

func search(w http.ResponseWriter, req bunrouter.Request) error {

    q := req.URL.Query().Get("q")
    if q == "" {
        http.Error(w, "missing query", http.StatusBadRequest)
        return nil
    }

    return bunrouter.JSON(w, map[string]string{"query": q})
}

func login(w http.ResponseWriter, req bunrouter.Request) error {

    if err := req.ParseForm(); err != nil {
        http.Error(w, "invalid form", http.StatusBadRequest)
        return nil
    }

    user := req.FormValue("user")
    if user == "" {
        http.Error(w, "missing user", http.StatusBadRequest)
        return nil
    }

    fmt.Fprintf(w, "welcome %s\n", user)
    return nil
}

FormValue returns the first value for the key and an empty string when the key is absent, which is enough for the check here. The search handler returns JSON.

$ curl 'localhost:8093/search?q=pen'
{"query":"pen"}
$ curl -i localhost:8093/search
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 14

missing query
$ curl -d 'user=alice' localhost:8093/login
welcome alice

Route groups

A group shares a path prefix and a middleware stack with its routes. NewGroup returns the group, and WithGroup passes it to a function.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/uptrace/bunrouter"
)

func main() {

    router := bunrouter.New()

    api := router.NewGroup("/api/v1")
    api.GET("/users", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintln(w, "user list")
        return nil
    })
    api.GET("/users/:id", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintf(w, "user %s\n", req.Param("id"))
        return nil
    })

    log.Println("listening on http://localhost:8094")
    log.Fatal(http.ListenAndServe(":8094", router))
}

The prefix is joined to the route path, so the two handlers answer on /api/v1/users and /api/v1/users/:id. Groups can be nested.

$ curl localhost:8094/api/v1/users
user list
$ curl localhost:8094/api/v1/users/7
user 7

Middleware

A middleware is a function that wraps a handler. It can inspect the request, change the response, call the next handler and inspect the returned error. Middleware is attached to a group, and the group that Use returns must be used for the routes.

main.go
package main

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

    "github.com/uptrace/bunrouter"
)

func logging(next bunrouter.HandlerFunc) bunrouter.HandlerFunc {

    return func(w http.ResponseWriter, req bunrouter.Request) error {

        start := time.Now()
        err := next(w, req)
        log.Printf("%s %s %s", req.Method, req.URL.Path,
            time.Since(start).Round(time.Microsecond))
        return err
    }
}

func auth(next bunrouter.HandlerFunc) bunrouter.HandlerFunc {

    return func(w http.ResponseWriter, req bunrouter.Request) error {

        if req.Header.Get("X-API-Key") != "secret" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return nil
        }
        return next(w, req)
    }
}

func main() {

    router := bunrouter.New()

    root := router.Use(logging)

    api := root.NewGroup("/api/v1")
    api.GET("/users", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintln(w, "user list")
        return nil
    })

    admin := root.NewGroup("/admin", bunrouter.WithMiddleware(auth))
    admin.GET("/stats", func(w http.ResponseWriter, req bunrouter.Request) error {
        fmt.Fprintln(w, "stats")
        return nil
    })

    log.Println("listening on http://localhost:8094")
    log.Fatal(http.ListenAndServe(":8094", router))
}

Use does not modify the receiver; it returns a new group with the middleware added. Calling router.Use(logging) without keeping the result therefore has no effect. The logging middleware wraps every route registered through root, while auth applies only to the /admin group.

The server log shows one line per request. The duration is measured around the next handler and changes from run to run.

$ go run main.go > server.log 2>&1 &
$ curl localhost:8094/api/v1/users
user list
$ curl -i localhost:8094/admin/stats
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
Content-Length: 13

unauthorized
$ curl -H 'X-API-Key: secret' localhost:8094/admin/stats
stats
$ cat server.log
2026/10/30 20:07:26 listening on http://localhost:8094
2026/10/30 20:07:29 GET /api/v1/users 6µs
2026/10/30 20:07:29 GET /admin/stats 24µs
2026/10/30 20:07:29 GET /admin/stats 12µs

Handler errors

The handler signature returns an error, but Router.ServeHTTP discards it. A handler that only returns an error produces a 200 response with an empty body. The error has to be turned into a response by the handler itself or by an error-handling middleware.

main.go
package main

import (
    "errors"
    "log"
    "net/http"

    "github.com/uptrace/bunrouter"
)

type appError struct {
    code int
    msg  string
}

func (e appError) Error() string   { return e.msg }
func (e appError) StatusCode() int { return e.code }

func errorHandler(next bunrouter.HandlerFunc) bunrouter.HandlerFunc {

    return func(w http.ResponseWriter, req bunrouter.Request) error {

        err := next(w, req)
        if err == nil {
            return nil
        }

        code := http.StatusInternalServerError
        var httpErr bunrouter.HTTPError
        if errors.As(err, &httpErr) {
            code = httpErr.StatusCode()
        }

        http.Error(w, err.Error(), code)
        return nil
    }
}

func fail(w http.ResponseWriter, req bunrouter.Request) error {
    return appError{code: http.StatusBadRequest, msg: "bad input"}
}

func main() {

    router := bunrouter.New()

    router.GET("/raw", fail)

    handled := router.NewGroup("", bunrouter.WithMiddleware(errorHandler))
    handled.GET("/handled", func(w http.ResponseWriter, req bunrouter.Request) error {
        return fail(w, req)
    })

    log.Println("listening on http://localhost:8095")
    log.Fatal(http.ListenAndServe(":8095", router))
}

appError implements bunrouter.HTTPError by providing a StatusCode method. The middleware uses errors.As to read that code and falls back to 500 for other errors. /raw bypasses the middleware and exposes the empty 200 response; /handled produces a proper 400.

$ curl -i localhost:8095/raw
HTTP/1.1 200 OK
Content-Length: 0

$ curl -i localhost:8095/handled
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 10

bad input

Source

Go bunrouter - Github page
Go bunrouter - package documentation

In this article we have worked with bunrouter. We created a router, used route parameters and wildcards, replaced the 404 and 405 handlers, returned JSON and read request bodies, query strings and forms, organized routes into groups, added logging and authentication middleware, and handled errors returned by handlers.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.