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.
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.
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.
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.
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.
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.
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.
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.
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
List all Go tutorials.