Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 018 / 100

Choosing a Go web framework: what developers should know

stdlib, chi, Gin or Echo?

Expona Admin7 min read

By Day 18 of building production APIs in Go, you've likely hit the first big decision: which web framework should your team standardize on? The choice between the standard library, chi, Gin, and Echo shapes not just your codebase but also your team's productivity, deployment size, and long-term maintenance burden. This article walks through what each option offers and how to make a decision you won't regret.

The standard library is enough for many APIs

Before reaching for a framework, recognize that Go's net/http package is genuinely powerful. It isn't a toy or a learning tool—it powers production APIs across finance, e-commerce, and ride-hailing at scale. The standard library gives you routing, middleware chains, context handling, and graceful shutdown. For many teams, especially those building microservices with limited scope, the stdlib is the right call.

Consider a straightforward order-processing microservice. You need to accept POST requests, validate JSON, query a database, and return responses. Here's what a minimal stdlib API looks like:

go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

type Order struct {
	ID    string `json:"id"`
	Total int    `json:"total"`
}

func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var order Order
	if err := json.NewDecoder(r.Body).Decode(&order); err != nil {
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}

	// Process order...
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(order)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders", handleCreateOrder)

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
	}

	go func() {
		log.Fatal(server.ListenAndServe())
	}()

	// Graceful shutdown
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
	<-sigChan

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	server.Shutdown(ctx)
}

This is a complete, production-ready server. Note that Go 1.22 introduced pattern-based routing (POST /orders), which handles a common pain point. The stdlib approach means zero external dependencies, fast deploys, small container images, and straightforward debugging. For internal services, backend-for-frontend layers, or APIs with simple route structures, this is often the optimal choice.

When to stick with stdlib: Your API has fewer than 20 routes, middleware needs are basic, and your team values simplicity. You're building a single-purpose service, not a monolith.

chi: lightweight and idiomatic

Chi is the pragmatic middle ground. It weighs around 10KB, adds almost no overhead, and feels like an extension of the stdlib rather than a different language. Chi excels when you need composable middleware, cleaner routing, and better request handling—without the ceremony of heavier frameworks.

The same order API in chi looks like this:

go
package main

import (
	"encoding/json"
	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"log"
	"net/http"
	"time"
)

type Order struct {
	ID    string `json:"id"`
	Total int    `json:"total"`
}

func createOrderHandler(w http.ResponseWriter, r *http.Request) {
	var order Order
	if err := json.NewDecoder(r.Body).Decode(&order); err != nil {
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(order)
}

func main() {
	router := chi.NewRouter()

	// Built-in middleware
	router.Use(middleware.Logger)
	router.Use(middleware.Recoverer)
	router.Use(middleware.Timeout(10 * time.Second))

	// Routes
	router.Post("/orders", createOrderHandler)

	// Nested routes and sub-routers for organization
	router.Route("/api/v1", func(r chi.Router) {
		r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(http.StatusOK)
		})
	})

	log.Fatal(http.ListenAndServe(":8080", router))
}

Chi shines through composable middleware, cleaner route grouping, and patterns that feel native to Go. In a real project, you'd build custom middleware for request ID tracking, authentication, or request validation—chi's design makes this natural:

go
func authenticate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		// Validate token...
		next.ServeHTTP(w, r)
	})
}

router.Route("/protected", func(r chi.Router) {
	r.Use(authenticate)
	r.Get("/data", getDataHandler)
})

When to choose chi: Your team wants stdlib-like simplicity with better routing, middleware composition matters, and you're not in a performance-critical hot path where microseconds count. Chi is the choice for most internal APIs and services we build at scale.

Gin and Echo: batteries and speed

Gin and Echo are full-featured frameworks. They include parameter binding, built-in validation, automatic HTML rendering, and aggressive optimization. If your API needs to accept complex nested JSON, validate payloads against schemas, or render HTML templates, these frameworks reduce boilerplate.

Here's the same order API in Gin:

go
package main

import (
	"github.com/gin-gonic/gin"
	"net/http"
)

type Order struct {
	ID    string `json:"id" binding:"required"`
	Total int    `json:"total" binding:"required,gt=0"`
}

func createOrderHandler(c *gin.Context) {
	var order Order
	if err := c.ShouldBindJSON(&order); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	c.JSON(http.StatusCreated, order)
}

func main() {
	router := gin.Default()

	router.POST("/orders", createOrderHandler)
	router.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	router.Run(":8080")
}

Gin's ShouldBindJSON automatically parses and validates JSON against struct tags. Echo is similar but exposes slightly more control over request lifecycle. Both frameworks are opinionated—they assume patterns for logging, error handling, and middleware—which speeds initial development but can feel constraining later.

In a ride-hailing or e-commerce backend where you're handling dozens of endpoints with complex request bodies, this structure reduces repetition. Both frameworks are also more aggressive about performance, using pooling and reflection tricks that matter at high throughput (think 10K+ requests per second).

When to choose Gin or Echo: You're building a large public API, request validation complexity is high, your team values convention over configuration, or performance is a constraint where every millisecond matters. Use Gin if your team leans toward simplicity; use Echo if you want more flexibility.

Pick one and stay consistent

The worst decision you can make is mixing frameworks across your codebase. A team running chi for some services and Gin for others pays the context-switching cost every sprint. Developers familiar with chi's middleware composition get confused by Gin's context semantics. Code reviews slow down. Onboarding takes longer.

Make a deliberate choice as a team, document it, and stick with it for at least a year. Here's a practical decision matrix:

Scenario Best Choice Reason
Simple microservice, few routes, internal only stdlib No dependencies, minimal overhead, easy to reason about
Internal API, composable middleware, mid-size codebase chi Lightweight, idiomatic, excellent routing and middleware composition
Public API, complex validation, high throughput Gin or Echo Built-in validation, binding, performance optimization
Monolith with mixed responsibilities chi or stdlib Flexibility to keep code simple; avoid over-engineering with a heavy framework

Once chosen, your framework becomes infrastructure. Treat it as such: document conventions, create scaffolding templates, and enforce consistency in code review. A team running chi across 15 services has institutional knowledge; the same team split between chi and Gin wastes cycles.

Common mistakes

Picking a framework before understanding requirements: Teams often default to a heavy framework because "we might need it later." This premature optimization wastes time and creates deployment bloat. Start simple; migrate if you hit real constraints.

Ignoring deployment size: Gin and Echo produce slightly larger binaries than stdlib or chi. In containerized deployments with thousands of instances, this compounds. Chi and stdlib keep images lean.

Framework shopping mid-project: Switching frameworks after six months of development is expensive. Make the call upfront, or live with it.

Underestimating learning curves: Gin and Echo are easy to start but harder to debug when context handling becomes complex. Chi and stdlib are more explicit, which feels verbose initially but pays off.

Neglecting middleware strategy: Whatever framework you choose, establish conventions for middleware ordering, error handling, and logging from day one. Framework choice matters less than consistency here.

Key takeaways

  • The stdlib is sufficient and preferable for many production APIs; don't default to a framework out of habit.
  • Chi offers the best balance of simplicity, performance, and idiomatic Go design for most teams.
  • Gin and Echo are justified when you have complex validation, high throughput needs, or a large public API surface.
  • Team consistency matters more than framework features; pick one and enforce it across services.
  • Evaluate frameworks based on your actual requirements, not hypothetical future needs.
  • Document your choice and middleware patterns early; they compound across a codebase.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles