Skip to content
gohacks.dev
APIs

Build a Todo API in Go With Chi and Postgres Step by Step

Build a complete todo REST API in Go with chi and PostgreSQL: schema, store, handlers, validation, keyset pagination, problem responses and a real run.

17 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the PostgreSQL logo.

A todo list is the "hello world" of REST APIs, which makes it a good vehicle for the parts that are the same in every real service: routing, validation, partial updates, pagination, error responses and tests against a real database. This project builds one in Go with the chi router and PostgreSQL via pgx, in three small files. It is deliberately boring. The value is in the choices made along the way and why, because those transfer to the API you actually have to write.

Tested with Go 1.25.6, chi v5.2.3, pgx v5.7.6, PostgreSQL 18, macOS on Apple Silicon (arm64)

If you want the design principles first, read REST API design and CRUD with a database. The handler tests are covered in table-driven handler tests and integration tests against a real database. To compare router choices, see Go web frameworks compared. The project ran on Go 1.25.6 with PostgreSQL 18 in a throwaway container.

What the API does

Method and pathPurpose
POST /v1/todosCreate a todo
GET /v1/todosList, with an optional done filter and keyset pagination
GET /v1/todos/{id}Fetch one
PATCH /v1/todos/{id}Change the title and/or the done flag
DELETE /v1/todos/{id}Remove it

Errors are RFC 9457 style problem documents, with the media type application/problem+json. IDs are integers for simplicity; for a public API, prefer random opaque ids (IDOR explains why).

Layout

Text
todo/
  go.mod
  main.go        wiring, config, graceful shutdown
  store.go       SQL and the Todo type
  handlers.go    router and HTTP

Three layers with a one-way dependency: handlers call the store; the store knows nothing about HTTP. For a bigger service, split into packages (internal/store, internal/api), but keep the direction. The point is that the SQL can be tested without HTTP, and the handlers can be tested without a database by substituting an interface when speed matters.

The schema and the store

The schema is part of the application. Constraints in the database are the last line of defence, so the title rule is enforced there too:

store.goGo
package main

import (
	"context"
	"errors"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

const schema = `
CREATE TABLE IF NOT EXISTS todos (
  id         bigserial PRIMARY KEY,
  title      text NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
  done       boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS todos_done_created ON todos (done, created_at DESC);
`

var ErrNotFound = errors.New("todo not found")

type Todo struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

type Store struct{ pool *pgxpool.Pool }

const cols = `id, title, done, created_at, updated_at`

func scan(row pgx.Row) (Todo, error) {
	var t Todo
	err := row.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt, &t.UpdatedAt)
	if errors.Is(err, pgx.ErrNoRows) {
		return Todo{}, ErrNotFound
	}
	return t, err
}

func (s *Store) Create(ctx context.Context, title string) (Todo, error) {
	return scan(s.pool.QueryRow(ctx, `INSERT INTO todos (title) VALUES ($1) RETURNING `+cols, title))
}

func (s *Store) Get(ctx context.Context, id int64) (Todo, error) {
	return scan(s.pool.QueryRow(ctx, `SELECT `+cols+` FROM todos WHERE id = $1`, id))
}

