Go From Zero, Part 12: Building a REST API with the Standard Library
This is Part 12 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 11: Best Practices for Production-Ready Go is the place to catch up.
This is it, the final part. In Part 11 we collected the habits that make Go code production-ready. Today we use all of them, plus nearly everything else from the series, to build a real REST API from scratch.
We’ll build a small task tracker API. It’s deliberately simple in what it does, so we can focus on how it’s built. And we’ll use only the standard library. Since Go 1.22, net/http supports method-based routing and path parameters, so for many services you genuinely don’t need a web framework.
Here’s what the finished API does:
GET /healthreturns a health checkGET /taskslists all tasksPOST /taskscreates a taskGET /tasks/{id}fetches one taskPATCH /tasks/{id}marks a task done or not doneDELETE /tasks/{id}deletes a task
The architecture

The design follows a simple layered flow, the same one you’ll find in many production Go services:
- HTTP layer (
httpapi): decodes requests, calls the service, encodes responses, maps errors to status codes. - Service layer (
task.Service): business rules, like “a title can’t be empty”. - Storage layer (
task.Store): an interface, with an in-memory implementation for now.
Each layer only knows about the one below it, and the storage is hidden behind a small interface (Part 4). Swapping the in-memory store for PostgreSQL later means writing one new type. Nothing else changes.
Project layout
Following Part 6:
tasks/
├── go.mod
├── cmd/
│ └── api/
│ └── main.go # wiring + graceful shutdown
└── internal/
├── task/
│ ├── task.go # domain types, errors, service
│ └── memstore.go # in-memory Store
└── httpapi/
├── handler.go # routes and handlers
├── json.go # JSON + error helpers
├── middleware.go # logging + panic recovery
└── handler_test.go # tests
Create it with:
mkdir tasks && cd tasks
go mod init example.com/tasks
mkdir -p cmd/api internal/task internal/httpapi
Step 1: the domain and service
This is the heart of the app. Notice the sentinel errors (Part 5), the small Store interface (Part 4), and that every method takes a context.Context first (Part 8).
// file: internal/task/task.go
// Package task holds the business logic for to-do tasks.
package task
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
var (
ErrNotFound = errors.New("task not found")
ErrInvalid = errors.New("invalid task")
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
// Store is everything the service needs from storage.
type Store interface {
Create(ctx context.Context, t Task) (Task, error)
Get(ctx context.Context, id int) (Task, error)
List(ctx context.Context) ([]Task, error)
Update(ctx context.Context, t Task) error
Delete(ctx context.Context, id int) error
}
type Service struct {
store Store
}
func NewService(s Store) *Service {
return &Service{store: s}
}
func (s *Service) Create(ctx context.Context, title string) (Task, error) {
title, err := validateTitle(title)
if err != nil {
return Task{}, err
}
return s.store.Create(ctx, Task{Title: title, CreatedAt: time.Now().UTC()})
}
func (s *Service) Get(ctx context.Context, id int) (Task, error) {
return s.store.Get(ctx, id)
}
func (s *Service) List(ctx context.Context) ([]Task, error) {
return s.store.List(ctx)
}
func (s *Service) SetDone(ctx context.Context, id int, done bool) (Task, error) {
t, err := s.store.Get(ctx, id)
if err != nil {
return Task{}, err
}
t.Done = done
if err := s.store.Update(ctx, t); err != nil {
return Task{}, fmt.Errorf("updating task %d: %w", id, err)
}
return t, nil
}
func (s *Service) Delete(ctx context.Context, id int) error {
return s.store.Delete(ctx, id)
}
func validateTitle(title string) (string, error) {
title = strings.TrimSpace(title)
if title == "" {
return "", fmt.Errorf("%w: title is required", ErrInvalid)
}
if len(title) > 200 {
return "", fmt.Errorf("%w: title must be at most 200 characters", ErrInvalid)
}
return title, nil
}
Validation errors wrap ErrInvalid with %w, so the HTTP layer can recognise them with errors.Is while users still get a helpful message like invalid task: title is required.
Step 2: an in-memory store
A map guarded by a mutex, exactly like the SafeCounter from Part 8. Many HTTP requests run concurrently, so this lock isn’t optional.
// file: internal/task/memstore.go
package task
import (
"context"
"slices"
"sync"
)
// Compile-time check that *MemoryStore satisfies Store.
var _ Store = (*MemoryStore)(nil)
// MemoryStore is an in-memory Store, safe for concurrent use.
type MemoryStore struct {
mu sync.Mutex
nextID int
tasks map[int]Task
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{nextID: 1, tasks: make(map[int]Task)}
}
func (m *MemoryStore) Create(_ context.Context, t Task) (Task, error) {
m.mu.Lock()
defer m.mu.Unlock()
t.ID = m.nextID
m.nextID++
m.tasks[t.ID] = t
return t, nil
}
func (m *MemoryStore) Get(_ context.Context, id int) (Task, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
return t, nil
}
func (m *MemoryStore) List(_ context.Context) ([]Task, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Task, 0, len(m.tasks))
for _, t := range m.tasks {
out = append(out, t)
}
slices.SortFunc(out, func(a, b Task) int { return a.ID - b.ID })
return out, nil
}
func (m *MemoryStore) Update(_ context.Context, t Task) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tasks[t.ID]; !ok {
return ErrNotFound
}
m.tasks[t.ID] = t
return nil
}
func (m *MemoryStore) Delete(_ context.Context, id int) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tasks[id]; !ok {
return ErrNotFound
}
delete(m.tasks, id)
return nil
}
The line var _ Store = (*MemoryStore)(nil) is a handy trick you’ll see in many codebases. It costs nothing at runtime, but it makes the compiler prove that MemoryStore satisfies Store. If someone changes the interface and forgets the implementation, the build fails right here with a clear message.
Step 3: JSON helpers and error mapping
Small helpers keep handlers short. The most important function here is writeError: it’s the one place where domain errors become HTTP status codes. Handlers never decide status codes for errors themselves.
// file: internal/httpapi/json.go
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"example.com/tasks/internal/task"
)
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 {
slog.Error("encoding response", "err", err)
}
}
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("%w: %v", errBadJSON, err)
}
return nil
}
var errBadJSON = errors.New("malformed JSON")
// writeError maps errors to HTTP status codes in one place.
func writeError(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusInternalServerError
msg := "internal server error"
switch {
case errors.Is(err, task.ErrNotFound):
status, msg = http.StatusNotFound, err.Error()
case errors.Is(err, task.ErrInvalid), errors.Is(err, errBadJSON):
status, msg = http.StatusBadRequest, err.Error()
default:
// unexpected: log the details, but never leak them to the client
slog.ErrorContext(r.Context(), "request failed",
"method", r.Method, "path", r.URL.Path, "err", err)
}
writeJSON(w, status, map[string]string{"error": msg})
}
A few production details worth pointing out:
http.MaxBytesReadercaps the request body so nobody can send a 5 GB payload.DisallowUnknownFieldsrejects typos like"tittle"instead of silently ignoring them.- Unexpected errors are logged with full detail but the client only sees
internal server error. Never leak internal errors to users. - This is the “handle each error once” rule from Part 5 in action: handlers pass errors up,
writeErrorlogs only the unexpected ones.
Step 4: routes and handlers
Look at the route patterns: "GET /tasks/{id}". The method and the path variable are right there in the pattern, and r.PathValue("id") reads it. That’s the Go 1.22+ router.
// file: internal/httpapi/handler.go
// Package httpapi exposes the task service over HTTP.
package httpapi
import (
"fmt"
"net/http"
"strconv"
"example.com/tasks/internal/task"
)
type Handler struct {
svc *task.Service
}
// Routes builds the router. Method and path patterns need Go 1.22+.
func Routes(svc *task.Service) http.Handler {
h := &Handler{svc: svc}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.health)
mux.HandleFunc("GET /tasks", h.list)
mux.HandleFunc("POST /tasks", h.create)
mux.HandleFunc("GET /tasks/{id}", h.get)
mux.HandleFunc("PATCH /tasks/{id}", h.update)
mux.HandleFunc("DELETE /tasks/{id}", h.delete)
return recoverPanics(logRequests(mux))
}
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
tasks, err := h.svc.List(r.Context())
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
}
if err := readJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
t, err := h.svc.Create(r.Context(), req.Title)
if err != nil {
writeError(w, r, err)
return
}
w.Header().Set("Location", fmt.Sprintf("/tasks/%d", t.ID))
writeJSON(w, http.StatusCreated, t)
}
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, r, err)
return
}
t, err := h.svc.Get(r.Context(), id)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, r, err)
return
}
var req struct {
Done *bool `json:"done"` // pointer: tells "false" apart from "missing"
}
if err := readJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
if req.Done == nil {
writeError(w, r, fmt.Errorf("%w: done is required", task.ErrInvalid))
return
}
t, err := h.svc.SetDone(r.Context(), id, *req.Done)
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, t)
}
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
writeError(w, r, err)
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func pathID(r *http.Request) (int, error) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
return 0, fmt.Errorf("%w: id must be a positive number", task.ErrInvalid)
}
return id, nil
}
One subtle detail: in update, Done is a *bool. With a plain bool, a request body of {} would decode to false and quietly mark the task as not done. The pointer lets us tell “the client sent false” apart from “the client sent nothing”. It’s a classic Go JSON trick.
Step 5: middleware
Middleware in Go is just a function that takes an http.Handler and returns a new one that wraps it. No framework magic. We add two: structured request logging with slog (Part 11) and panic recovery (Part 5).
// file: internal/httpapi/middleware.go
package httpapi
import (
"log/slog"
"net/http"
"time"
)
// statusRecorder remembers the status code a handler wrote.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// logRequests logs one structured line per request.
func logRequests(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)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
// recoverPanics turns a panic in one handler into a 500, instead of crashing the server.
func recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
slog.Error("panic recovered", "panic", v, "path", r.URL.Path)
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "internal server error"})
}
}()
next.ServeHTTP(w, r)
})
}

