Skip to main content

Echo Framework: Building Web Applications in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 5 min read

Echo is a minimalist, high-performance web framework that sits on top of Go’s standard net/http. Unlike Fiber (which replaces net/http with fasthttp), Echo wraps the standard library — so standard middleware, the context.Context propagation model, and http.Handler wrappers all work natively.

The core value: Echo adds routing, parameter extraction, request binding, validation, and a custom error handler on top of net/http, without hiding the underlying HTTP machinery. When you need to drop down to r.Context(), http.ServeFile, or a third-party http.Handler, you can.

For the Gin alternative see Go Gin framework. For standard library HTTP see Go HTTP client and server.

Installation and Basic Setup

go get github.com/labstack/echo/v4
go get github.com/labstack/echo/v4/middleware

Echo wraps an http.Server — you set timeouts the standard way:

e := echo.New()
e.HideBanner = true  // suppress the Echo ASCII art banner in production

// Built-in middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.RequestID())  // adds X-Request-ID header

// Custom error handler — all handler errors route here
e.HTTPErrorHandler = func(err error, c echo.Context) {
    code := http.StatusInternalServerError
    msg  := "internal server error"
    var he *echo.HTTPError
    if errors.As(err, &he) {
        code = he.Code
        msg  = fmt.Sprintf("%v", he.Message)
    }
    if !c.Response().Committed {
        c.JSON(code, map[string]string{"error": msg})
    }
}

// Wrap with a configured http.Server for production timeouts
srv := &http.Server{
    Addr:         ":8080",
    Handler:      e,
    ReadTimeout:  15 * time.Second,
    WriteTimeout: 15 * time.Second,
    IdleTimeout:  60 * time.Second,
}
srv.ListenAndServe()

Routing

Echo’s router is case-sensitive with exact path matching. Route parameters use :name syntax; wildcards use *:

e.GET("/",              handleHome)
e.GET("/users/:id",     getUser)
e.POST("/users",        createUser)
e.PUT("/users/:id",     updateUser)
e.DELETE("/users/:id",  deleteUser)
e.GET("/files/*",       serveFiles)  // wildcard path

func getUser(c echo.Context) error {
    id := c.Param("id")

    user, err := userService.Get(c.Request().Context(), id)
    if errors.Is(err, ErrNotFound) {
        return echo.ErrNotFound  // *echo.HTTPError with code 404
    }
    if err != nil {
        return err  // caught by HTTPErrorHandler
    }
    return c.JSON(http.StatusOK, user)
}

Handlers return error — non-nil errors are passed to HTTPErrorHandler. Returning echo.ErrNotFound or echo.NewHTTPError(code, message) gives the error handler structured information to format the response.

Route Groups and Middleware

Groups share a URL prefix and a middleware chain. Middleware added to a group applies only to routes in that group:

api := e.Group("/api/v1")

// Public routes — no authentication
api.POST("/auth/login",    handleLogin)
api.POST("/auth/register", handleRegister)

// Authenticated routes
auth := api.Group("", jwtMiddleware())
auth.GET("/profile",         getProfile)
auth.PUT("/profile",         updateProfile)
auth.GET("/orders",          listOrders)
auth.GET("/orders/:id",      getOrder)
auth.POST("/orders",         createOrder)

// Admin routes — authentication + role check
admin := api.Group("/admin", jwtMiddleware(), requireRole("admin"))
admin.GET("/users",          adminListUsers)
admin.DELETE("/users/:id",   adminDeleteUser)
admin.GET("/stats",          getSystemStats)

Echo middleware is a func(echo.HandlerFunc) echo.HandlerFunc — it wraps the next handler:

func jwtMiddleware() echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            header := c.Request().Header.Get("Authorization")
            if !strings.HasPrefix(header, "Bearer ") {
                return echo.ErrUnauthorized
            }
            claims, err := parseJWT(strings.TrimPrefix(header, "Bearer "))
            if err != nil {
                return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
            }

            // Store for downstream handlers
            c.Set("userID", claims.UserID)
            c.Set("role",   claims.Role)
            return next(c)
        }
    }
}

func requireRole(role string) echo.MiddlewareFunc {
    return func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            if c.Get("role") != role {
                return echo.ErrForbidden
            }
            return next(c)
        }
    }
}

Request Binding and Validation

Echo’s Bind decodes JSON, form data, or query parameters into a struct based on Content-Type and struct tags. Pair it with go-playground/validator for field-level validation:

