Updated Jun 23, 2026

A REST API with Error Handling

This is the phase where everything from the last five clicks into place. You've got routing, groups, binding, validation, responses, and middleware. Now we assemble them into a real REST resource — and lean hard on the one feature that's been quietly waiting in the wings since Phase 3: Echo's centralized error handling.

Here's the mental model to carry through this whole phase, two ideas held together:

  1. A REST resource is five handlers over one collection. For books, that's: list them all, get one, create one, update one, delete one. Five verbs, one slice of the world. Every CRUD API you'll ever write is this same shape repeated.
  2. In Echo, every failure path is "return an error." A handler doesn't write a 404 by hand. It returns an error, and one place — the HTTPErrorHandler — decides how that error looks to the client. Your handlers describe what went wrong; the central handler decides how it's rendered.

Hold both at once and the code almost writes itself: five small functions, each doing its work and either returning JSON on success or returning an error on failure. No handler ever hand-rolls an error response. That discipline is Echo's signature, and it's why Echo apps stay readable as they grow.

We'll keep building the books API, where a book is Book{id, title, author}.

The in-memory store

Before the handlers, we need somewhere to keep books. We'll use a plain map for now — no database, no file, just memory. That keeps the focus on Echo instead of SQL.

type Book struct {
	ID     int    `json:"id"`
	Title  string `json:"title"`
	Author string `json:"author"`
}

type Store struct {
	mu     sync.RWMutex
	books  map[int]Book
	nextID int
}

func NewStore() *Store {
	return &Store{books: make(map[int]Book), nextID: 1}
}

What just happened: Book is the resource as the client sees it — note the json:"..." tags so it serializes with lowercase keys. Store holds a map[int]Book keyed by ID, a nextID counter for assigning new IDs, and — the important part — a sync.RWMutex. NewStore hands back a ready-to-use, empty store with IDs starting at 1.

⚠️ That mutex is not optional, and this is the trap that bites people who skip it. Echo serves requests concurrently — each request runs in its own goroutine. If two requests touch books at the same moment (one reading while another writes), Go's map will panic with fatal error: concurrent map read and map write, and that one isn't recoverable — not even the Recover middleware from Phase 5 catches it. The fix is to guard every access: take a read lock (RLock) when you're only reading, and a full write lock (Lock) when you're modifying. We'll do exactly that in each handler below.

The five handlers

Now the heart of it. Each handler hangs off a versioned group — g := e.Group("/api/v1") — so all five live under /api/v1/books. Each one does its work and ends one of two ways: return c.JSON(...) on success, or return echo.NewHTTPError(...) on failure. Watch how that single rule plays out five times.

List — GET /api/v1/books

func (s *Store) list(c echo.Context) error {
	s.mu.RLock()
	defer s.mu.RUnlock()

	out := make([]Book, 0, len(s.books))
	for _, b := range s.books {
		out = append(out, b)
	}
	return c.JSON(http.StatusOK, out)
}

What just happened: We take a read lock (RLock) because we're only reading — multiple list requests can run at once without blocking each other, which is the whole point of RWMutex. We build a slice from the map's values and return it as JSON with 200 OK. The make([]Book, 0, ...) matters: it guarantees an empty store serializes to [], not null, which clients much prefer.

Get one — GET /api/v1/books/:id

func (s *Store) get(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "id must be a number")
	}

	s.mu.RLock()
	book, ok := s.books[id]
	s.mu.RUnlock()

	if !ok {
		return echo.NewHTTPError(http.StatusNotFound, "book not found")
	}
	return c.JSON(http.StatusOK, book)
}

