Go’s net/http is great. But once you start building bigger APIs, the boilerplate adds up — route grouping, content-type negotiation, request binding, middleware composition. Three Go web frameworks have emerged as the popular choices: Gin, Echo, and Chi. Each takes a different approach.
This post is a practical comparison. Same endpoint built three ways, side by side, with my honest take on which to pick.
If you haven’t yet seen what the stdlib alone can do, read Building a REST API in Go with net/http first — it makes the framework comparison sharper.
The contenders
| Framework | Style | Speed | Stdlib-compatible | Vibe |
|---|---|---|---|---|
| Gin | Opinionated; custom Context | Very fast | No (own types) | “Express.js for Go” |
| Echo | Opinionated; custom Context | Very fast | No (own types) | Cleaner Gin alternative |
| Chi | Minimalist; pure stdlib | Fast | Yes (http.Handler) | “stdlib, but better” |
The big philosophical split: Gin and Echo invent their own Context type; Chi uses the standard http.Handler. We’ll see what that means in practice.
The same endpoint, three ways
We’ll build a single endpoint: POST /users that takes a JSON body, validates it, and returns the created user.
With Gin
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type CreateUserReq struct {
Name string `json:"name" binding:"required,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
}
func main() {
r := gin.Default()
r.POST("/users", func(c *gin.Context) {
var req CreateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": 1,
"name": req.Name,
"email": req.Email,
})
})
r.Run(":8080")
}
ShouldBindJSON reads the body, unmarshals it, and runs validation in one call. gin.H is a shorthand for map[string]any. Concise.
With Echo
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type CreateUserReq struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
}
func main() {
e := echo.New()
e.Use(middleware.Logger(), middleware.Recover())
e.POST("/users", func(c echo.Context) error {
var req CreateUserReq
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]any{
"id": 1,
"name": req.Name,
"email": req.Email,
})
})
e.Logger.Fatal(e.Start(":8080"))
}
Echo separates Bind (parse) and Validate (check). Returning errors from handlers is idiomatic. Slightly more verbose than Gin but cleaner separation of concerns.
With Chi
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-playground/validator/v10"
)
type CreateUserReq struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
}
var validate = validator.New()
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger, middleware.Recoverer)
r.Post("/users", func(w http.ResponseWriter, r *http.Request) {
var req CreateUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := validate.Struct(req); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": 1, "name": req.Name, "email": req.Email})
})
http.ListenAndServe(":8080", r)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
Chi gives you nothing fancy. The handler signature is the standard http.HandlerFunc. You bring your own JSON helpers and your own validator. But anything written for net/http (or other frameworks!) plugs in directly.
Where they differ
Routing power
All three support route groups, prefixes, and route-level middleware. Differences are minor in practice. Chi’s grouping is the cleanest IMO:
r.Route("/api", func(r chi.Router) {
r.Use(authRequired)
r.Get("/me", meHandler)
r.Route("/posts", func(r chi.Router) {
r.Get("/", listPosts)
r.Post("/", createPost)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", getPost)
r.Delete("/", deletePost)
})
})
})
Reads like the URL tree it represents.
Middleware
- Gin —
gin.HandlerFunc(c *gin.Context)signature. Custom type, can’t reuse stdlib middleware directly. - Echo —
echo.MiddlewareFunc. Same caveat. - Chi —
func(http.Handler) http.Handler. Same as the entire Go ecosystem. You can drop in any middleware ever written fornet/http. This is Chi’s killer feature.
Performance
All three are fast enough that your database, not the framework, will be the bottleneck. In synthetic benchmarks Gin and Echo edge out Chi by a small margin, but we’re talking microseconds on a request that probably takes 5+ ms anyway. Don’t pick on benchmarks.
Ecosystem
Gin is by far the most popular — biggest community, most tutorials, most third-party middleware. Echo is a close second. Chi is smaller but the community-led move toward stdlib-compatible patterns means a lot of “framework-agnostic” middleware works with it natively.
Stdlib-compatibility (the big one)
Chi handlers are http.Handler. You can:
- Test them with
httptest.NewRecorder()directly. - Wrap them in any
net/httpmiddleware. - Migrate from Chi to stdlib (or vice versa) by changing the router and almost nothing else.
Gin and Echo make you commit to their Context. The lock-in isn’t dramatic, but it’s real.
My take in 2026
- Picking for a small team, want it boring and the largest ecosystem? → Gin. It’s the de-facto default for a reason. Lots of examples online. New hires already know it.
- Want similar features but cleaner code style? → Echo. Subjective preference; both are great.
- Want maximum compatibility with the rest of the Go ecosystem and the cleanest mental model? → Chi. Especially if you might ever want to drop the framework entirely.
- Building something tiny or a microservice? → consider the stdlib alone .
For a brand-new API project today, I’d reach for Chi. The stdlib-compatibility is genuinely valuable as your project ages. But Gin is a perfectly fine choice and you won’t regret it.
What about Fiber?
You’ll hear about Fiber too — built on fasthttp, marketed on speed. It’s not compatible with net/http, which means you can’t reuse standard libraries that depend on the Go HTTP interfaces (which is most of them). I’d avoid it for serious projects unless raw RPS is genuinely your bottleneck.
Common patterns regardless of framework
- Centralize error rendering — one helper that turns errors into JSON responses with the right status code.
- Don’t put business logic in handlers — handlers parse, validate, call into a service, format the response. That’s it.
- Wire dependencies through the constructor, not globals — pass
*gorm.DB,*redis.Client, etc. into yourServerstruct. - Always set timeouts on
http.Server—ReadTimeout,WriteTimeout,IdleTimeout,ReadHeaderTimeout. Frameworks wraphttp.Serverbut don’t override your settings. - Always do graceful shutdown —
srv.Shutdown(ctx)so in-flight requests finish.
Conclusion
Pick the framework that fits your team’s taste, not the one with the biggest benchmark number. All three are mature, fast, and battle-tested. The difference between them in real-world apps is usually negligible.
If you want to understand what these frameworks are doing under the hood, build a small API with net/http directly
first. You’ll appreciate frameworks more — and lock in less.
Happy routing!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .