Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · The Standard Library and the Big Four
Echo: Handlers That Return Errors
Echo's handlers return an error, and one central HTTPErrorHandler turns every error into a response. Build the books API with Echo v5: binding, a pluggable validator, groups and built-in middleware.
What you'll build
The books API with Echo v5, where handlers simply return errors (not-found, bad input, unexpected failures) and one error handler formats them all consistently.
The idea
Echo (github.com/labstack/echo/v5) is a minimal, fast framework with a different philosophy from Gin: handlers return error.
func getBook(c *echo.Context) error {
return echo.NewHTTPError(http.StatusNotFound, "book not found")
}
- Returning an
*echo.HTTPErrorsets the status and message; returning any other error becomes a 500. e.HTTPErrorHandleris the single place that turns errors into JSON, logs unexpected ones, and hides internals.c.Bind(&in)binds path params, query params and the body;c.Validate(&in)calls the validator you plug intoe.Validator.- Middleware live in
github.com/labstack/echo/v5/middleware(Recover,RequestID,CORS,KeyAuth,RateLimiter…).
In v5 a handler receives *echo.Context (a struct pointer) instead of the v4 echo.Context interface; most other concepts carry over.
The code
package main
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
)
type CreateBook struct {
Title string `json:"title" validate:"required,min=2"`
Author string `json:"author" validate:"required"`
}
type requestValidator struct{ v *validator.Validate }
func (rv requestValidator) Validate(i any) error {
if err := rv.v.Struct(i); err != nil {
var fields validator.ValidationErrors
errors.As(err, &fields)
return echo.NewHTTPError(http.StatusUnprocessableEntity,
fmt.Sprintf("%s is %s", strings.ToLower(fields[0].Field()), fields[0].Tag()))
}
return nil
}
var errDatabaseDown = errors.New("connection refused: 10.0.0.5:5432") // an internal detail to hide
func main() {
e := echo.New()
e.Validator = requestValidator{validator.New()}
e.Use(middleware.Recover())
// One place decides how every error looks to the client.
e.HTTPErrorHandler = func(c *echo.Context, err error) {
status, message := http.StatusInternalServerError, "something went wrong"
var he *echo.HTTPError
if errors.As(err, &he) {
status, message = he.Code, fmt.Sprint(he.Message)
} else {
fmt.Println(" [log] unexpected error:", err) // log the real cause, don't send it
}
_ = c.JSON(status, map[string]string{"error": message})
}
g := e.Group("/api/v1")
g.GET("/books/:id", func(c *echo.Context) error {
if c.Param("id") != "1" {
return echo.NewHTTPError(http.StatusNotFound, "book not found")
}
return c.JSON(http.StatusOK, map[string]string{"id": "1", "title": "Learning Go"})
})
g.POST("/books", func(c *echo.Context) error {
var in CreateBook
if err := c.Bind(&in); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid JSON")
}
if err := c.Validate(&in); err != nil {
return err
}
return c.JSON(http.StatusCreated, in)
})
g.GET("/stats", func(c *echo.Context) error {
return fmt.Errorf("loading stats: %w", errDatabaseDown)
})
send := func(method, path, body string) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
out, _ := io.ReadAll(rec.Body)
fmt.Printf("%s %s -> %d %s", method, path, rec.Code, out)
}
send("GET", "/api/v1/books/1", "")
send("GET", "/api/v1/books/2", "")
send("POST", "/api/v1/books", `{"title":"Go in Action","author":"Kennedy"}`)
send("POST", "/api/v1/books", `{"title":"Go"}`)
send("POST", "/api/v1/books", `{not json`)
send("GET", "/api/v1/stats", "")
}
Output
GET /api/v1/books/1 -> 200 {"id":"1","title":"Learning Go"}
GET /api/v1/books/2 -> 404 {"error":"book not found"}
POST /api/v1/books -> 201 {"title":"Go in Action","author":"Kennedy"}
POST /api/v1/books -> 422 {"error":"author is required"}
POST /api/v1/books -> 400 {"error":"invalid JSON"}
[log] unexpected error: loading stats: connection refused: 10.0.0.5:5432
GET /api/v1/stats -> 500 {"error":"something went wrong"}Run this on your computer — see “Get Started” for setup.
The client sees "something went wrong" for the database failure, while the log keeps the real cause. That split is the point of a central error handler.
Common mistakes
- Returning
nilafter writing an error response manually. In Echo, justreturnthe error and let the handler format it. - Sending internal errors to clients. Database addresses and stack traces belong in logs.
- Forgetting to set
e.Validator.c.Validatereturns an error if no validator is registered. - Mixing v4 and v5 examples. v4 handlers take
echo.Context(interface), v5 take*echo.Context; import paths differ (/v4vs/v5). - Writing the response twice. Check
c.Response().Committedin custom error handlers if a handler may have already written.
Key points
- Echo handlers return
error;echo.NewHTTPError(code, msg)sets status and message. - One
HTTPErrorHandlerformats every error and hides internals. c.Bind+ a pluggablee.Validatorfor input; groups and rich built-in middleware.- v5 uses
*echo.Context; the engine is anhttp.Handler.
Further reading: Echo documentation, echo v5 on pkg.go.dev.
Practice questions
How does error handling in Echo differ from Gin?
Answer
Echo handlers return an error; returning an *echo.HTTPError sets the status and message, and a single e.HTTPErrorHandler converts every returned error into a response (and can log unexpected ones). In Gin, handlers write error responses themselves (c.JSON / c.AbortWithStatusJSON) and return nothing.
Why have one central error handler?
Answer
Every error response gets the same shape, internal details (stack traces, DB addresses) are logged but never sent to clients, and handlers stay short because they just return errors.
Open in the app to mark lessons complete, take notes and track your progress.