What just happened: We pull :id from the path (it's always a string) and convert it with strconv.Atoi. A non-numeric id is the client's mistake, so we return a 400. Then we look the book up under a read lock. The comma-ok idiom (book, ok := s.books[id]) tells us whether it existed — if not, we return echo.NewHTTPError(http.StatusNotFound, "book not found"). Notice we are not writing the 404 response ourselves; we hand the error up and let the central handler render it. On a hit, 200 with the book.

Create — POST /api/v1/books

This is where Phase 3 comes back. Bind, validate, then act.

type CreateBook struct {
	Title  string `json:"title"  validate:"required,min=1"`
	Author string `json:"author" validate:"required"`
}

func (s *Store) create(c echo.Context) error {
	var in CreateBook
	if err := c.Bind(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid body")
	}
	if err := c.Validate(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	s.mu.Lock()
	book := Book{ID: s.nextID, Title: in.Title, Author: in.Author}
	s.books[book.ID] = book
	s.nextID++
	s.mu.Unlock()

	return c.JSON(http.StatusCreated, book)
}

What just happened: Same two-step gate from Phase 3 — c.Bind decodes the JSON, c.Validate runs the validate:"..." rules, and either failure returns a 400 describing what broke. Only once both pass do we take a full write lock (Lock, not RLock — we're mutating the map and the counter), build the Book with the next ID, store it, bump the counter, and unlock. Success returns 201 Created with the new resource so the client learns its assigned ID. We hold the write lock around all three mutations so no other request can squeeze in between assigning the ID and bumping the counter.

Update — PUT /api/v1/books/:id

func (s *Store) update(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "id must be a number")
	}

	var in CreateBook
	if err := c.Bind(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid body")
	}
	if err := c.Validate(&in); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.books[id]; !ok {
		return echo.NewHTTPError(http.StatusNotFound, "book not found")
	}
	updated := Book{ID: id, Title: in.Title, Author: in.Author}
	s.books[id] = updated
	return c.JSON(http.StatusOK, updated)
}

What just happened: Update is get-and-create fused. We parse the id, bind+validate the new values (reusing the very same CreateBook struct — no need for a second type), then take a write lock. We check the book exists first; missing means 404. If it's there, we overwrite it — keeping the original id so it stays addressable — and return 200 with the updated record. The defer s.mu.Unlock() is safe here even though we return early from inside the lock; defer runs on every exit path, including the 404 return.

Delete — DELETE /api/v1/books/:id

func (s *Store) delete(c echo.Context) error {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "id must be a number")
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.books[id]; !ok {
		return echo.NewHTTPError(http.StatusNotFound, "book not found")
	}
	delete(s.books, id)
	return c.NoContent(http.StatusNoContent)
}

What just happened: Parse the id, take a write lock, confirm the book exists (404 if not), then delete(s.books, id). The success response is c.NoContent(http.StatusNoContent) — a 204 with an empty body, which is the conventional answer to a successful delete: "done, nothing to hand back."

Wiring them up

All five mount on the /api/v1 group in main:

func main() {
	e := echo.New()
	e.Validator = &CustomValidator{v: validator.New()} // from Phase 3

	s := NewStore()
	g := e.Group("/api/v1")
	g.GET("/books", s.list)
	g.GET("/books/:id", s.get)
	g.POST("/books", s.create)
	g.PUT("/books/:id", s.update)
	g.DELETE("/books/:id", s.delete)

	e.Logger.Fatal(e.Start(":8080"))
}

What just happened: One group, five routes, each pointing at a method on the shared Store. Because the handlers are methods on *Store, they all close over the same map and mutex — that's how five independent functions cooperate on one collection. We register the Phase 3 validator so c.Validate works. Notice what's not here yet: any error-handling code. By default Echo already turns those returned HTTPErrors into JSON. But the default shape isn't quite what we want — so let's take control.

The payoff: a centralized HTTPErrorHandler

This is Echo's signature feature, the thing that makes all that return echo.NewHTTPError(...) discipline pay off. Every error your handlers return — plus any error Echo itself raises (a 404 for an unknown route, a 405 for the wrong method) — funnels through one function: e.HTTPErrorHandler. Change that one function and you've changed how every error in the entire API looks. One place, total consistency.

By default, Echo's handler renders an *echo.HTTPError as {"message": "..."} with the right status, and turns any other error into a generic 500. That's fine, but say your API has standardized on {"error": "..."} instead. Rather than touch thirty handlers, you write the shape once:

e.HTTPErrorHandler = func(err error, c echo.Context) {
	code := http.StatusInternalServerError
	msg := "internal error"
	if he, ok := err.(*echo.HTTPError); ok {
		code = he.Code
		msg = fmt.Sprintf("%v", he.Message)
	}
	c.JSON(code, map[string]string{"error": msg})
}

What just happened: This function receives every error a handler returns. We start with safe defaults — 500 and a vague "internal error", because an unexpected error (a nil-pointer deref, a failed DB call later) should never leak its guts to the client. Then we type-assert: if the error is an *echo.HTTPError (which is what echo.NewHTTPError produces), we trust its Code and Message, because we chose those deliberately. Finally we render one consistent JSON body: {"error": "..."}, every single time. A book not found and a malformed id and a deep 500 all come out the same shape — only the status and message differ. Register it in main alongside the validator:

e.HTTPErrorHandler = customErrorHandler // the function above

