Skip to main content

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

ItemGinEcho
PerformanceVery fastFast
API stylec *gin.Contextecho.Context interface
Context extensionc.Set/c.Get (map-based)Type-safe extension via interface embedding
Built-in middlewareFewer (rich ecosystem)Many (JWT, CORS, compression, etc.)
BindingShouldBind + validatorBind + custom Validator
Error handlingManual per handlerCentralized via e.HTTPErrorHandler
PopularityHigherHigh

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.