// List supports a done filter and keyset pagination on id, newest first.
func (s *Store) List(ctx context.Context, done *bool, before int64, limit int) ([]Todo, error) {
	rows, err := s.pool.Query(ctx, `
		SELECT `+cols+` FROM todos
		WHERE ($1::boolean IS NULL OR done = $1) AND ($2 = 0 OR id < $2)
		ORDER BY id DESC LIMIT $3`, done, before, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []Todo{}
	for rows.Next() {
		t, err := scan(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// Update changes only the fields that were sent (nil means leave alone).
func (s *Store) Update(ctx context.Context, id int64, title *string, done *bool) (Todo, error) {
	return scan(s.pool.QueryRow(ctx, `
		UPDATE todos SET title = COALESCE($2, title), done = COALESCE($3, done), updated_at = now()
		WHERE id = $1 RETURNING `+cols, id, title, done))
}

func (s *Store) Delete(ctx context.Context, id int64) error {
	tag, err := s.pool.Exec(ctx, `DELETE FROM todos WHERE id = $1`, id)
	if err == nil && tag.RowsAffected() == 0 {
		return ErrNotFound
	}
	return err
}

Choices worth explaining.

bigserial ids and a CHECK. The CHECK (length(title) BETWEEN 1 AND 200) repeats a rule the handler also checks. The handler's check gives a friendly 422. The constraint makes sure that a different code path (a script, an admin tool, a future endpoint) cannot store an empty title. If the handler's rule and the constraint ever disagree, the user gets a 500, which is a clear signal to fix one of them.

One scan function. scan maps a row to a Todo and converts pgx.ErrNoRows to ErrNotFound. Handlers never import pgx. That is why the HTTP layer can ask errors.Is(err, ErrNotFound) and produce a 404 in a single place.

Partial update with COALESCE. Update takes *string and *bool. A nil pointer is passed as SQL NULL, and COALESCE($2, title) keeps the old value. This is the cleanest way to support PATCH in SQL without building the statement dynamically. It cannot set a nullable column to NULL, which is fine here, and the PATCH article covers that harder case.

Keyset pagination. List filters with id < $2 and orders by id descending. The client passes the last id it saw as before. Compared with OFFSET, the cost does not grow with the page number, and the result does not skip or repeat items when rows are added or removed between requests. The query uses the primary key, so it is an index scan. See pagination for when to choose each style.

done as an optional filter. The condition $1::boolean IS NULL OR done = $1 lets one query serve filtered and unfiltered requests. The done, created_at index supports the filtered path.

The handlers

handlers.goGo
package main

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

func NewRouter(s *Store) http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.RequestID, middleware.Recoverer, middleware.Timeout(5*time.Second))
	r.Route("/v1/todos", func(r chi.Router) {
		r.Post("/", create(s))
		r.Get("/", list(s))
		r.Route("/{id}", func(r chi.Router) {
			r.Get("/", get(s))
			r.Patch("/", patch(s))
			r.Delete("/", remove(s))
		})
	})
	return r
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func problem(w http.ResponseWriter, status int, detail string) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]any{"title": http.StatusText(status), "status": status, "detail": detail})
}

func fail(w http.ResponseWriter, err error) {
	if errors.Is(err, ErrNotFound) {
		problem(w, http.StatusNotFound, "no such todo")
		return
	}
	problem(w, http.StatusInternalServerError, "something went wrong")
}

func idParam(w http.ResponseWriter, r *http.Request) (int64, bool) {
	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	if err != nil || id < 1 {
		problem(w, http.StatusBadRequest, "id must be a positive integer")
		return 0, false
	}
	return id, true
}

func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14))
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		problem(w, http.StatusBadRequest, "invalid JSON body")
		return false
	}
	return true
}

func validTitle(t string) bool { t = strings.TrimSpace(t); return t != "" && len(t) <= 200 }

func create(s *Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Title string `json:"title"`
		}
		if !decode(w, r, &in) {
			return
		}
		if !validTitle(in.Title) {
			problem(w, http.StatusUnprocessableEntity, "title must be 1 to 200 characters")
			return
		}
		t, err := s.Create(r.Context(), strings.TrimSpace(in.Title))
		if err != nil {
			fail(w, err)
			return
		}
		w.Header().Set("Location", "/v1/todos/"+strconv.FormatInt(t.ID, 10))
		writeJSON(w, http.StatusCreated, t)
	}
}

func get(s *Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, ok := idParam(w, r)
		if !ok {
			return
		}
		t, err := s.Get(r.Context(), id)
		if err != nil {
			fail(w, err)
			return
		}
		writeJSON(w, http.StatusOK, t)
	}
}

func list(s *Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		var done *bool
		if v := q.Get("done"); v != "" {
			b, err := strconv.ParseBool(v)
			if err != nil {
				problem(w, http.StatusBadRequest, "done must be true or false")
				return
			}
			done = &b
		}
		limit, _ := strconv.Atoi(q.Get("limit"))
		if limit < 1 || limit > 100 {
			limit = 20
		}
		before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
		items, err := s.List(r.Context(), done, before, limit)
		if err != nil {
			fail(w, err)
			return
		}
		resp := map[string]any{"data": items}
		if len(items) == limit {
			resp["next_before"] = items[len(items)-1].ID
		}
		writeJSON(w, http.StatusOK, resp)
	}
}

func patch(s *Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, ok := idParam(w, r)
		if !ok {
			return
		}
		var in struct {
			Title *string `json:"title"`
			Done  *bool   `json:"done"`
		}
		if !decode(w, r, &in) {
			return
		}
		if in.Title != nil {
			if !validTitle(*in.Title) {
				problem(w, http.StatusUnprocessableEntity, "title must be 1 to 200 characters")
				return
			}
			t := strings.TrimSpace(*in.Title)
			in.Title = &t
		}
		t, err := s.Update(r.Context(), id, in.Title, in.Done)
		if err != nil {
			fail(w, err)
			return
		}
		writeJSON(w, http.StatusOK, t)
	}
}

func remove(s *Store) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, ok := idParam(w, r)
		if !ok {
			return
		}
		if err := s.Delete(r.Context(), id); err != nil {
			fail(w, err)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	}
}

Notes on the handler code.

Middleware. chi brings the standard set: RequestID, Recoverer (turns a panic into a 500 instead of a dropped connection) and Timeout, which cancels the request context after five seconds. Passing r.Context() to the store means a timed-out request cancels its query, as covered in cancelling database queries.

Routing. r.Route("/v1/todos", ...) nests subrouters, so the version prefix and the resource path are written once. URL parameters come from chi.URLParam. The standard library mux can do this too since Go 1.22; chi still earns its place with middleware composition and route groups.

Decode, validate, call, write. Every handler has the same four steps. decode caps the body at 16 KiB and rejects unknown fields, so a typo like {"titel": "x"} is a 400 and not a silently ignored field (strict decoding). Validation is separate from decoding so each failure maps to the right status: malformed JSON is 400, well-formed but invalid is 422.

Errors in one place. fail translates ErrNotFound into a 404 and everything else into a generic 500 with no detail. The real error belongs in the log with the request id, not in the response. problem writes the problem document. Every failing path goes through them, so the error shape is uniform without any framework magic.

Trimming. The title is trimmed before validation and storage, so " " is rejected and " hello " is stored as hello. Decide what normalisation you want and do it in one place.

The list response is an object with data and, when there may be more, next_before. Wrapping the array in an object leaves room to add fields later without breaking clients (response structure). The handler decides there may be more when the page is full, which can produce one empty trailing page; checking for one extra row (fetch limit+1) removes that.

Running it against a real database

The main.go of the lab is a test drive. It connects, creates the table, serves the router on a test server, and sends twelve requests:

main.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"regexp"
	"strings"

	"github.com/jackc/pgx/v5/pgxpool"
)

