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, andhttp.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
Validatoron theecho.Echoinstance and callc.Validateafterc.Bind - Return
echo.ErrNotFound,echo.ErrUnauthorized, orecho.NewHTTPError(code, msg)from handlers — all route toHTTPErrorHandler - Test with
e.NewContext(req, rec)andhttptest— no server startup required
Comments