Skip to content
gohacks.dev
Web

Standard Library Router or Framework in Go 1.22 and Later

Compare the Go 1.22 standard library mux with chi, gin, echo, gorilla mux and httprouter on routing speed, 405 handling and trailing slashes.

11 min read
Summarize in AI
Flat illustration of a request passing through concentric middleware layers to a handler.

Before Go 1.22 the answer to "do I need a router package?" was an easy yes: the standard ServeMux could not match a method or capture a path parameter, so everyone installed gorilla/mux, chi, httprouter or a whole framework. Since 1.22 it can do both, and a lot of the reasons for reaching for a dependency disappeared. This article measures what is left. It runs the same routes through the standard mux, chi, gin, echo, gorilla/mux and httprouter, compares how each behaves on the awkward requests, and times them.

Tested with Go 1.25.6, chi v5.5.0, gin v1.12.0, echo v4.15.0, gorilla/mux v1.8.1, httprouter v1.3.0, macOS on Apple M1 Max (arm64)

It belongs to the web cluster that ends in a production HTTP server, builds on the Go 1.22 mux patterns, and leads into the best Go web framework by use case and route organization for larger applications. Versions are pinned in the tested line above, and the benchmarks ran on an Apple M1 Max under Go 1.25.6.

What the standard mux covers now

A pattern can start with a method (GET /users/{id}), include named wildcards ({id}, and {path...} for the rest of a path), pin the end of a path with {$}, and start with a host. The most specific pattern wins, conflicting patterns are rejected at registration time instead of at the first request, and a method mismatch produces a 405 with an Allow header. r.PathValue("id") reads a wildcard. That is routing for almost every API.

What it does not do: route groups with a shared prefix and shared middleware, regular expression constraints on a parameter, named routes you can build URLs from, and any helper for binding or validating input. Whether you miss those depends on the application, and the section on what frameworks add comes below.

How they behave when the request is odd

A router is judged on the requests that do not fit. The program registers GET /users/:id in each package and sends five requests. In the table, A means a 405 that included an Allow header.

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/gin-gonic/gin"
	"github.com/go-chi/chi/v5"
	"github.com/gorilla/mux"
	"github.com/julienschmidt/httprouter"
	"github.com/labstack/echo/v4"
)

func routers() map[string]http.Handler {
	gin.SetMode(gin.ReleaseMode)

	std := http.NewServeMux()
	std.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "user "+r.PathValue("id")) })

	c := chi.NewRouter()
	c.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "user "+chi.URLParam(r, "id")) })

	g := gin.New()
	g.GET("/users/:id", func(c *gin.Context) { c.String(200, "user "+c.Param("id")) })

	e := echo.New()
	e.GET("/users/:id", func(c echo.Context) error { return c.String(200, "user "+c.Param("id")) })

	m := mux.NewRouter()
	m.HandleFunc("/users/{id}", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "user "+mux.Vars(r)["id"]) }).Methods("GET")

	h := httprouter.New()
	h.GET("/users/:id", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
		fmt.Fprint(w, "user "+ps.ByName("id"))
	})

	return map[string]http.Handler{"net/http": std, "chi": c, "gin": g, "echo": e, "gorilla/mux": m, "httprouter": h}
}

func main() {
	cases := []struct{ label, method, path string }{
		{"GET /users/42", "GET", "/users/42"},
		{"POST /users/42", "POST", "/users/42"},
		{"GET /users/42/", "GET", "/users/42/"},
		{"HEAD /users/42", "HEAD", "/users/42"},
		{"GET /nope", "GET", "/nope"},
	}
	names := []string{"net/http", "chi", "gin", "echo", "gorilla/mux", "httprouter"}
	rs := routers()
	fmt.Printf("%-16s", "")
	for _, n := range names {
		fmt.Printf("%-17s", n)
	}
	fmt.Println()
	for _, c := range cases {
		fmt.Printf("%-16s", c.label)
		for _, n := range names {
			rec := httptest.NewRecorder()
			rs[n].ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil))
			cell := fmt.Sprint(rec.Code)
			if loc := rec.Header().Get("Location"); loc != "" {
				cell += " ->" + loc
			}
			if a := rec.Header().Get("Allow"); a != "" && rec.Code == 405 {
				cell += " A"
			}
			fmt.Printf("%-17s", cell)
		}
		fmt.Println()
	}
}
Text
                net/http         chi              gin              echo             gorilla/mux      httprouter       