var stamps = regexp.MustCompile(`,"created_at":"[^"]*","updated_at":"[^"]*"`)

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		panic(err)
	}
	defer pool.Close()
	pool.Exec(ctx, "DROP TABLE IF EXISTS todos")
	if _, err := pool.Exec(ctx, schema); err != nil {
		panic(err)
	}

	srv := httptest.NewServer(NewRouter(&Store{pool}))
	defer srv.Close()

	call := func(method, path, body string) string {
		req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		out := fmt.Sprintf("%-6s %-24s %d", method, path, resp.StatusCode)
		if loc := resp.Header.Get("Location"); loc != "" {
			out += " Location=" + loc
		}
		s := strings.TrimSpace(string(b))
		// timestamps vary run to run, so show the stable part of each body
		s = stamps.ReplaceAllString(s, "")
		return out + " " + s
	}
	for _, c := range [][3]string{
		{"POST", "/v1/todos", `{"title":"write the article"}`},
		{"POST", "/v1/todos", `{"title":"run the code"}`},
		{"POST", "/v1/todos", `{"title":"   "}`},
		{"POST", "/v1/todos", `{"title":"x","extra":1}`},
		{"GET", "/v1/todos/1", ""},
		{"GET", "/v1/todos/99", ""},
		{"GET", "/v1/todos/abc", ""},
		{"PATCH", "/v1/todos/1", `{"done":true}`},
		{"GET", "/v1/todos?done=true", ""},
		{"GET", "/v1/todos?done=false&limit=1", ""},
		{"DELETE", "/v1/todos/2", ""},
		{"DELETE", "/v1/todos/2", ""},
	} {
		fmt.Println(call(c[0], c[1], c[2]))
	}
}
Shell
docker run -d --rm --name pg-todo-lab -e POSTGRES_PASSWORD=secret -p 55434:5432 postgres:18 >/dev/null
for i in $(seq 1 60); do docker exec pg-todo-lab pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
sleep 2
DATABASE_URL='postgres://postgres:secret@localhost:55434/postgres' go run .
docker stop pg-todo-lab >/dev/null
Text
POST   /v1/todos                201 Location=/v1/todos/1 {"id":1,"title":"write the article","done":false}
POST   /v1/todos                201 Location=/v1/todos/2 {"id":2,"title":"run the code","done":false}
POST   /v1/todos                422 {"detail":"title must be 1 to 200 characters","status":422,"title":"Unprocessable Entity"}
POST   /v1/todos                400 {"detail":"invalid JSON body","status":400,"title":"Bad Request"}
GET    /v1/todos/1              200 {"id":1,"title":"write the article","done":false}
GET    /v1/todos/99             404 {"detail":"no such todo","status":404,"title":"Not Found"}
GET    /v1/todos/abc            400 {"detail":"id must be a positive integer","status":400,"title":"Bad Request"}
PATCH  /v1/todos/1              200 {"id":1,"title":"write the article","done":true}
GET    /v1/todos?done=true      200 {"data":[{"id":1,"title":"write the article","done":true}]}
GET    /v1/todos?done=false&limit=1 200 {"data":[{"id":2,"title":"run the code","done":false}],"next_before":2}
DELETE /v1/todos/2              204 
DELETE /v1/todos/2              404 {"detail":"no such todo","status":404,"title":"Not Found"}

Reading the walk-through

The two creates return 201 and a Location header pointing at the new resource. The third, with a blank title, gets 422 with a problem body; the fourth carries an unknown field and gets 400. The distinction is deliberate: syntax and shape problems are 400, valid shape with unacceptable values is 422.

GET /v1/todos/1 returns the stored todo, 99 returns 404 and abc returns 400, because an id that is not a positive integer is a client error before the database is consulted.

The PATCH sends only {"done":true}. The response shows the title unchanged and done true. Filtering by done=true then returns exactly that item. done=false&limit=1 returns the other one, plus next_before: 2, which the client would send as before=2 to continue.

The first DELETE returns 204 with no body. The second returns 404. Some APIs make repeated deletes return 204 to be idempotent for the caller; both are defensible, and the choice should be documented. See idempotency.

Running as a real server

The test drive is not what you deploy. The main for the service reads configuration from the environment, builds the pool, sets server timeouts and shuts down cleanly:

Go
func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()
	if _, err := pool.Exec(ctx, schema); err != nil {
		log.Fatal(err)
	}

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           NewRouter(&Store{pool}),
		ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
	go func() {
		<-ctx.Done()
		shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		srv.Shutdown(shutdown)
	}()
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

Applying the schema at start-up is fine for a tutorial. A real service uses a migration tool and versioned migrations, run as a separate step. The server timeouts are covered in HTTP server timeouts and the shutdown in graceful shutdown. This snippet is a sketch that follows those articles; the lab above is what I ran.

Why chi, and when the standard library is enough

Since Go 1.22 the standard ServeMux supports methods and path parameters (mux.HandleFunc("GET /v1/todos/{id}", ...) and r.PathValue("id")), which removes the main reason many projects used to add a router. This API could be written without chi. What chi still gives you is a middleware stack with sensible defaults, nested routes with shared prefixes and middleware per group, and a handler type that is exactly http.Handler, so nothing in your handlers is chi-specific beyond reading URL parameters. Switching away later means changing a handful of lines. If you prefer fewer dependencies, the comparison in the standard library mux versus frameworks walks through the trade-offs.

