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.

Create a free account to save this page, take notes and track your progress. Sign in

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.HTTPError sets the status and message; returning any other error becomes a 500.
  • e.HTTPErrorHandler is 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 into e.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

Errors returned from handlers, handled in one place
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 nil after writing an error response manually. In Echo, just return the 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.Validate returns 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 (/v4 vs /v5).
  • Writing the response twice. Check c.Response().Committed in 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 HTTPErrorHandler formats every error and hides internals.
  • c.Bind + a pluggable e.Validator for input; groups and rich built-in middleware.
  • v5 uses *echo.Context; the engine is an http.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.

Was this page helpful?