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.

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.
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()
}
} 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 404Four 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.
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)
}
})
}
}
}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| Route | net/http | chi | gin | echo | gorilla/mux | httprouter |
|---|---|---|---|---|---|---|
| static | 103 (0) | 200 (2) | 34 (0) | 29 (0) | 530 (7) | 17 (0) |
| one parameter | 117 (1) | 297 (4) | 37 (0) | 39 (0) | 736 (8) | 44 (1) |
| two parameters | 205 (2) | 345 (4) | 44 (0) | 49 (0) | 888 (8) | 50 (1) |
| miss | 548 (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.