import "github.com/go-playground/validator/v10"

// Register a custom validator on the echo instance
type CustomValidator struct{ v *validator.Validate }
func (cv *CustomValidator) Validate(i any) error {
    return cv.v.Struct(i)
}

e.Validator = &CustomValidator{v: validator.New()}

// Request struct — both binding and validation tags
type CreateOrderRequest struct {
    CustomerID string  `json:"customer_id" validate:"required,uuid4"`
    Amount     int     `json:"amount"      validate:"required,gt=0"`
    Currency   string  `json:"currency"    validate:"required,len=3,alpha"`
    Items      []Item  `json:"items"       validate:"required,min=1,dive"`
}

func createOrder(c echo.Context) error {
    var req CreateOrderRequest
    // Bind reads body, query params, or path params based on tags
    if err := c.Bind(&req); err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, err.Error())
    }
    // Validate runs go-playground/validator
    if err := c.Validate(&req); err != nil {
        return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
    }

    order, err := orderService.Create(c.Request().Context(), req)
    if err != nil {
        return err
    }
    return c.JSON(http.StatusCreated, order)
}

For query parameters specifically, use QueryParam and QueryParamInt:

func listOrders(c echo.Context) error {
    status := c.QueryParam("status")
    page,_ := strconv.Atoi(c.QueryParam("page"))
    if page < 1 { page = 1 }

    orders, total, err := orderService.List(c.Request().Context(), status, page)
    if err != nil {
        return err
    }
    return c.JSON(http.StatusOK, echo.Map{
        "orders": orders,
        "total":  total,
        "page":   page,
    })
}

WebSocket Support

Echo provides WebSocket support through its websocket.IsWebSocketUpgrade helper and the gorilla/websocket upgrader:

import "github.com/gorilla/websocket"

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true  // allow all origins in development; restrict in production
    },
}

func handleWebSocket(c echo.Context) error {
    ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
    if err != nil {
        return err
    }
    defer ws.Close()

    for {
        messageType, msg, err := ws.ReadMessage()
        if err != nil {
            if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
                return nil
            }
            return err
        }
        // Echo the message back
        if err := ws.WriteMessage(messageType, msg); err != nil {
            return err
        }
    }
}

e.GET("/ws", handleWebSocket)

Testing Echo Handlers

Echo handlers are straightforward to test with httptest — the same approach as standard library handlers:

func TestCreateOrder(t *testing.T) {
    e := echo.New()
    e.Validator = &CustomValidator{v: validator.New()}

    tests := []struct {
        name       string
        body       string
        wantStatus int
    }{
        {
            name:       "valid order",
            body:       `{"customer_id":"550e8400-e29b-41d4-a716-446655440000","amount":999,"currency":"USD","items":[{"product_id":"p1","qty":1}]}`,
            wantStatus: http.StatusCreated,
        },
        {
            name:       "missing amount",
            body:       `{"customer_id":"550e8400-e29b-41d4-a716-446655440000","currency":"USD"}`,
            wantStatus: http.StatusUnprocessableEntity,
        },
        {
            name:       "invalid JSON",
            body:       `{bad json}`,
            wantStatus: http.StatusBadRequest,
        },
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            req := httptest.NewRequest(http.MethodPost, "/orders",
                strings.NewReader(tc.body))
            req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
            rec := httptest.NewRecorder()

            c := e.NewContext(req, rec)
            handler := createOrder

            // If using middleware, wrap the handler
            // handler = middleware(createOrder)

            if err := handler(c); err != nil {
                e.HTTPErrorHandler(err, c)
            }

            if rec.Code != tc.wantStatus {
                t.Errorf("status=%d want=%d body=%s", rec.Code, tc.wantStatus, rec.Body)
            }
        })
    }
}

e.NewContext(req, rec) creates an echo.Context from standard httptest types — no real server needed.

Summary

  • Echo wraps net/http — compatible with standard middleware, context.Context, and http.Handler
  • Set timeouts on the underlying http.Server, not via Echo’s config
  • Groups + middleware give clean authorization tiers without repeating middleware on every route
  • Register a custom Validator on the echo.Echo instance and call c.Validate after c.Bind
  • Return echo.ErrNotFound, echo.ErrUnauthorized, or echo.NewHTTPError(code, msg) from handlers — all route to HTTPErrorHandler
  • Test with e.NewContext(req, rec) and httptest — no server startup required

Resources

Comments

👍 Was this article helpful?