GET /users/42   200              200              200              200              200              200              
POST /users/42  405 A            405 A            404              405 A            405              405 A            
GET /users/42/  404              404              301 ->/users/42  200              404              301 ->/users/42  
HEAD /users/42  200              405 A            404              405 A            405              405 A            
GET /nope       404              404              404              404              404              404

Four differences are worth knowing before you choose.

405 versus 404. For a POST to a path that only accepts GET, net/http, chi, echo and httprouter answer 405 Method Not Allowed with an Allow header; gorilla/mux answers 405 without the header; gin answers 404, because its method-not-allowed handling is off unless you set HandleMethodNotAllowed. A client library that distinguishes the two will behave differently against these servers, and an API that returns 404 for a wrong method wastes someone's afternoon.

Trailing slashes. /users/42/ is a different path from /users/42 for the standard mux, chi and gorilla/mux (a 404), while httprouter and gin redirect with a 301 to the canonical form, and echo happily serves it as a match. If you publish URLs people type or link to, decide this deliberately; the redirect is friendlier, the 404 is stricter, and the fallthrough hides mistakes.

HEAD. The standard mux treats a GET pattern as also matching HEAD, which is what HTTP expects. chi, echo, gorilla/mux and httprouter returned 405 for HEAD here, and gin a 404, so health checkers and link validators that send HEAD can fail against them until you register the method.

Misses. All six answer 404 for an unknown path, but with different bodies and headers, so a custom not-found page is configured differently in each; for the standard mux the technique is in custom 404 pages.

How fast is the routing

The benchmark gives each router 30 filler routes plus /health, /users/:id and /users/:id/posts/:pid, and times ServeHTTP on a reused request and a no-op writer, so the numbers are the router and nothing else. Times are nanoseconds per request; allocations in parentheses.

bench_test.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"
	"github.com/go-chi/chi/v5"
	"github.com/gorilla/mux"
	"github.com/julienschmidt/httprouter"
	"github.com/labstack/echo/v4"
)

// nopWriter keeps the recorder's own allocations out of the numbers.
type nopWriter struct{ h http.Header }

func (w *nopWriter) Header() http.Header         { return w.h }
func (w *nopWriter) Write(b []byte) (int, error) { return len(b), nil }
func (w *nopWriter) WriteHeader(int)             {}

// Every router gets 30 filler routes plus the three routes the benchmark hits.
func build() map[string]http.Handler {
	gin.SetMode(gin.ReleaseMode)
	std := http.NewServeMux()
	c := chi.NewRouter()
	g := gin.New()
	e := echo.New()
	m := mux.NewRouter()
	hr := httprouter.New()

	for i := 0; i < 30; i++ {
		p := fmt.Sprintf("/static/section%02d/page", i)
		std.HandleFunc("GET "+p, func(http.ResponseWriter, *http.Request) {})
		c.Get(p, func(http.ResponseWriter, *http.Request) {})
		g.GET(p, func(*gin.Context) {})
		e.GET(p, func(echo.Context) error { return nil })
		m.HandleFunc(p, func(http.ResponseWriter, *http.Request) {}).Methods("GET")
		hr.GET(p, func(http.ResponseWriter, *http.Request, httprouter.Params) {})
	}
	std.HandleFunc("GET /health", func(http.ResponseWriter, *http.Request) {})
	std.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) { _ = r.PathValue("id") })
	std.HandleFunc("GET /users/{id}/posts/{pid}", func(w http.ResponseWriter, r *http.Request) { _ = r.PathValue("pid") })

	c.Get("/health", func(http.ResponseWriter, *http.Request) {})
	c.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) { _ = chi.URLParam(r, "id") })
	c.Get("/users/{id}/posts/{pid}", func(w http.ResponseWriter, r *http.Request) { _ = chi.URLParam(r, "pid") })

	g.GET("/health", func(*gin.Context) {})
	g.GET("/users/:id", func(c *gin.Context) { _ = c.Param("id") })
	g.GET("/users/:id/posts/:pid", func(c *gin.Context) { _ = c.Param("pid") })

	e.GET("/health", func(echo.Context) error { return nil })
	e.GET("/users/:id", func(c echo.Context) error { _ = c.Param("id"); return nil })
	e.GET("/users/:id/posts/:pid", func(c echo.Context) error { _ = c.Param("pid"); return nil })

	m.HandleFunc("/health", func(http.ResponseWriter, *http.Request) {}).Methods("GET")
	m.HandleFunc("/users/{id}", func(w http.ResponseWriter, r *http.Request) { _ = mux.Vars(r)["id"] }).Methods("GET")
	m.HandleFunc("/users/{id}/posts/{pid}", func(w http.ResponseWriter, r *http.Request) { _ = mux.Vars(r)["pid"] }).Methods("GET")

	hr.GET("/health", func(http.ResponseWriter, *http.Request, httprouter.Params) {})
	hr.GET("/users/:id", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { _ = ps.ByName("id") })
	hr.GET("/users/:id/posts/:pid", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { _ = ps.ByName("pid") })

	return map[string]http.Handler{"net_http": std, "chi": c, "gin": g, "echo": e, "gorilla_mux": m, "httprouter": hr}
}