The part that matters more than the router is keeping handlers small and uniform. All five handlers here follow the same four steps, which means a reviewer can check one for correctness and trust the rest by shape, and a new endpoint is a copy-and-adjust job with an obvious place for each rule.

The error model

Every failure path ends in problem, which writes application/problem+json with a title, a status and a detail. Three rules keep it honest.

First, the detail is for the client's developer and never contains internals. "no such todo" and "title must be 1 to 200 characters" tell them what to fix. A raw database error would tell an attacker about your schema.

Second, the status code carries the machine meaning and the body carries the human one. Clients should branch on 404 and 422, not on the text of the detail. If clients need to distinguish between several 422 cases, add a stable code field to the problem document and document the values.

Third, every unexpected failure becomes the same generic 500, and the specifics go to the log. The RequestID middleware puts an id on the request context, and a Logger middleware can print it with each line, so when a user reports "something went wrong" you can find the cause from the id in the response header. See problem details for the full format and HTTP error handling for mapping domain errors to statuses.

Connection pool and timeouts

pgxpool.New creates a pool with defaults based on the number of CPUs. For a service that handles real traffic, set MaxConns deliberately: the pool size times the number of service instances must stay below the database's max_connections, with headroom for migrations and admin sessions. A pool that is too large moves the queue from your service into the database, where it is harder to see. A pool that is too small makes handlers wait for a connection; the Timeout middleware then turns that wait into a 503-like failure, and the pool's statistics (pool.Stat()) show the wait count so you can tell.

Every store call receives r.Context(). When the five-second timeout fires, or when the client disconnects, the query is cancelled and the connection returns to the pool. This is the property that prevents a slow query from piling up work after the caller has gone.

Testing it properly

The lab's walk-through proves the happy and sad paths once. To keep them proven, convert it into tests that assert. The pattern is a helper that sends a request and decodes the response, and a table of cases, as in table-driven handler tests:

Go
func TestPatchKeepsUntouchedFields(t *testing.T) {
	srv, store := newTestServer(t) // fresh schema per test, as in the integration article
	created := mustCreate(t, store, "write the article")

	resp := send(t, srv, "PATCH", fmt.Sprintf("/v1/todos/%d", created.ID), `{"done":true}`)

	if resp.Status != 200 || resp.Todo.Title != "write the article" || !resp.Todo.Done {
		t.Fatalf("got %+v", resp)
	}
}

Three tests earn their place before any others. The partial update test above protects the COALESCE behaviour. A pagination test creates five rows, reads with limit=2 and follows next_before until the end, asserting that every row appears exactly once, which catches off-by-one mistakes in keyset queries. A constraint test inserts an empty title directly through the store and expects an error, which proves the CHECK is really in the schema.

Migrations in a real project

The schema string in store.go uses IF NOT EXISTS, which makes it safe to run twice and wrong for evolution. The first time you need to add a column, you want a versioned migration that runs once, in order, and records that it ran. Tools such as golang-migrate, goose and Atlas do that (choosing a migration tool). Keep migrations as plain SQL files in the repository, embed them into the binary with go:embed, and run them as a step before the new version of the service starts. Make each migration backward compatible with the previous release of the code, since for a short time both versions will be running against the same database.

What to add next

Tests. Write a table-driven handler test with a fake store for the validation and error mapping, and an integration test per store method against PostgreSQL. The walk-through above becomes the integration test with assertions in place of prints.

Authentication and ownership. A todo belongs to a user. Add a user_id column, take the user from a verified token, and put it in every query: WHERE id = $1 AND user_id = $2. That is the object-level authorisation fix.

Observability. Request logging with the request id, metrics by route template and status class, and traces. Logging and tracing have the details.

Rate limiting and body limits at the edge (rate limiting).

OpenAPI. Describe the API in a document and generate documentation and clients from it (OpenAPI in Go).

Containerise. A two-stage Dockerfile with CGO_ENABLED=0, and a compose.yaml with the database for local work.

The router documentation is at go-chi.io, and the pgx pool and query API used by the store are on pkg.go.dev.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.