I’ve been arguing about Go web frameworks with myself for about six years now. Every time a new project starts I get the same itchy feeling: do I reach for Gin out of habit, try Chi again because it’s “closer to the standard library,” or actually be a grown-up and just use net/http since Go 1.22 finally fixed routing?
The answer changes depending on what I’m building. So instead of writing a feature-matrix post that nobody reads, here’s how I actually pick a Go web framework in 2026, with the code I’d write in each one and the projects where each pays for itself.
Short version for the impatient: small internal service, just use net/http plus Chi for the middleware ergonomics. Public API with humans hitting it, Gin or Echo. Need raw throughput on a tight server, Fiber, but read the fine print. I’ll defend each of these below.
What changed in Go’s standard library that made me reconsider
Honestly, the biggest shift wasn’t a framework release, it was Go 1.22 finally adding pattern-matching to net/http.ServeMux in 2024. Before that you couldn’t do path parameters or method routing without a third-party router, which is why everyone reached for Gin or Chi the moment they needed GET /users/{id}.
Here’s the old way I used to write a tiny user service:
// pre-1.22, you needed a router library for anything beyond exact-match paths
r := chi.NewRouter()
r.Get("/users/{id}", getUser)
r.Post("/users", createUser)
http.ListenAndServe(":8080", r)
And the new way, using only the standard library:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
http.ListenAndServe(":8080", mux)
No dependency. Method-aware routing. Path values via r.PathValue("id"). For a sidecar I’m writing to expose three endpoints to an internal Prometheus scraper, that’s the whole framework discussion done. The official net/http package docs walked through this when 1.22 shipped and it’s the first thing I now check before adding any dependency.
Which is also when I stopped treating “which framework” as a meaningful question for small services. The interesting question is what you give up by leaving the standard library.
Gin: still my default for an API that real users hit
Gin is the framework I keep going back to when I’m building something a frontend will actually call. The middleware ecosystem is enormous, error handling is sane, and the JSON binding is good enough that I rarely write request parsing by hand.
A representative handler I’d ship today:
func main() {
r := gin.Default()
r.POST("/posts", createPost)
r.Run(":8080")
}
type CreatePostBody struct {
Title string `json:"title" binding:"required,min=3,max=200"`
Body string `json:"body" binding:"required,min=10"`
}
func createPost(c *gin.Context) {
var body CreatePostBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ... insert into db ...
c.JSON(http.StatusCreated, gin.H{"id": newID})
}
The binding tags do enough validation that for 80% of endpoints I don’t need to pull in go-playground/validator directly. The Default() engine wires up logging and panic recovery, which I will absolutely forget to do if left to my own devices at 11pm.
The argument against Gin is that it’s not the standard http.Handler interface. Middleware you write for it doesn’t compose with other frameworks. That used to bother me. Now I don’t care, because I rarely move handlers between frameworks. If I did, I’d be designing badly.
If you’re already writing Go services and want to compare how this style feels against the patterns I worked through in my Go concurrency post, the trade-off is the same one Go makes everywhere: a small amount of magic in exchange for a lot less typing.
Echo: when I want Gin without the global state vibes
Echo is, for my money, the most thoughtfully designed of the big four. Routing is roughly the same speed as Gin’s, the error handling model is more explicit, and the request context isn’t a magic bag of interface{}.
A roughly equivalent handler in Echo:
func main() {
e := echo.New()
e.POST("/posts", createPost)
e.Logger.Fatal(e.Start(":8080"))
}
func createPost(c echo.Context) error {
var body CreatePostBody
if err := c.Bind(&body); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if err := c.Validate(&body); err != nil {
return err
}
return c.JSON(http.StatusCreated, map[string]int{"id": newID})
}
Notice the handler returns an error. That single difference changes how the whole codebase feels. You write one centralized error handler at the top of main and every endpoint funnels through it. No more half-handlers that return on the wrong line and silently write twice to the response.
Where Echo loses me is the validator setup, you have to bring your own (go-playground/validator is the usual answer) and wire it in. Gin does that out of the box. So if I’m in a hurry, Gin wins. If I’m building something I’ll maintain for two years, Echo’s slightly more disciplined model has saved me debugging time.
Fiber: fast, but you’re not getting net/http
Fiber is the framework people pull up when somebody says “Go isn’t fast enough.” It’s built on fasthttp instead of net/http. That makes it genuinely faster on benchmarks, especially for small responses at high concurrency.
It also makes it a different thing. Standard middleware doesn’t work. The http.Handler interface isn’t there. Anything that expects *http.Request won’t compile against a Fiber handler.
func main() {
app := fiber.New()
app.Post("/posts", createPost)
app.Listen(":8080")
}
func createPost(c *fiber.Ctx) error {
body := new(CreatePostBody)
if err := c.BodyParser(body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(201).JSON(fiber.Map{"id": newID})
}
Is the speed real? Yes. Is it the bottleneck on your service? Almost never. In the last three years of services I’ve shipped, the framework was the slow part exactly zero times. It’s been the database, the JSON serializer, the third-party API, an N+1 query I didn’t see in code review.
I use Fiber for one specific shape of project: a small, single-purpose Go service whose entire job is to do something quick over HTTP a million times a second. A request-signing proxy. A token mint endpoint. A static-config service. For a real product API, I’d rather have net/http compatibility than the marginal speedup.
Chi: my pick when I want a router and nothing else
Chi is the most boring framework on this list, which is the highest compliment I can pay it. It’s a router and a middleware pipeline, both built on top of net/http. That’s it. Handlers are normal http.HandlerFunc. Middleware is normal func(http.Handler) http.Handler.
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Post("/posts", createPost)
http.ListenAndServe(":8080", r)
}
func createPost(w http.ResponseWriter, r *http.Request) {
var body CreatePostBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// ... insert ...
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]int{"id": newID})
}
Notice there’s no “context type,” no special binding, no magic. You write what you’d write in net/http, except Chi gives you grouped routes, real path parameters, and a clean middleware stack on top.
This is what I reach for on internal services where I don’t need validation, don’t need pretty error JSON, and don’t need to teach a new junior dev what a gin.Context is. Pair it with slog for structured logging and you have a 50-line service that’s easy to operate.
How I actually decide
If I’m building today, I run through five questions in this order:
- Is this fewer than 5 endpoints and internal-only? Use
net/httpand Chi for middleware. Done. - Is it a JSON API a frontend will hit? Gin if I want to ship by Friday, Echo if I want to like it in two years.
- Is throughput the literal product? Fiber. But measure first, your service isn’t the bottleneck.
- Do I need request validation, request binding, file uploads, websockets, and a debug page all in one binary? Gin. Echo also, with a bit more wiring.
- Am I going to onboard four developers to this codebase next quarter? Whatever has the best docs for the shape of app you’re building. Right now, Gin has the most StackOverflow answers, Echo has the cleanest docs, Chi has the smallest surface area to learn.
The thing I wish I’d known earlier is that the framework rarely matters past month three. What matters is whether your error model is consistent, your tests can spin up the server quickly, and your team agrees on where validation lives. I’ve shipped good services on all four. I’ve shipped messy ones on all four too. The framework was never the variable.
One concrete thing to try this week
If you’ve been on Gin or Echo since you started writing Go, take a Tuesday and port your smallest service to net/http plus Chi. You don’t have to ship it. Just see what falls out. For a lot of services, the answer is “a hundred lines of imports” and you go back to Gin happily. For some, you’ll realize the framework was masking a layout problem you didn’t know you had.
If you want to see how I think about the rest of the Go ecosystem, I write more about backend tradeoffs over on my work page and in the rest of the Go posts on this blog. And if you’ve been pining for one Right Answer, sorry, there isn’t one. But there’s usually a right answer for this service, and that’s the question worth asking.