func BenchmarkRouting(b *testing.B) {
	routers := build()
	for _, route := range []struct{ name, path string }{
		{"static", "/health"},
		{"one_param", "/users/42"},
		{"two_params", "/users/42/posts/7"},
		{"miss", "/does/not/exist"},
	} {
		for _, name := range []string{"net_http", "chi", "gin", "echo", "gorilla_mux", "httprouter"} {
			h := routers[name]
			b.Run(route.name+"/"+name, func(b *testing.B) {
				req := httptest.NewRequest("GET", route.path, nil)
				w := &nopWriter{h: http.Header{}}
				b.ReportAllocs()
				for i := 0; i < b.N; i++ {
					h.ServeHTTP(w, req)
				}
			})
		}
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkRouting/static/net_http-10         	15479725	       103.3 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/static/chi-10              	 6716406	       199.7 ns/op	     368 B/op	       2 allocs/op
BenchmarkRouting/static/gin-10              	39205009	        33.76 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/static/echo-10             	42926258	        29.45 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/static/gorilla_mux-10      	 2218995	       530.1 ns/op	     848 B/op	       7 allocs/op
BenchmarkRouting/static/httprouter-10       	71472709	        16.89 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/one_param/net_http-10      	10330533	       116.8 ns/op	      16 B/op	       1 allocs/op
BenchmarkRouting/one_param/chi-10           	 4200123	       297.4 ns/op	     704 B/op	       4 allocs/op
BenchmarkRouting/one_param/gin-10           	33157948	        36.55 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/one_param/echo-10          	31573857	        38.82 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/one_param/gorilla_mux-10   	 1392757	       736.1 ns/op	    1152 B/op	       8 allocs/op
BenchmarkRouting/one_param/httprouter-10    	26645300	        43.66 ns/op	      64 B/op	       1 allocs/op
BenchmarkRouting/two_params/net_http-10     	 5908435	       204.9 ns/op	      48 B/op	       2 allocs/op
BenchmarkRouting/two_params/chi-10          	 3460639	       344.9 ns/op	     704 B/op	       4 allocs/op
BenchmarkRouting/two_params/gin-10          	27661428	        43.66 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/two_params/echo-10         	24215497	        49.09 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/two_params/gorilla_mux-10  	 1356894	       887.7 ns/op	    1168 B/op	       8 allocs/op
BenchmarkRouting/two_params/httprouter-10   	24357960	        50.12 ns/op	      64 B/op	       1 allocs/op
BenchmarkRouting/miss/net_http-10           	 2157103	       547.8 ns/op	     208 B/op	      12 allocs/op
BenchmarkRouting/miss/chi-10                	 4010313	       297.4 ns/op	     416 B/op	       5 allocs/op
BenchmarkRouting/miss/gin-10                	29032520	        41.40 ns/op	       0 B/op	       0 allocs/op
BenchmarkRouting/miss/echo-10               	 2925885	       451.8 ns/op	     480 B/op	       7 allocs/op
BenchmarkRouting/miss/gorilla_mux-10        	 2552301	       456.4 ns/op	      96 B/op	       4 allocs/op
BenchmarkRouting/miss/httprouter-10         	 4453366	       263.4 ns/op	      48 B/op	       3 allocs/op
PASS
ok  	example.test/lab	35.808s
Routenet/httpchiginechogorilla/muxhttprouter
static103 (0)200 (2)34 (0)29 (0)530 (7)17 (0)
one parameter117 (1)297 (4)37 (0)39 (0)736 (8)44 (1)
two parameters205 (2)345 (4)44 (0)49 (0)888 (8)50 (1)
miss548 (12)297 (5)41 (0)452 (7)456 (4)263 (3)

Read the table for what it is. The ranking is the familiar one: httprouter, gin and echo are the fastest, designed around a radix tree that does not allocate; the standard mux is in the middle and allocates a little for parameters; chi allocates a handful of objects per request; gorilla/mux is the slowest because it tries routes in order and matches regular expressions. These are single-threaded numbers on one machine, and a different CPU, a different route set or a bigger table would shift them, though not the order by much.

Now the context. The slowest router here takes under a microsecond to route. A handler that makes one database round trip takes a few hundred microseconds to a few milliseconds, between a thousand and ten thousand times more. If a service handles 5,000 requests per second, the difference between the fastest and the slowest router is about 4 ms of CPU per second, which is not a thing you will ever see in a profile. Choose a router for how it feels to use and how it behaves, as the next sections do, and do not pick one for these numbers. The exception is a proxy or a gateway that does almost nothing per request; there the 20 ns versus 200 ns gap starts to register.

One more observation from the miss row: the standard mux is slowest on an unmatched path in this benchmark (548 ns, 12 allocations), which is the path it takes to build a 404 response. That matters if you are being scanned by bots, where most traffic is misses, and a reason to put a cheap guard in front of a public server.

What frameworks add besides routing

gin and echo are not only routers. They add a context object with helpers for binding JSON and query parameters into structs, validation through struct tags, rendering helpers, route groups with shared middleware, and built-in middleware for logging, recovery, CORS and compression. chi stays close to the standard library: its router is compatible with http.Handler, it adds groups and middleware stacks, and its context helpers are tiny. That difference is the real choice.

A framework context is convenient and it is also a lock-in. A gin handler has the signature func(*gin.Context), so none of your handlers can be reused outside gin, and your tests and middleware all speak its types. chi and the standard library handlers are plain func(http.ResponseWriter, *http.Request), and moving between them is a change in the router file, not in every handler. For a codebase expected to live for years, I weight that reversibility heavily.

What I would still reach for a package to get: route groups with middleware (chi, 40 lines to write yourself), automatic OpenAPI generation (huma, which works with several routers), and a team's shared familiarity with gin or echo, which is a legitimate reason. The helper features of the frameworks are small enough to write once in your own httpx package, as in decoding JSON request bodies and validation.

My recommendation

Start with net/http for a new service. It has no dependency, the behavior is the one the specification expects (405 with Allow, HEAD for GET), and the speed is irrelevant for application code. Add chi when the route table gets large enough that groups and middleware stacks would help, because it adds the missing structure without changing the handler signature. Use gin or echo when the team already knows one of them and the productivity of bundled helpers matters more than portable handlers, and accept that you are choosing the framework for the life of the codebase. Avoid gorilla/mux for new code unless you need its regex routes; it is the slowest here and its conveniences now exist elsewhere. The comparison across more criteria is in the framework roundup, and the question of skipping frameworks entirely is argued in do you need a Go web framework. The pattern syntax is documented in the net/http reference, and the Go team's routing enhancements announcement explains the design.

Also inGinChiEcho
Taggedstdlib
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.