Echo Framework
Echo is one of Go's leading high-performance web frameworks. It is among the most widely used alongside Gin, and excels at middleware customization and context extension. It features type-safe data binding and a rich set of built-in middleware.
Installation and Basic Usage
go get github.com/labstack/echo/v4
go get github.com/labstack/echo/v4/middleware
package main
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
// Register built-in middleware
e.Use(middleware.Logger()) // request logging
e.Use(middleware.Recover()) // panic recovery
e.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
})
e.Logger.Fatal(e.Start(":8080"))
}
Routing
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
// Routes by HTTP method
e.GET("/users", listUsers)
e.POST("/users", createUser)
e.GET("/users/:id", getUser) // path parameter
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)
// Accessing path parameters
e.GET("/posts/:id/comments/:cid", func(c echo.Context) error {
postID := c.Param("id") // path parameter
commentID := c.Param("cid")
return c.String(http.StatusOK, "post="+postID+", comment="+commentID)
})
// Query parameters
e.GET("/search", func(c echo.Context) error {
q := c.QueryParam("q") // single query parameter
page := c.QueryParam("page")
if page == "" { page = "1" }
return c.JSON(http.StatusOK, echo.Map{"q": q, "page": page})
})
// Wildcard
e.GET("/static/*", func(c echo.Context) error {
return c.String(http.StatusOK, c.Param("*"))
})
e.Logger.Fatal(e.Start(":8080"))
}
func listUsers(c echo.Context) error {
return c.JSON(http.StatusOK, echo.Map{"users": []any{}})
}
func createUser(c echo.Context) error { return c.JSON(http.StatusCreated, echo.Map{"id": 1}) }
func getUser(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"id": c.Param("id")}) }
func updateUser(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"updated": true}) }
func deleteUser(c echo.Context) error { return c.NoContent(http.StatusNoContent) }
Data Binding and Validation
package main
import (
"net/http"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)
// CustomValidator integrates the validator library
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i any) error {
return cv.validator.Struct(i)
}
type CreatePostRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Content string `json:"content" validate:"required"`
Status string `json:"status" validate:"omitempty,oneof=draft published"`
}
type QueryParams struct {
Page int `query:"page" validate:"omitempty,min=1"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
Sort string `query:"sort" validate:"omitempty,oneof=created_at title"`
}
func main() {
e := echo.New()
// Register validator
e.Validator = &CustomValidator{validator: validator.New()}
// JSON binding + validation
e.POST("/posts", func(c echo.Context) error {
var req CreatePostRequest
// Bind: auto-detects JSON/Form/XML
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": echo.Map{"code": "BAD_REQUEST", "message": err.Error()},
})
}
// Validation
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusUnprocessableEntity, echo.Map{
"error": echo.Map{"code": "VALIDATION_ERROR", "message": err.Error()},
})
}
return c.JSON(http.StatusCreated, echo.Map{"data": req})
})
// Query parameter binding
e.GET("/posts", func(c echo.Context) error {
var q QueryParams
if err := c.Bind(&q); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
}
if q.Page == 0 { q.Page = 1 }
if q.Limit == 0 { q.Limit = 20 }
return c.JSON(http.StatusOK, echo.Map{"page": q.Page, "limit": q.Limit})
})
e.Logger.Fatal(e.Start(":8080"))
}
Built-in Middleware
package main
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
// Request/response logging
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: `{"time":"${time_rfc3339}","method":"${method}","uri":"${uri}","status":${status},"latency":"${latency_human}"}` + "\n",
}))
// Panic recovery
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
StackSize: 1 << 10, // 1KB stack trace
}))
// CORS
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"https://example.com", "http://localhost:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowHeaders: []string{"Content-Type", "Authorization"},
}))
// Request ID
e.Use(middleware.RequestID())
// Gzip compression
e.Use(middleware.Gzip())
// Body size limit (2MB)
e.Use(middleware.BodyLimit("2M"))
// Rate limiting (20 requests per second)
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20)))
// Static file serving
e.Static("/static", "public")
e.GET("/", func(c echo.Context) error {
// Access request ID
id := c.Response().Header().Get(echo.HeaderXRequestID)
return c.JSON(200, echo.Map{"request_id": id})
})
e.Logger.Fatal(e.Start(":8080"))
}
Custom Context Extension
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
// AppContext is a custom context that extends echo.Context
type AppContext struct {
echo.Context
UserID int64
Role string
}
// Custom context middleware
func customContextMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cc := &AppContext{
Context: c,
UserID: 0,
Role: "guest",
}
return next(cc)
}
}
// Handler using the custom context
func profileHandler(c echo.Context) error {
cc := c.(*AppContext) // type assertion
return c.JSON(http.StatusOK, echo.Map{
"user_id": cc.UserID,
"role": cc.Role,
})
}
func main() {
e := echo.New()
e.Use(customContextMiddleware)
e.GET("/profile", profileHandler)
e.Logger.Fatal(e.Start(":8080"))
}
Groups and Middleware Chains
package main
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.Logger(), middleware.Recover())
// Public route group
public := e.Group("/api")
public.POST("/login", loginHandler)
public.POST("/register", registerHandler)
// Group requiring authentication
private := e.Group("/api/v1")
private.Use(jwtMiddleware)
private.GET("/me", meHandler)
private.GET("/users", listUsersHandler)
// Admin-only group (two middleware chained)
admin := e.Group("/api/admin")
admin.Use(jwtMiddleware, adminOnlyMiddleware)
admin.GET("/stats", statsHandler)
e.Logger.Fatal(e.Start(":8080"))
}
func jwtMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
auth := c.Request().Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return c.JSON(http.StatusUnauthorized, echo.Map{
"error": echo.Map{"code": "UNAUTHORIZED", "message": "Bearer token required"},
})
}
// In production, parse JWT and store user info via c.Set
c.Set("user_id", int64(1))
return next(c)
}
}
func adminOnlyMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// In production, verify user role here
return next(c)
}
}
func loginHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"token": "..."}) }
func registerHandler(c echo.Context) error { return c.JSON(http.StatusCreated, echo.Map{"id": 1}) }
func meHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"id": c.Get("user_id")}) }
func listUsersHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"users": []any{}}) }
func statsHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"users": 100}) }
Practical Example: File Upload Server
package main
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
const uploadDir = "./uploads"
const maxFileSize = 10 << 20 // 10MB
func init() {
os.MkdirAll(uploadDir, 0755)
}
// uploadHandler handles single file uploads
func uploadHandler(c echo.Context) error {
// Get the uploaded file
file, err := c.FormFile("file")
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": echo.Map{"code": "BAD_REQUEST", "message": "file field is required"},
})
}
// File size limit
if file.Size > maxFileSize {
return c.JSON(http.StatusRequestEntityTooLarge, echo.Map{
"error": echo.Map{"code": "FILE_TOO_LARGE", "message": "File size must be 10MB or less"},
})
}
// Extension validation
ext := strings.ToLower(filepath.Ext(file.Filename))
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".pdf": true}
if !allowedExts[ext] {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": echo.Map{"code": "INVALID_FILE_TYPE", "message": "Allowed file types: jpg, png, gif, pdf"},
})
}
// Open the file
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": echo.Map{"code": "SERVER_ERROR"}})
}
defer src.Close()
// Generate a unique filename (prevents path traversal attacks)
safeFilename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
dstPath := filepath.Join(uploadDir, safeFilename)
// Save the file
dst, err := os.Create(dstPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": echo.Map{"code": "SERVER_ERROR"}})
}
defer dst.Close()
if _, err = io.Copy(dst, src); err != nil {
os.Remove(dstPath) // delete on failure
return c.JSON(http.StatusInternalServerError, echo.Map{"error": echo.Map{"code": "SAVE_FAILED"}})
}
return c.JSON(http.StatusOK, echo.Map{
"data": echo.Map{
"filename": safeFilename,
"original_name": file.Filename,
"size": file.Size,
"url": "/uploads/" + safeFilename,
},
})
}
// multiUploadHandler handles multiple file uploads
func multiUploadHandler(c echo.Context) error {
form, err := c.MultipartForm()
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": echo.Map{"code": "BAD_REQUEST"}})
}
files := form.File["files"]
results := make([]echo.Map, 0, len(files))
for _, file := range files {
src, err := file.Open()
if err != nil {
continue
}
defer src.Close()
ext := filepath.Ext(file.Filename)
name := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
dst, _ := os.Create(filepath.Join(uploadDir, name))
io.Copy(dst, src)
dst.Close()
results = append(results, echo.Map{
"filename": name,
"original": file.Filename,
"size": file.Size,
})
}
return c.JSON(http.StatusOK, echo.Map{"data": results, "count": len(results)})
}
func main() {
e := echo.New()
e.Use(middleware.Logger(), middleware.Recover())
// File upload endpoints
e.POST("/upload", uploadHandler)
e.POST("/upload/multiple", multiUploadHandler)
// Serve uploaded files
e.Static("/uploads", uploadDir)
e.Logger.Fatal(e.Start(":8080"))
}
Gin vs Echo Comparison
| Item | Gin | Echo |
|---|---|---|
| Performance | Very fast | Fast |
| API style | c *gin.Context | echo.Context interface |
| Context extension | c.Set/c.Get (map-based) | Type-safe extension via interface embedding |
| Built-in middleware | Fewer (rich ecosystem) | Many (JWT, CORS, compression, etc.) |
| Binding | ShouldBind + validator | Bind + custom Validator |
| Error handling | Manual per handler | Centralized via e.HTTPErrorHandler |
| Popularity | Higher | High |
Expert Tips
1. Centralized error handling: Override Echo's e.HTTPErrorHandler to handle all errors in one place.
e.HTTPErrorHandler = func(err error, c echo.Context) {
code := http.StatusInternalServerError
message := "Internal server error"
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = fmt.Sprintf("%v", he.Message)
}
c.JSON(code, echo.Map{"error": echo.Map{"code": http.StatusText(code), "message": message}})
}
2. echo.Map vs structs: echo.Map is convenient for rapid prototyping, but as API responses grow more complex, defining structs is better for maintainability.
3. Middleware order: Middleware registered with e.Use() in Echo executes in registration order. Place Logger after Recover so that panics are also logged.