This tutorial builds a complete JSON REST API in Go using only the standard library: routing, validation, a storage interface, error mapping, middleware, per-request timeouts, graceful shutdown, and tests. You end with a service you could actually deploy, and you will understand every line. If you know Go basics, you are ready.
Works with Go 1.22+ (all examples tested on Go 1.24.7). The method-and-path routing this article leans on landed in Go 1.22; on older versions you would need a third-party router for the same result.
Most REST API tutorials for Go make one of two mistakes. They open by importing Gin or Chi, so you learn the framework instead of HTTP. Or they stop at an in-memory GET /users that returns a hardcoded slice, with no request parsing, no validation, no error shape, no tests, and no shutdown story. A toy. This one goes the whole distance on the standard library, and since Go 1.22 the standard library is genuinely enough for most services. We will build a tasks API: create a task, fetch one, list them all. Small enough to hold in your head, complete enough to expose every real problem.
If any Go fundamentals feel shaky as you go, keep the complete Go tutorial open in another tab; this article assumes you can already read structs, interfaces, and error returns.
What you will build, and how the pieces fit
The finished service is about 250 lines in one file, then split into the layers a real project uses. Here is the shape before we write it, so every step has a home:
- Transport layer:
net/httphandlers that parse requests and write JSON. They contain no business logic. - Storage layer: a
TaskStoreinterface with an in-memory implementation. Swap it for Postgres later without touching a handler. - Error mapping: one function that translates domain errors (
ErrTaskNotFound) into HTTP status codes. - Middleware: request ID, structured logging, and panic recovery, composed as a chain wrapping every route.
- Lifecycle: an
http.Serverwith read timeouts and graceful shutdown onSIGTERM.
The API surface:
GET /tasks list all tasks
POST /tasks create a task
GET /tasks/{id} fetch one task
Every code block below is compile-tested on Go 1.24.7. To make the examples run offline and show you real request/response pairs, they use net/http/httptest, which starts a real HTTP server on a random local port. The output under each block is the actual output that ran, not a mockup. You can paste any block into a main.go and run it.
How net/http models a server: Handler and HandlerFunc
Before routing, understand the one interface the whole net/http server is built on. From the net/http docs:
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
Anything with a ServeHTTP method can handle HTTP requests. That is the entire contract. You rarely write a struct with a ServeHTTP method by hand, though, because most handlers are just functions. http.HandlerFunc is an adapter: it is a function type that has a ServeHTTP method calling itself, so any func(http.ResponseWriter, *http.Request) becomes a Handler for free.
Here is the smallest complete server, exercised with a real request:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
// healthHandler is an http.HandlerFunc: a plain function with the
// signature func(http.ResponseWriter, *http.Request).
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintln(w, "ok")
}
func main() {
// httptest.NewServer starts a real HTTP server on a random local port,
// so this runs offline with no browser or curl needed.
srv := httptest.NewServer(http.HandlerFunc(healthHandler))
defer srv.Close()
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Printf("GET /healthz -> %d\n", resp.StatusCode)
fmt.Printf("Content-Type: %s\n", resp.Header.Get("Content-Type"))
fmt.Printf("body: %s", body)
}
Output:
GET /healthz -> 200
Content-Type: text/plain; charset=utf-8
body: ok
Two things to notice. The ResponseWriter is a stream: you set headers, then write the body, and once bytes flow the status is locked in. And you never called WriteHeader; writing a body implicitly sends a 200. That implicit-200 behavior is the source of a common bug we will hit in the mistakes section.
If you want the ground-up mechanics of the server (listeners, connection handling, the write flow), the net/http server from scratch guide goes deeper than we can here. This tutorial stays focused on the API.
Routing with method and path since Go 1.22
Before Go 1.22, the standard ServeMux matched on path only, with no method matching and no path parameters. GET /tasks/{id} was impossible without a third-party router, which is the single biggest reason older tutorials reach for gorilla/mux or Chi. Go 1.22 changed that. Per the routing enhancements announcement, patterns can now include an optional method and named wildcards.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
// Method + literal path.
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "list all tasks")
})
// Method + wildcard path segment. {id} is captured by name.
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "get task %s\n", id)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "create a task")
})
srv := httptest.NewServer(mux)
defer srv.Close()
// Helper to fire a request and print the result.
call := func(method, path string) {
req, _ := http.NewRequest(method, srv.URL+path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("%-4s %-12s -> %d %s", method, path, resp.StatusCode, body)
}
call("GET", "/tasks")
call("GET", "/tasks/42")
call("POST", "/tasks")
call("DELETE", "/tasks/42") // no route registered for DELETE
}
Output:
GET /tasks -> 200 list all tasks
GET /tasks/42 -> 200 get task 42
POST /tasks -> 200 create a task
DELETE /tasks/42 -> 405 Method Not Allowed
Look at that last line. You registered GET /tasks/{id} but not DELETE /tasks/{id}, and the mux returned 405 Method Not Allowed on its own, with the correct Allow header. You did not write that. Before 1.22 you would have hand-rolled a method switch inside one handler and probably returned the wrong status. This is exactly the kind of work a router used to justify.
A few rules worth knowing before you design routes:
{id}matches one path segment. To match the rest of the path (for a file server or catch-all), use{path...}with the trailing dots.r.PathValue("id")reads the wildcard. It returns a string, always. You parse and validate it yourself, which we do below.- Most specific pattern wins.
GET /tasks/latestbeatsGET /tasks/{id}for the path/tasks/latest, and a method-specific pattern beats a method-less one. You do not order routes manually. - Overlapping patterns panic at registration. If two patterns match the same requests and neither is more specific,
mux.Handlepanics when you register them, not at request time. That is a deliberate fail-fast: you find route conflicts on startup, not in production.
That last point matters. A whole class of routing bug in other languages (silently shadowed routes) is a startup crash in Go, which is where you want it.
Designing the resource model and JSON handling
A REST resource is a struct plus JSON tags. The tags map Go field names (exported, PascalCase) to JSON keys (usually lowercase):
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
The fields must be exported (capitalized) or encoding/json cannot see them, a mistake we will name later. Now the request path: decode the incoming body, validate it, and respond with a consistent shape whether you succeed or fail. Every response, including errors, should be JSON, so a client never has to parse a plain-text error out of a JSON stream.
Here is a create handler that decodes, validates, and returns proper status codes with a single error shape:
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
// Task is the resource. Struct tags control the JSON field names.
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
// apiError is the single error shape every failure returns.
type apiError struct {
Error string `json:"error"`
}
// writeJSON centralizes response encoding: it sets Content-Type once,
// writes the status, then the body.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
// The header is already sent; all we can do is log server-side.
fmt.Println("encode response:", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, apiError{Error: msg})
}
func createTaskHandler(w http.ResponseWriter, r *http.Request) {
// Reject bodies larger than 1 MB so a client cannot exhaust memory.
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject typos and unexpected fields
var in Task
if err := dec.Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, decodeMessage(err))
return
}
// Validation lives here, not deeper: reject before doing any work.
if strings.TrimSpace(in.Title) == "" {
writeError(w, http.StatusUnprocessableEntity, "title is required")
return
}
in.ID = 1 // a real store assigns this
writeJSON(w, http.StatusCreated, in)
}
// decodeMessage turns json decode failures into a safe client message.
func decodeMessage(err error) string {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return "request body is empty"
case errors.As(err, &syntaxErr):
return fmt.Sprintf("malformed JSON at byte %d", syntaxErr.Offset)
case errors.As(err, &typeErr):
return fmt.Sprintf("field %q must be of type %s", typeErr.Field, typeErr.Type)
default:
return "invalid request body"
}
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(createTaskHandler))
defer srv.Close()
post := func(body string) {
resp, err := http.Post(srv.URL+"/tasks", "application/json", strings.NewReader(body))
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Printf("%-40s -> %d %s", body, resp.StatusCode, out)
}
post(`{"title":"write the tests","done":false}`)
post(`{"title":""}`)
post(`{"title":"x","done":"yes"}`) // wrong type for done
post(`{"title":"x",}`) // malformed JSON
post(`{"titel":"typo"}`) // unknown field
}
Output:
{"title":"write the tests","done":false} -> 201 {"id":1,"title":"write the tests","done":false}
{"title":""} -> 422 {"error":"title is required"}
{"title":"x","done":"yes"} -> 400 {"error":"field \"done\" must be of type bool"}
{"title":"x",} -> 400 {"error":"malformed JSON at byte 14"}
{"titel":"typo"} -> 400 {"error":"invalid request body"}
This is where most tutorials stop short, so let me justify every decision in it.
Status codes carry meaning. 201 Created for a successful create, 422 Unprocessable Entity when the JSON parsed fine but the data is invalid (empty title), 400 Bad Request when the JSON itself is broken. The distinction between 400 and 422 is real: 400 says “I could not read your request,” 422 says “I read it and it is wrong.” Clients branch on that.
DisallowUnknownFields catches typos. Without it, {"titel":"typo"} decodes silently into a Task with an empty title, and your client swears they sent a title. With it, the API tells them field titel is not recognized. This one line prevents hours of confused debugging on the client side.
http.MaxBytesReader caps the body. A decoder reading an unbounded body is a denial-of-service vector: a client streams gigabytes and your process OOMs. One line prevents it, and the wrapped body returns an error past the limit instead of allocating.
Decode errors become useful messages, not stack traces. The decodeMessage helper turns encoding/json’s internal error types into a message safe to return to a client. It never leaks Go internals. The switch on *json.SyntaxError and *json.UnmarshalTypeError uses errors.As, the same chain-walking you would use anywhere; if that pattern is unfamiliar, Go error handling covers errors.Is and errors.As in depth.
One error shape, everywhere. apiError is {"error":"..."} for every failure. A client can write one error handler. Compare that with an API that returns plain text on a 500, JSON on a 400, and an HTML page from a proxy on a 502; that is a client-side nightmare.
JSON has more depth than this: custom marshaling, omitempty, embedded structs, streaming large arrays, handling null versus absent. When you need it, JSON encoding and decoding in Go is the full treatment. For the API, the pattern above is 90 percent of what you write.
A storage layer behind an interface
Handlers should not know whether tasks live in a map, Postgres, or Redis. You get that separation with an interface. The handlers depend on the interface; the concrete store implements it. This is the single most important design decision in the whole service, because it is what makes the handlers testable without a database.
package main
import (
"errors"
"fmt"
"sync"
)
// Domain errors: the vocabulary the store speaks. Handlers map these
// to status codes; the store never imports net/http.
var (
ErrTaskNotFound = errors.New("task not found")
ErrInvalidTask = errors.New("invalid task")
)
type Task struct {
ID int
Title string
Done bool
}
// TaskStore is the interface handlers depend on. Swap the in-memory
// implementation for Postgres later without touching a handler.
type TaskStore interface {
Create(title string) (Task, error)
Get(id int) (Task, error)
List() []Task
}
// memoryStore is the in-memory implementation. The mutex guards the
// map and counter because handlers run concurrently, one goroutine
// per request.
type memoryStore struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
}
func newMemoryStore() *memoryStore {
return &memoryStore{tasks: make(map[int]Task), nextID: 1}
}
func (s *memoryStore) Create(title string) (Task, error) {
if title == "" {
return Task{}, fmt.Errorf("create task: %w", ErrInvalidTask)
}
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t, nil
}
func (s *memoryStore) Get(id int) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, fmt.Errorf("get task %d: %w", id, ErrTaskNotFound)
}
return t, nil
}
func (s *memoryStore) List() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
func main() {
var store TaskStore = newMemoryStore()
t, _ := store.Create("ship the API")
fmt.Printf("created: %+v\n", t)
got, _ := store.Get(t.ID)
fmt.Printf("fetched: %+v\n", got)
_, err := store.Get(999)
fmt.Println("missing:", err)
fmt.Println("is ErrTaskNotFound:", errors.Is(err, ErrTaskNotFound))
_, err = store.Create("")
fmt.Println("empty: ", err)
fmt.Println("is ErrInvalidTask: ", errors.Is(err, ErrInvalidTask))
}
Output:
created: {ID:1 Title:ship the API Done:false}
fetched: {ID:1 Title:ship the API Done:false}
missing: get task 999: task not found
is ErrTaskNotFound: true
empty: create task: invalid task
is ErrInvalidTask: true
Three things earn their place here.
The store returns domain errors, not HTTP. ErrTaskNotFound is a sentinel value the store owns. It has no idea 404 exists. The handler layer decides that a missing task becomes a 404. This keeps your business logic reusable: the same store works behind a CLI, a gRPC server, or a queue worker. Interfaces are the mechanism that makes this clean, and if the interface-as-seam idea is new to you, Go interfaces explained builds it up from scratch.
The mutex is not optional. An HTTP server runs each request in its own goroutine, so two requests can hit Create at the same instant. A plain map under concurrent writes will crash your process with a fatal concurrent map writes, not a recoverable panic. The sync.RWMutex serializes writes and allows concurrent reads. Reach for RLock on reads and Lock on writes. Getting this wrong is the number one way in-memory Go services fall over under load; sync.Mutex and protecting shared state covers the failure modes and the read-write tradeoff in detail.
When you swap in Postgres, nothing above the store changes. You write a postgresStore with the same three methods, change one line in main (newMemoryStore() becomes newPostgresStore(db)), and every handler and test keeps working. That is the payoff for the interface. The real implementation would use database/sql with a driver; we cannot run live database code in this sandbox, but the method signatures and the error vocabulary stay identical.
Mapping domain errors to HTTP status codes in one place
The store speaks in ErrTaskNotFound and ErrInvalidTask. HTTP speaks in 404 and 422. You need exactly one translation point, so that adding a new error kind means one new case, not a hunt through every handler.
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("encode response: %v", err)
}
}
// writeError maps a domain error to a status code in one place.
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrTaskNotFound):
writeJSON(w, http.StatusNotFound, apiError{"task not found"})
case errors.Is(err, ErrInvalidTask):
writeJSON(w, http.StatusUnprocessableEntity, apiError{"title is required"})
default:
log.Printf("internal error: %v", err)
writeJSON(w, http.StatusInternalServerError, apiError{"internal server error"})
}
}
The default case is the security-critical one. Any error you did not explicitly map becomes a generic 500 with a bland message, and the real error goes to your logs, not to the client. This is deliberate: internal errors leak file paths, SQL fragments, and infrastructure details that help an attacker. The client learns “something went wrong”; you learn exactly what. This pattern (a vocabulary of errors at the storage layer, plain %w wrapping through the middle, one translation point at the edge) is the same structure the error handling tutorial develops, applied to a full API.
Because writeError uses errors.Is, it sees through wrapping. The store returns fmt.Errorf("get task %d: %w", id, ErrTaskNotFound), and errors.Is(err, ErrTaskNotFound) still matches through the %w. You get a useful log message (get task 999: task not found) and the correct status code, from the same error value.
Middleware for logging, request IDs, and panic recovery
Cross-cutting concerns (logging every request, tagging each with an ID, catching panics) do not belong inside handlers. They belong in middleware: functions that wrap a Handler and return a Handler. The type is one line:
type Middleware func(http.Handler) http.Handler
Each middleware does its work, then calls the next handler in the chain. Here are three real ones plus the composition, exercised against a route that panics on purpose:
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"time"
)
type Middleware func(http.Handler) http.Handler
// ctxKey is an unexported type so no other package can collide with our
// context keys. Storing a plain string key is a classic bug.
type ctxKey int
const requestIDKey ctxKey = 0
// requestID attaches a unique ID to every request via context.
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var buf [8]byte
_, _ = rand.Read(buf[:])
id := hex.EncodeToString(buf[:])
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// statusRecorder captures the status code, which the ResponseWriter
// does not expose after the fact.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// logging records method, path, status and duration for each request.
func logging(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
id, _ := r.Context().Value(requestIDKey).(string)
logger.Printf("%s %s %d %s id=%s",
r.Method, r.URL.Path, rec.status, time.Since(start), id)
})
}
}
// recoverPanic turns a panic in any handler into a 500 instead of
// crashing the whole server. defer runs even as the stack unwinds.
func recoverPanic(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Printf("panic recovered: %v", rec)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// requireAuth rejects requests without the expected bearer token.
func requireAuth(token string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// chain composes middleware so the first listed runs outermost.
func chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func main() {
logger := log.New(os.Stdout, "", 0)
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, `[{"id":1,"title":"demo"}]`)
})
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
panic("something broke")
})
handler := chain(mux,
requestID,
logging(logger),
recoverPanic(logger),
requireAuth("secret-token"),
)
srv := httptest.NewServer(handler)
defer srv.Close()
call := func(path, token string) {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("RESPONSE GET %-8s token=%-13q -> %d %s", path, token, resp.StatusCode, body)
}
call("/tasks", "secret-token")
call("/tasks", "")
call("/boom", "secret-token")
}
Output (log lines and response lines interleave because the log is written during the request):
GET /tasks 200 13.71µs id=82edeff0bdcff83c
RESPONSE GET /tasks token="secret-token" -> 200 [{"id":1,"title":"demo"}]
GET /tasks 401 2.934µs id=3c38ba648f9a6853
RESPONSE GET /tasks token="" -> 401 {"error":"unauthorized"}
panic recovered: something broke
GET /boom 500 8.902µs id=d0bfbe049a44b74a
RESPONSE GET /boom token="secret-token" -> 500 {"error":"internal server error"}
The recovery middleware is the one that saves you at 3am. Without it, a nil-map access or an index-out-of-range in any handler panics, and a panic that reaches the top of a request goroutine takes down that request with a cryptic stack trace. recoverPanic wraps every handler in a defer with recover, so the panic becomes a clean 500 and the server keeps serving other requests. defer and recover are the exact tools here; defer, panic and recover explains why recover only works inside a deferred function and how the stack unwind interacts with it.
Now the subtle part that most tutorials get wrong: order matters, and the chain order is deliberate. Read the chain call top to bottom as outermost to innermost. A request flows requestID to logging to recoverPanic to requireAuth to the mux. Notice logging sits outside recoverPanic. That is why the /boom request still produces a GET /boom 500 log line: the panic is caught by recoverPanic, which writes the 500 through the statusRecorder, and then control returns up through logging, which records the final status. If you put recoverPanic outermost instead, the panic would unwind straight through logging before it could log, and the panicked request would vanish from your access logs. Getting the order right is not cosmetic; it decides whether your worst requests are the ones you cannot see.
This is a deliberately compact middleware treatment. Reusable middleware has more patterns worth knowing (wrapping the ResponseWriter to capture bytes written, threading values through context safely, third-party middleware libraries, Flush and Hijack support), all of which writing reusable HTTP middleware covers. For an API, request ID plus logging plus recovery plus auth is the core set.
Per-request timeouts and cancellation with context
A handler that calls a database or an upstream service must not run forever. If the dependency hangs, the request goroutine hangs, the connection stays open, and under load you leak goroutines until the process dies. The fix is a per-request deadline carried by context.Context. Every request already has a context (r.Context()), which is cancelled automatically if the client disconnects. You add a timeout on top:
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
// slowDependency simulates a database or upstream call that respects
// cancellation. Real database/sql and http.Client calls take a context
// and return early when it is cancelled.
func slowDependency(ctx context.Context, work time.Duration) error {
select {
case <-time.After(work):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func reportHandler(w http.ResponseWriter, r *http.Request) {
// Cap this handler at 100ms regardless of what the client does.
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
if err := slowDependency(ctx, 500*time.Millisecond); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, `{"error":"request timed out"}`, http.StatusGatewayTimeout)
return
}
http.Error(w, `{"error":"cancelled"}`, http.StatusRequestTimeout)
return
}
fmt.Fprintln(w, `{"status":"done"}`)
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(reportHandler))
defer srv.Close()
start := time.Now()
resp, err := http.Get(srv.URL + "/report")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("GET /report -> %d %safter %dms\n",
resp.StatusCode, body, time.Since(start).Milliseconds())
}
Output:
GET /report -> 504 {"error":"request timed out"}
after 101ms
The dependency wanted 500ms; the context cut it off at 100ms and the handler returned 504 Gateway Timeout. The defer cancel() is not optional: it releases the timer and the context’s resources when the handler returns, and skipping it leaks a goroutine and a timer per request until the deadline fires. go vet will warn you if you drop it.
The critical rule: the timeout only works because slowDependency selects on ctx.Done(). A function that ignores the context runs to completion no matter what. This is why database/sql’s QueryContext, http.Client with req.WithContext, and well-written libraries all take a context.Context as their first argument. Pass the request’s context down through every layer and cancellation propagates automatically. Context has more to it (deadlines versus timeouts versus manual cancel, context.WithValue caveats, propagation across API boundaries), and context: cancellation, timeouts, and deadlines is the full guide. For an API, “put a timeout on outbound calls and thread the context through” is the habit that keeps you alive under load.
Graceful shutdown with signal handling
When Kubernetes or a deploy sends your process SIGTERM, you have a few seconds to finish in-flight requests before the process is killed. A server that calls os.Exit immediately drops every request that was mid-flight, returning connection-reset errors to real users during every deploy. http.Server.Shutdown does it properly: it stops accepting new connections and waits for active requests to finish, up to a deadline you set.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// Run the server in its own goroutine so main can wait for a signal.
go func() {
fmt.Println("listening on :8080")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Println("server error:", err)
}
}()
// signal.NotifyContext cancels ctx when SIGINT or SIGTERM arrives.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Simulate the signal so this example terminates on its own.
go func() {
time.Sleep(50 * time.Millisecond)
syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
}()
<-ctx.Done() // block until a shutdown signal
fmt.Println("shutdown signal received")
// Give in-flight requests up to 10s to finish, then force close.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
fmt.Println("graceful shutdown failed:", err)
return
}
fmt.Println("server stopped cleanly")
}
Output:
listening on :8080
shutdown signal received
server stopped cleanly
(The example sends itself a SIGTERM after 50ms so it terminates on its own; in production that signal comes from your orchestrator. Delete the simulating goroutine and the server runs until it is actually signalled.)
Four details make this correct:
ListenAndServeruns in a goroutine somainis free to block on the signal. WhenShutdownis called,ListenAndServereturnshttp.ErrServerClosed, which you check for and treat as a clean exit rather than an error. Logging that as a failure is a common false alarm.signal.NotifyContext(Go 1.16+) turns a signal into a cancelled context, which reads more cleanly than the older channel-and-signal.Notifydance.Shutdowntakes its own context with a timeout. In-flight requests get up to 10 seconds; if one is still running after that,Shutdownreturns an error and you exit anyway. Without this deadline a single stuck request would block shutdown forever.ReadHeaderTimeoutis set on the server. The zero-valuehttp.Serverhas no timeouts at all, which means a slow-loris client can hold a connection open indefinitely by dribbling out header bytes. Setting at leastReadHeaderTimeoutcloses that hole. Production servers usually setReadTimeout,WriteTimeout, andIdleTimeouttoo.
Testing the API with httptest and table-driven tests
Because handlers depend on the TaskStore interface and the routing is just a ServeMux, you can test the whole HTTP surface with no network and no database. httptest.NewRecorder captures a response in memory; httptest.NewRequest builds a request. Feed a request through your router and assert on the recorder. Go’s table-driven style keeps the cases dense and readable.
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateTask(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
}{
{"valid", `{"title":"write tests"}`, http.StatusCreated},
{"empty title", `{"title":""}`, http.StatusUnprocessableEntity},
{"malformed", `{`, http.StatusBadRequest},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
srv := newStore()
req := httptest.NewRequest("POST", "/tasks", strings.NewReader(tc.body))
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body)
}
})
}
}
func TestGetTaskRoundTrip(t *testing.T) {
srv := newStore()
mux := srv.routes()
// Create, then fetch the same task back.
create := httptest.NewRequest("POST", "/tasks", strings.NewReader(`{"title":"deploy"}`))
createRec := httptest.NewRecorder()
mux.ServeHTTP(createRec, create)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201", createRec.Code)
}
get := httptest.NewRequest("GET", "/tasks/1", nil)
getRec := httptest.NewRecorder()
mux.ServeHTTP(getRec, get)
if getRec.Code != http.StatusOK {
t.Fatalf("get status = %d, want 200", getRec.Code)
}
if !strings.Contains(getRec.Body.String(), `"title":"deploy"`) {
t.Errorf("body missing title: %s", getRec.Body)
}
}
Run it:
=== RUN TestCreateTask
=== RUN TestCreateTask/valid
=== RUN TestCreateTask/empty_title
=== RUN TestCreateTask/malformed
--- PASS: TestCreateTask (0.00s)
--- PASS: TestCreateTask/valid (0.00s)
--- PASS: TestCreateTask/empty_title (0.00s)
--- PASS: TestCreateTask/malformed (0.00s)
=== RUN TestGetTaskRoundTrip
--- PASS: TestGetTaskRoundTrip (0.00s)
PASS
ok tasktest 0.004s
ServeHTTP on the mux runs the real routing, the real handlers, and the real store, so these are not mocked unit tests of one function; they exercise the request path end to end, minus the socket. The t.Run subtests give each table row its own name in the output, so a failure tells you exactly which case broke. Note TestGetTaskRoundTrip creates then reads, proving the two handlers agree on the ID contract, which a single-handler test would miss. Testing has more to give (fuzzing your decoder, httptest.Server for full-stack tests, golden files for response bodies, benchmarks), and the coming Go testing guide will go there. For an API, table-driven handler tests over httptest.NewRecorder are the bread and butter.
The complete runnable service
Here is everything assembled: the store behind its interface, the error mapping, the handlers, the middleware chain, and the wiring. In main it is exercised over a real local server with a sequence of requests so you can see the whole thing behave. In a real deployment, main would call srv.ListenAndServe() with the graceful-shutdown block from earlier instead of the httptest server; that is the only change.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"time"
)
// ---------- domain ----------
var (
ErrTaskNotFound = errors.New("task not found")
ErrInvalidTask = errors.New("invalid task")
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type TaskStore interface {
Create(title string) (Task, error)
Get(id int) (Task, error)
List() []Task
}
type memoryStore struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
}
func newMemoryStore() *memoryStore {
return &memoryStore{tasks: make(map[int]Task), nextID: 1}
}
func (s *memoryStore) Create(title string) (Task, error) {
if strings.TrimSpace(title) == "" {
return Task{}, fmt.Errorf("create task: %w", ErrInvalidTask)
}
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t, nil
}
func (s *memoryStore) Get(id int) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, fmt.Errorf("get task %d: %w", id, ErrTaskNotFound)
}
return t, nil
}
func (s *memoryStore) List() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
// ---------- http helpers ----------
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("encode response: %v", err)
}
}
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrTaskNotFound):
writeJSON(w, http.StatusNotFound, apiError{"task not found"})
case errors.Is(err, ErrInvalidTask):
writeJSON(w, http.StatusUnprocessableEntity, apiError{"title is required"})
default:
log.Printf("internal error: %v", err)
writeJSON(w, http.StatusInternalServerError, apiError{"internal server error"})
}
}
// ---------- handlers ----------
type server struct {
store TaskStore
}
func (s *server) handleCreate(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var in Task
if err := dec.Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, apiError{"invalid request body"})
return
}
t, err := s.store.Create(in.Title)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, t)
}
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, apiError{"id must be a number"})
return
}
t, err := s.store.Get(id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.store.List())
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", s.handleList)
mux.HandleFunc("POST /tasks", s.handleCreate)
mux.HandleFunc("GET /tasks/{id}", s.handleGet)
return mux
}
// ---------- middleware ----------
type Middleware func(http.Handler) http.Handler
type ctxKey int
const requestIDKey ctxKey = 0
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var buf [8]byte
_, _ = rand.Read(buf[:])
id := hex.EncodeToString(buf[:])
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func logging(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
id, _ := r.Context().Value(requestIDKey).(string)
logger.Printf("%s %s %d %s id=%s", r.Method, r.URL.Path, rec.status, time.Since(start), id)
})
}
}
func recoverPanic(logger *log.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Printf("panic recovered: %v", rec)
writeJSON(w, http.StatusInternalServerError, apiError{"internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}
}
func chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---------- wiring ----------
func newHandler(store TaskStore, logger *log.Logger) http.Handler {
srv := &server{store: store}
return chain(srv.routes(),
requestID,
logging(logger),
recoverPanic(logger),
)
}
func main() {
logger := log.New(os.Stdout, "", 0)
handler := newHandler(newMemoryStore(), logger)
// Exercise the full service over a real local server, offline.
ts := httptest.NewServer(handler)
defer ts.Close()
call := func(method, path, body string) {
req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
fmt.Printf("RESP %-4s %-10s -> %d %s", method, path, resp.StatusCode, out)
}
call("POST", "/tasks", `{"title":"write the pillar"}`)
call("POST", "/tasks", `{"title":""}`)
call("GET", "/tasks/1", "")
call("GET", "/tasks/999", "")
call("GET", "/tasks", "")
call("DELETE", "/tasks/1", "")
}
Output (access-log lines from the logging middleware interleave with the response lines):
POST /tasks 201 53.508µs id=56ecd41e865b2d17
RESP POST /tasks -> 201 {"id":1,"title":"write the pillar","done":false}
POST /tasks 422 12.655µs id=1c8ba93090c8305a
RESP POST /tasks -> 422 {"error":"title is required"}
GET /tasks/1 200 2.86µs id=3a75c8404f8e99bc
RESP GET /tasks/1 -> 200 {"id":1,"title":"write the pillar","done":false}
GET /tasks/999 404 8.389µs id=7c80167885786d51
RESP GET /tasks/999 -> 404 {"error":"task not found"}
GET /tasks 200 10.222µs id=0983f04b556453f2
RESP GET /tasks -> 200 [{"id":1,"title":"write the pillar","done":false}]
DELETE /tasks/1 405 5.826µs id=8de87e93f7152b14
RESP DELETE /tasks/1 -> 405 Method Not Allowed
That is a complete API: create returns 201 with the assigned ID, invalid input returns 422, a missing task returns 404, list returns an array, and an unsupported method returns 405 for free. Every request is logged with a unique ID, every panic would be caught, and the business logic never once imports net/http. This is deployable. Add the graceful-shutdown block, point the store at a database, put it behind TLS, and it is a real service.
The server struct holding the store is the standard Go pattern for handler dependencies. Methods on server are your handlers, so they reach the store (and later a logger, a config, a metrics client) through the receiver instead of package-level globals. This is what makes newHandler(store, logger) testable: pass a different store in a test, get a different behavior, no globals to reset.
Common mistakes when building Go REST APIs
Mistake 1: not setting Content-Type, or setting it too late
Headers must be set before you write the body, because the first write flushes the status line and headers. Set Content-Type after writing and it silently does nothing; call WriteHeader after writing and you get a runtime warning and the wrong status:
func badHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "task created") // this sends a 200 header implicitly
w.WriteHeader(http.StatusCreated) // too late: header already sent
}
Output on the server side:
2026/07/20 20:36:29 http: superfluous response.WriteHeader call from main.badHandler (mistake.go:11)
status: 200
The client wanted a 201 and got a 200, and the only sign is a log line most people never read. The fix is order: set every header, call WriteHeader(status), then write the body. Last. The writeJSON helper used throughout this article enforces that order, which is the main reason to route every response through one function instead of touching w directly in each handler.
Mistake 2: ignoring the error from Decode
var in Task
json.NewDecoder(r.Body).Decode(&in) // error discarded
// in.Title is "" on failure, and you proceed as if it worked
If the body is malformed or empty, Decode returns an error and leaves in at its zero value. Ignore the error and you happily create a task with an empty title, or worse, act on partial data. Always check it, and turn it into a 400. The decodeMessage helper earlier shows how to make the message useful without leaking internals.
Mistake 3: unexported struct fields silently vanish from JSON
type Task struct {
id int // lowercase: encoding/json cannot see it
title string // same
}
encoding/json uses reflection and can only touch exported (capitalized) fields. Lowercase id and title marshal to {}, an empty object, with no error and no warning. Your API returns blank tasks and nothing tells you why. Every field you want in JSON must start with a capital letter; use the json:"id" tag to control the wire name.
Mistake 4: unprotected shared state under concurrent requests
The server runs one goroutine per request, so a plain map written by two requests at once is a data race that crashes the process:
type badStore struct {
tasks map[int]Task // no mutex
}
func (s *badStore) Create(t Task) { s.tasks[t.ID] = t } // fatal error under load
Under concurrent writes Go’s runtime detects the race and aborts with fatal error: concurrent map writes, which recover cannot catch. It is a hard crash. Guard shared state with a sync.Mutex or sync.RWMutex as the memoryStore does, or push the state into a database that handles concurrency for you. Run your tests with go test -race to catch these before production; the race detector finds them reliably. sync.Mutex and protecting shared state covers the patterns.
Mistake 5: no timeouts, so slow clients and hung dependencies leak
The zero-value http.Server has no read or write timeouts, and a handler that calls an upstream without a context deadline waits forever. Both leak: connections pile up, goroutines accumulate, memory climbs, and the process falls over hours into a traffic spike. Set ReadHeaderTimeout (at minimum) on the server, and wrap every outbound call in a context.WithTimeout as the report handler did. Timeouts are not a nice-to-have; they are what stops one slow dependency from taking down your whole service.
Mistake 6: business logic living inside handlers
When a handler parses the request, runs the business rules, talks to the database, and formats the response all in one function, you cannot test the business rules without constructing an HTTP request, and you cannot reuse them from a CLI or a queue worker. Keep handlers thin: parse and validate input, call a method on the store or a service, map the result or error to a response. The logic lives behind the interface. Every handler in this article is under 15 lines for exactly this reason.
When to add a framework, and why you probably do not need one yet
The honest answer for 2026: since Go 1.22, the standard library covers most of what people used to import Gin or Chi for. Method-and-path routing, path parameters, and automatic 405s were the big missing pieces, and they are all here now. Everything in this tutorial (routing, JSON, middleware, context, shutdown, tests) is standard library. For a service with a few dozen routes, that is often the whole story, and you ship with zero third-party HTTP dependencies to audit, update, or get breached by.
The go.dev RESTful API with Gin tutorial is a fine introduction to Gin, but notice it reaches for the framework before you have seen what the standard library does. Learn the standard library first, then add a framework when you hit a wall it addresses, not before. The walls are real but specific:
- Chi is the closest to “the standard library with the rough edges filed off.” Route grouping, a large middleware ecosystem, and
chi.Routeris compatible withhttp.Handler, so you can adopt it incrementally. If you outgrowServeMux, Chi is the smallest step. - Gin trades standard-library compatibility for speed and ergonomics: a custom context type, built-in binding and validation, and rendering helpers. Reach for it when you have many routes and want the batteries included, and you do not mind that handlers now take
*gin.Contextinstead of the standard pair. - Echo sits near Gin: its own context, built-in middleware, binding, and validation, with a clean API. The choice between Echo and Gin is mostly taste.
The tell that you have outgrown the standard library: you are hand-writing the same route-grouping, request-binding, and validation helpers in every project, and they add up to a worse version of what Chi already ships. Until then, the standard library keeps your dependency tree small and your handlers portable. When you do make the jump, the coming Gin tutorial walks through it, and everything you learned here (the store interface, the error mapping, the middleware concept, the tests) carries straight over, because Gin changes the transport layer and nothing beneath it.
What next
You have built a complete, tested, standard-library REST API: routed, validated, error-mapped, middleware-wrapped, timeout-guarded, and shut down cleanly. Where to go from here:
- Go deeper on the transport pieces you assembled: net/http server from scratch for the server mechanics, JSON encoding and decoding for custom marshaling and edge cases, and reusable HTTP middleware for advanced composition.
- Your API will need to call other APIs; the Go HTTP client guide covers making requests, timeouts, and retries from the client side.
- Solidify the foundations this pillar leans on: error handling and wrapping, interfaces, and context for cancellation and timeouts.
- If any Go basics felt thin, the complete Go tutorial covers the language in order, and the Go interview questions set includes the HTTP and concurrency questions this material prepares you for.