Step 6: main, with timeouts and graceful shutdown
main stays thin: it configures logging, wires the layers together, and runs the server with proper timeouts and graceful shutdown, exactly as in Part 11. I like the run() error pattern: main only handles the exit code, and everything else can return errors normally.
// file: cmd/api/main.go
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"example.com/tasks/internal/httpapi"
"example.com/tasks/internal/task"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
addr := os.Getenv("ADDR")
if addr == "" {
addr = ":8080"
}
// wiring: storage -> service -> HTTP
store := task.NewMemoryStore()
svc := task.NewService(store)
srv := &http.Server{
Addr: addr,
Handler: httpapi.Routes(svc),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
slog.Info("listening", "addr", addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
Run it
go run ./cmd/api
In another terminal:
curl -i -X POST localhost:8080/tasks -d '{"title":"Learn Go"}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /tasks/1
{"id":1,"title":"Learn Go","done":false,"created_at":"2026-11-13T04:50:07.3249Z"}
curl -X POST localhost:8080/tasks -d '{"title":"Publish the blog series"}'
curl -X PATCH localhost:8080/tasks/1 -d '{"done":true}'
curl localhost:8080/tasks
[{"id":1,"title":"Learn Go","done":true,...},{"id":2,"title":"Publish the blog series","done":false,...}]
And the error cases:
curl -X POST localhost:8080/tasks -d '{"title":""}'
# {"error":"invalid task: title is required"}
curl localhost:8080/tasks/42
# {"error":"task not found"}
Meanwhile, the server logs one structured JSON line per request:
{"time":"...","level":"INFO","msg":"request","method":"POST","path":"/tasks","status":201,"duration_ms":0}
{"time":"...","level":"INFO","msg":"request","method":"GET","path":"/tasks/42","status":404,"duration_ms":0}
Press Ctrl+C and you’ll see shutting down as the server finishes in-flight requests and exits cleanly.
Step 7: tests
Using Part 10’s techniques: httptest.NewServer spins up the real router on a random port, t.Helper and t.Cleanup keep things tidy, and a table-driven test covers validation.
// file: internal/httpapi/handler_test.go
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"example.com/tasks/internal/task"
)
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(Routes(task.NewService(task.NewMemoryStore())))
t.Cleanup(srv.Close)
return srv
}
func do(t *testing.T, method, url, body string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("building request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
t.Cleanup(func() { res.Body.Close() })
return res
}
func TestTaskLifecycle(t *testing.T) {
srv := newTestServer(t)
// create
res := do(t, http.MethodPost, srv.URL+"/tasks", `{"title":"Learn Go"}`)
if res.StatusCode != http.StatusCreated {
t.Fatalf("create status = %d, want %d", res.StatusCode, http.StatusCreated)
}
var created task.Task
if err := json.NewDecoder(res.Body).Decode(&created); err != nil {
t.Fatalf("decoding: %v", err)
}
if created.ID != 1 || created.Title != "Learn Go" || created.Done {
t.Fatalf("created = %+v", created)
}
// mark done
res = do(t, http.MethodPatch, srv.URL+"/tasks/1", `{"done":true}`)
if res.StatusCode != http.StatusOK {
t.Fatalf("patch status = %d", res.StatusCode)
}
// delete, then it should be gone
res = do(t, http.MethodDelete, srv.URL+"/tasks/1", "")
if res.StatusCode != http.StatusNoContent {
t.Fatalf("delete status = %d", res.StatusCode)
}
res = do(t, http.MethodGet, srv.URL+"/tasks/1", "")
if res.StatusCode != http.StatusNotFound {
t.Fatalf("get after delete status = %d, want 404", res.StatusCode)
}
}
func TestCreateValidation(t *testing.T) {
srv := newTestServer(t)
tests := []struct {
name string
body string
want int
}{
{name: "empty title", body: `{"title":" "}`, want: http.StatusBadRequest},
{name: "bad json", body: `{"title":`, want: http.StatusBadRequest},
{name: "unknown field", body: `{"title":"x","owner":"me"}`, want: http.StatusBadRequest},
{name: "ok", body: `{"title":"Write tests"}`, want: http.StatusCreated},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := do(t, http.MethodPost, srv.URL+"/tasks", tt.body)
if res.StatusCode != tt.want {
t.Errorf("status = %d, want %d", res.StatusCode, tt.want)
}
})
}
}
func TestBadID(t *testing.T) {
srv := newTestServer(t)
res := do(t, http.MethodGet, srv.URL+"/tasks/abc", "")
if res.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want 400", res.StatusCode)
}
}
go test -race ./...
# ok example.com/tasks/internal/httpapi 1.021s
Where to take it next
This is a solid foundation. Here’s how you’d grow it into a full production service, and which part of the series covers the skills:
- Swap in a real database. Write a
PostgresStorethat satisfiestask.Store, usingdatabase/sqlwith thepgxdriver. Passctxto every query so cancelled requests stop their queries (Parts 4 and 8). - Add configuration for the address, database URL and timeouts from environment variables (Part 11).
- Add authentication middleware that checks a token and stores the user ID in the request context (Part 8).
- Add pagination to
GET /taskswith?limit=and?offset=query parameters. - Containerise it with the multi-stage Dockerfile from Part 11.
- Profile it under load with
pprofbefore optimising anything (Part 11).
What you’ve learned in this series
Look back at how much ground we’ve covered:
- Why Go exists, and how to run and build programs.
- Variables, types, control flow and functions with multiple returns.
- Slices, maps and structs, and how slices share memory.
- Methods, small interfaces and composition through embedding.
- Errors as values: wrapping,
errors.Isanderrors.As. - Packages, modules, dependencies and project layout.
- Goroutines, channels and
select. - Mutexes,
context, worker pools, pipelines anderrgroup. - Generics, and when not to use them.
- Table-driven tests, fakes, benchmarks and fuzzing.
- Production habits: tooling, logging, timeouts, graceful shutdown and profiling.
- A complete, tested REST API with only the standard library.
That’s a genuinely complete picture of Go. The rest comes from writing code. Pick a small project you actually care about, like a CLI for something you do every day or an API for a side project, and build it. You’ll be surprised how quickly Go starts to feel natural.
Thanks for reading all the way through. It’s been a pleasure writing this series.
Further reading: A Tour of Go, Effective Go, Go Code Review Comments, the Go blog, and Go by Example.
Where to go from here
⬅️ Previous: Part 11: Best Practices for Production-Ready Go
🎉 That was the final part. Go build something.
The full Go From Zero series
- Part 1: Why Go, Setting It Up, and Your First Program
- Part 2: Variables, Types, Control Flow, and Functions
- Part 3: Arrays, Slices, Maps, and Structs
- Part 4: Methods, Interfaces, and Composition
- Part 5: Error Handling the Go Way
- Part 6: Packages, Modules, and Project Layout
- Part 7: Goroutines, Channels, and select
- Part 8: sync, context, and Concurrency Patterns
- Part 9: Generics Without the Headache
- Part 10: Testing, Benchmarks, and Fuzzing
- Part 11: Best Practices for Production-Ready Go
- Part 12: Building a REST API with the Standard Library (you are here)
If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.
Releted Posts
Go From Zero, Part 11: Best Practices for Production-Ready Go
This is Part 11 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 10: Testing, Benchmarks, and Fuzzing is the place to catch up.
Read moreGo From Zero, Part 10: Testing, Benchmarks, and Fuzzing
This is Part 10 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 9: Generics Without the Headache is the place to catch up.
Read moreGo From Zero, Part 9: Generics Without the Headache
This is Part 9 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 8: sync, context, and Concurrency Patterns is the place to catch up.
Read more