💡 Sit with what this bought you. Across five handlers we wrote return echo.NewHTTPError(...) maybe eight times, and zero lines of response-formatting code in any of them. The handlers stayed pure business logic — look up, check, act. All the "what does an error look like on the wire" logic lives in one ten-line function. When a teammate asks "why are our errors shaped like that?" there's exactly one file to open. That's the difference between an API that stays clean at fifty endpoints and one that rots into copy-pasted c.JSON(400, ...) calls everywhere.

Taking it for a spin

Start the server, then exercise the books API with a few curl calls:

# Create a book → 201
curl -s -X POST localhost:8080/api/v1/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"Dune","author":"Herbert"}'
# {"id":1,"title":"Dune","author":"Herbert"}

# List them → 200
curl -s localhost:8080/api/v1/books
# [{"id":1,"title":"Dune","author":"Herbert"}]

# Get one that doesn't exist → 404, your custom shape
curl -s localhost:8080/api/v1/books/999
# {"error":"book not found"}

# Create with an empty title → 400, validator's message
curl -s -X POST localhost:8080/api/v1/books \
  -H 'Content-Type: application/json' \
  -d '{"title":"","author":"Nobody"}'
# {"error":"Key: 'CreateBook.Title' Error:Field validation for 'Title' failed on the 'required' tag"}

# Delete it → 204, empty body
curl -s -i -X DELETE localhost:8080/api/v1/books/1 | head -n 1
# HTTP/1.1 204 No Content

What just happened: The happy paths return the resource as JSON with the right status. The two failure paths — a missing book and a validation miss — both come back in your {"error": ...} shape, even though one originated in a handler's NewHTTPError and the other in the validator. That uniformity is the centralized handler doing its job: the client only ever has to parse one error format.

💡 Notice the store was the only part tied to memory. Every handler talks to Store, never to a map directly — so when you outgrow in-memory storage, you swap Store's guts for a database and the five handlers don't change a line. That's the natural next step: GORM From Zero shows how to back this exact API with a real SQL table. Same handlers, same error handling, real persistence underneath.

Recap

  • A REST resource is five handlers over one collection: list (200), get (200/404), create (201), update (200/404), delete (204). The books API is this shape, mounted on a g := e.Group("/api/v1").
  • The in-memory Store is a map[int]Book plus a sync.RWMutex and a nextID. ⚠️ Echo serves requests concurrently — guard reads with RLock and writes with Lock, or an unguarded map will panic unrecoverably.
  • Handlers stay clean by following one rule: bind → validate → do work → return c.JSON(...) or return echo.NewHTTPError(...). They never hand-roll an error response.
  • The centralized HTTPErrorHandler is Echo's signature feature: one function turns every returned error into one consistent shape (here {"error": ...}), with safe 500 defaults for unexpected errors and trusted codes/messages for *echo.HTTPError.
  • Because every handler talks to Store and never to a map directly, you can swap the store for a real database (GORM From Zero) without touching a single handler.

Quick check

[
  {
    "q": "Why does the in-memory Store need a sync.RWMutex?",
    "choices": ["To make the JSON serialize faster", "Because Echo serves requests concurrently, and an unguarded map read+write panics", "Because echo.NewHTTPError requires a locked store", "It's optional; maps are already concurrency-safe in Go"],
    "answer": 1,
    "explain": "Echo runs each request in its own goroutine. Concurrent read and write on a plain Go map causes an unrecoverable fatal error, so shared state must be guarded with a mutex — RLock for reads, Lock for writes."
  },
  {
    "q": "In an Echo handler, how should you report that a book wasn't found?",
    "choices": ["Call c.JSON(404, ...) with a hand-built error body", "panic(\"not found\") and let Recover handle it", "return echo.NewHTTPError(http.StatusNotFound, \"book not found\")", "Return nil and set the status separately"],
    "answer": 2,
    "explain": "The Echo style is to return an HTTPError. Handlers describe what went wrong; the centralized HTTPErrorHandler decides how it's rendered — so handlers never hand-roll error responses."
  },
  {
    "q": "What does customizing e.HTTPErrorHandler give you?",
    "choices": ["One place that turns every returned error into one consistent response shape for the whole API", "Automatic validation of every request body", "Per-route error formatting that each handler configures itself", "Faster routing for the /api/v1 group"],
    "answer": 0,
    "explain": "The HTTPErrorHandler is a single function every error funnels through. Change it once and every error in the API — from handlers and from Echo itself — comes out in the same shape, with safe 500 defaults for unexpected errors."
  }
]

← Phase 5: Middleware · Guide overview · Phase 7: Testing & Production →

Check your understanding

1. Why does the in-memory Store need a sync.RWMutex?

2. In an Echo handler, how should you report that a book wasn't found?

3. What does customizing e.HTTPErrorHandler give you?

Was this page helpful?