Echo: A High‑Performance, Minimalist Go Web Framework
Introduction
Echo (github.com/labstack/echo) is a lightweight, yet powerful web framework for Go that focuses on speed, simplicity, and extensibility. With 32,228 stars on GitHub, it has become one of the most popular choices for building HTTP services, APIs, and micro‑services in the Go ecosystem.
Developers reach for Echo when they need:
- High throughput – Echo’s router is optimized for low allocation and fast lookup, making it suitable for services that handle tens of thousands of requests per second.
- Minimal boilerplate – The core API consists of a few structs and methods, letting you get a server up and running in under ten lines of code.
- Rich middleware ecosystem – Built‑in support for logging, recovery, CORS, JWT, rate limiting, request ID, and more means you can add cross‑cutting concerns without pulling in extra dependencies.
- Easy extensibility – You can write custom middleware, bind custom renderers, or plug in your own validator with virtually zero friction.
Typical real‑world use cases include RESTful JSON APIs for mobile back‑ends, internal micro‑service communication, admin dashboards, and even simple static‑file servers with dynamic routing. Echo solves the common pain points of verbose net/http code, repetitive error handling, and the need to assemble a suite of third‑party packages just to get logging, CORS, and JWT working together.
Key Features Echo packs a lot of capability into a small API surface. Below are the features that make it stand out, each explained with why it matters and how it compares to the standard library.
| Feature | What It Does | Why It’s Valuable | Standard Go Equivalent |
|---|---|---|---|
| High‑performance router | Uses a radix tree (trie) for O(log n) route lookup, with zero‑allocation path parameters. | Faster than the default ServeMux for complex routing tables; scales well with many static and parameterized routes. |
http.ServeMux (linear scan) |
| Middleware chaining | Middleware are func(echo.HandlerFunc) echo.HandlerFunc; can be applied globally, per‑group, or per‑route. |
Centralizes cross‑cutting concerns (logging, auth, CORS) and keeps handlers clean. | Manual wrapping of http.Handler |
| Built‑in logger & recovery | echo.Logger (zerolog‑compatible) and middleware.Recover() recover panics and log stack traces. |
Eliminates boilerplate for request‑scoped logging and prevents crashes from taking down the whole server. | log.Printf + custom recover |
| Automatic HTTPS & HTTP/2 | e.StartTLS or e.AutoTLS (via golang.org/x/crypto/acme/autocert) handles cert provisioning. |
One‑line production‑grade TLS setup; HTTP/2 comes for free. | Manual http.ListenAndServeTLS + cert management |
| Data binding & validation | e.Bind(&payload) uses tags (json, form, xml) and integrates with go-playground/validator.v10. |
Reduces repetitive json.Decoder code and provides declarative validation with customizable error messages. |
Manual decoding + validation |
| Template rendering | Supports HTML/template, Jet, Amber, etc., via e.Renderer. |
Enables server‑side rendering without leaving Echo; swap renderers by implementing a simple interface. | html/template package directly |
| Graceful shutdown | e.Shutdown(ctx) waits for active connections to finish before exiting. |
Essential for zero‑downtime deployments and container orchestrators (Kubernetes). | http.Server.Shutdown (requires manual server creation) |
| WebSocket support (via middleware) | middleware.WebSocketWithConfig upgrades connections and lets you define a handler func. |
Real‑time features (chat, notifications) become a few lines of code. | Manual http.Hijacker + upgrade logic |
These features combine to give you a “batteries‑included” experience while staying true to Go’s philosophy of small, composable packages.
Installation and Setup
Echo is distributed as a Go module. The current stable major version is v4, which requires Go 1.18+ (modules are mandatory).
1# Initialize a module if you don’t have one yet
2go mod init example.com/echo-demo
3
4# Add Echo v4 as a dependencygo get github.com/labstack/echo/v4
Verification
Create a file main.go with the minimal Hello World (see the next section) and run:
You should see output similar to:
1⇨ http server started on [::]:1323
Visiting http://localhost:1323 in a browser or with curl returns Hello, World!. If the server starts without errors, Echo is correctly installed.
Basic Usage
Below is the simplest possible Echo program. Every line is annotated to show what it does.
1package main
2
3import (
4 "github.com/labstack/echo/v4"
5 "github.com/labstack/echo/v4/middleware"
6)
7
8func main() {
9 // 1️⃣ Create a new Echo instance.
10 e := echo.New()
11
12 // 2️⃣ Attach global middleware: logger and recovery.
13 e.Use(middleware.Logger())
14 e.Use(middleware.Recover())
15
16 // 3️⃣ Define a route. Echo’s router supports HTTP verbs as methods.
17 e.GET("/", func(c echo.Context) error {
18 // Context gives you access to request, response, and helpers.
19 return c.String(200, "Hello, World!")
20 })
21
22 // 4️⃣ Start the server on port 1323. ListenAddr can be ":8080", "127.0.0.1:8080", etc.
23 // The method blocks until the program is interrupted or Shutdown is called.
24 if err := e.Start(":1323"); err != nil {
25 e.Logger.Fatalf("shutting down the server: %v", err)
26 }
27}
Explanation
echo.New()creates the core router and holds middleware, groups, and routes.middleware.Logger()writes a formatted access log toos.Stdout;middleware.Recover()catches panics, logs the stack, and returns a 500 response.e.GETregisters a handler for the root path. The handler receives anecho.Context, which abstracts*http.Requestandhttp.ResponseWriter.e.Startbinds the listener and begins accepting connections. On error (e.g., port already in use) we log and exit.
Expected output (terminal):
1⇨ http server started on [::]:1323
curl test
1HTTP/1.1 200 OK
2Content-Length: 13
3Content-Type: text/plain; charset=UTF-8
4Date: ...
5
6Hello, World!
Real‑World Examples
The following three examples illustrate production‑grade usage of Echo. Each program is self‑contained, includes proper error handling, logging, and demonstrates features you would typically need in a service.
Note: All examples assume you have initialized a Go module (
go mod init) and have rungo getfor the dependencies shown in the imports.
Example 1 – JWT‑Protected Todo REST API (CRUD + Validation)
This example builds a tiny todo service:
-
Endpoints
POST /todos– create a todo (requires JWT)GET /todos– list all todos (requires JWT)GET /todos/:id– get a single todoPUT /todos/:id– update a todoDELETE /todos/:id– delete a todo
-
Middleware
middleware.Logger()andmiddleware.Recover()(global) *middleware.JWTWithConfigfor protecting/todos*routes- Custom validator using
go-playground/validator/v10for request payloads
-
Data store – an in‑memory map protected by a
sync.RWMutex(good for demo; replace with a real DB in production).
1package main
2
3import (
4 "errors"
5 "net/http"
6 "sync"
7 "time"
8
9 "github.com/dgrijalva/jwt-go"
10 "github.com/labstack/echo/v4"
11 "github.com/labstack/echo/v4/middleware"
12 "gopkg.in/go-playground/validator.v9"
13)
14
15// ---------- Model ----------
16type Todo struct {
17 ID int `json:"id" validate:"required,gte=1"`
18 Title string `json:"title" validate:"required,min=3,max=100"`
19 Done bool `json:"done"`
20}
21
22// ---------- In‑memory store ----------
23type todoStore struct {
24 mu sync.RWMutex
25 data map[int]*Todo
26 next int
27}
28
29func newTodoStore() *todoStore {
30 return &todoStore{data: make(map[int]*Todo), next: 1}
31}
32
33func (s *todoStore) Create(t *Todo) int {
34 s.mu.Lock()
35 defer s.mu.Unlock()
36 t.ID = s.next
37 s.next++
38 s.data[t.ID] = t
39 return t.ID
40}
41
42func (s *todoStore) Get(id int) (*Todo, bool) {
43 s.mu.RLock()
44 defer s.mu.RUnlock()
45 t, ok := s.data[id]
46 return t, ok
47}
48
49func (s *todoStore) Update(id int, t *Todo) error {
50 s.mu.Lock()
51 defer s.mu.Unlock()
52 if _, ok := s.data[id]; !ok {
53 return errors.New("not found")
54 }
55 t.ID = id s.data[id] = t
56 return nil}
57
58func (s *todoStore) Delete(id int) error {
59 s.mu.Lock()
60 defer s.mu.Unlock()
61 if _, ok := s.data[id]; !ok {
62 return errors.New("not found")
63 }
64 delete(s.data, id)
65 return nil
66}
67
68func (s *todoStore) List() []*Todo {
69 s.mu.RLock()
70 defer s.mu.RUnlock()
71 list := make([]*Todo, 0, len(s.data))
72 for _, t := range s.data {
73 list = append(list, t)
74 }
75 return list
76}
77
78// ---------- Custom validator ----------
79type customValidator struct {
80 validator *validator.Validate
81}
82
83func (cv *customValidator) Validate(i interface{}) error {
84 if err := cv.validator.Struct(i); err != nil {
85 // Return Echo's ValidationError type for proper JSON error response.
86 return echo.NewHTTPError(http.StatusBadRequest, err.Error())
87 }
88 return nil
89}
90
91// ---------- JWT helper ----------
92var jwtSecret = []byte("super-secret-key") // In production load from env or secret manager.
93
94func generateJWT(id int) (string, error) {
95 claims := jwt.MapClaims{
96 "user_id": id,
97 "exp": time.Now().Add(time.Hour * 72).Unix(),
98 }
99 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
100 return token.SignedString(jwtSecret)
101}
102
103// ---------- Main ----------
104func main() {
105 e := echo.New()
106 e.Use(middleware.Logger())
107 e.Use(middleware.Recover())
108
109 // Register custom validator.
110 e.Validator = &customValidator{validator: validator.New()}
111
112 // Public endpoint – login (returns JWT).
113 e.POST("/login", func(c echo.Context) error {
114 // In a real app you’d validate username/password against a DB.
115 // Here we accept any payload with a "username" field and fake user ID 1.
116 type loginReq struct {
117 Username string `json:"username" validate:"required,alphanum"`
118 }
119 var req loginReq
120 if err := c.Bind(&req); err != nil {
121 return err
122 }
123 if err := c.Validate(req); err != nil {
124 return err
125 }
126 token, err := generateJWT(1) // user ID 1 for demo
127 if err != nil {
128 return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate token")
129 }
130 return c.JSON(http.StatusOK, map[string]string{"token": token})
131 })
132
133 // Protected todo routes.
134 r := e.Group("/todos")
135 config := middleware.JWTConfig{
136 Claims: &jwt.MapClaims{},
137 SigningKey: jwtSecret,
138 }
139 r.Use(middleware.JWTWithConfig(config))
140
141 store := newTodoStore()
142
143 r.POST("", func(c echo.Context) error {
144 var t Todo
145 if err := c.Bind(&t); err != nil {
146 return err
147 }
148 if err := c.Validate(t); err != nil {
149 return err
150 }
151 id := store.Create(&t)
152 return c.JSON(http.StatusCreated, map[string]int{"id": id})
153 })
154
155 r.GET("", func(c echo.Context) error {
156 todos := store.List()
157 return c.JSON(http.StatusOK, todos)
158 })
159
160 r.GET("/:id", func(c echo.Context) error {
161 id := c.Param("id")
162 var tid int
163 if _, err := fmt.Sscanf(id, "%d", &tid); err != nil {
164 return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
165 }
166 t, ok := store.Get(tid)
167 if !ok {
168 return echo.NewHTTPError(http.StatusNotFound, "todo not found")
169 }
170 return c.JSON(http.StatusOK, t)
171 })
172
173 r.PUT("/:id", func(c echo.Context) error {
174 id := c.Param("id")
175 var tid int
176 if _, err := fmt.Sscanf(id, "%d", &tid); err != nil {
177 return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
178 }
179 var t Todo
180 if err := c.Bind(&t); err != nil {
181 return err
182 }
183 if err := c.Validate(t); err != nil {
184 return err
185 }
186 if err := store.Update(tid, &t); err != nil {
187 return echo.NewHTTPError(http.StatusNotFound, err.Error())
188 }
189 return c.NoContent(http.StatusOK)
190 })
191
192 r.DELETE("/:id", func(c echo.Context) error {
193 id := c.Param("id")
194 var tid int
195 if _, err := fmt.Sscanf(id, "%d", &tid); err != nil {
196 return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
197 }
198 if err := store.Delete(tid); err != nil {
199 return echo.NewHTTPError(http.StatusNotFound, err.Error())
200 }
201 return c.NoContent(http.StatusNoContent)
202 })
203
204 // Graceful shutdown.
205 go func() {
206 if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed {
207 e.Logger.Fatalf("shutting down the server: %v", err)
208 }
209 }()
210
211 // Wait for interrupt signal to gracefully shutdown.
212 quit := make(chan os.Signal, 1)
213 signal.Notify(quit, os.Interrupt)
214 <-quit
215 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
216 defer cancel()
217 if err := e.Shutdown(ctx); err != nil {
218 e.Logger.Fatal(err)
219 }
220}
How to run
1go run .
Sample interaction
1# 1️⃣ Obtain a token (any username works)
2$ curl -X POST http://localhost:1323/login -H "Content-Type: application/json" \
3 -d '{"username":"alice"}'
4{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
5
6# 2️⃣ Create a todo$ curl -X POST http://localhost:1323/todos \
7 -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
8 -H "Content-Type: application/json" \
9 -d '{"title":"Buy milk"}'
10{"id":1}
11
12# 3️⃣ List todos
13$ curl -H "Authorization: Bearer <same token>" http://localhost:1323/todos
14[{"id":1,"title":"Buy milk","done":false}]
The example demonstrates:
- Middleware stacking (logger → recover → JWT).
- Custom validator returning Echo’s
HTTPErrorfor clean JSON validation errors. - Stateless JWT authentication (no sessions).
- Graceful shutdown on SIGINT.