Step 11 of 24

REST API design

Design resource-oriented JSON APIs with correct methods and status codes, consistent errors and pagination that scales.

16 min 8 sections

You already know how to write handlers, route requests and decode JSON. This lesson is about the contract your API offers. A well-designed API is predictable: a developer who has used one endpoint can guess how the next one works. We'll cover resource modelling, method semantics, status codes, a standard error format, pagination, and how to change an API without breaking the people using it.

Model resources, not actions

REST APIs are built around resources: the nouns of your domain. URLs name resources, and HTTP methods say what to do with them. Most resources come in two shapes: a collection and an item in that collection.

text
GET    /v1/articles             list articles
POST   /v1/articles             create an article
GET    /v1/articles/42          fetch article 42
PATCH  /v1/articles/42          change some fields of article 42
DELETE /v1/articles/42          delete article 42
GET    /v1/articles/42/comments list the comments that belong to article 42

RPC-style URLs like /getArticle?id=42 put the verb in the path, so every endpoint is a one-off. With resources, the verbs come from a small set everyone already knows. A few rules keep URLs consistent:

  • Use plural nouns for collections (/articles, not /article), lowercase, with hyphens between words (/order-items).
  • Nest only for real ownership, and only one level deep. /articles/42/comments is fine; /users/7/articles/42/comments/3 is not. Once you have a comment's ID, /comments/3 is enough.
  • Model actions such as publishing as a state change (PATCH /v1/articles/42 with {"status": "published"}) or a sub-resource (POST /v1/articles/42/publication), not /publishArticle.

Go's ServeMux maps directly onto this style:

go
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/articles", listArticles)
mux.HandleFunc("POST /v1/articles", createArticle)
mux.HandleFunc("GET /v1/articles/{id}", getArticle)
mux.HandleFunc("PATCH /v1/articles/{id}", updateArticle)
mux.HandleFunc("DELETE /v1/articles/{id}", deleteArticle)

A PUT /v1/articles/42 gets an automatic 405 Method Not Allowed with an Allow header listing the methods that exist.

Methods have semantics

Each HTTP method makes promises that clients, proxies and retry logic rely on. Your handlers must keep them.

MethodPurposeSafeIdempotentTypical success
GETRead a resourceYesYes200
POSTCreate in a collection, or trigger processingNoNo201 + Location
PUTReplace the whole resourceNoYes200 or 204
PATCHChange part of a resourceNoNot guaranteed200
DELETERemove a resourceNoYes204

Safe means the request does not change server state. Idempotent means sending it twice has the same effect as sending it once: deleting article 42 twice still leaves it deleted (the second call may return 404, but the state is the same), while POSTing twice creates two articles. This matters when the network fails: a client whose PUT timed out can simply retry; a client whose POST timed out can't know whether it was applied.

PUT versus PATCH

PUT replaces the entire resource; fields you leave out are reset. PATCH changes only the fields present, which is what clients usually want. Decode PATCH bodies into a struct of pointers so you can tell "not sent" (nil) from "set to empty":

go
type articlePatch struct {
	Title  *string `json:"title"`
	Body   *string `json:"body"`
	Status *string `json:"status"`
}

Making POST safe to retry

Where duplicates are costly (payments, orders), accept an Idempotency-Key header. The client generates a unique key per logical operation; the server stores the key with the response it produced and replays that response if the key arrives again.

Status codes that carry meaning

Clients use the status code to decide whether to retry, ask the user to fix input, or give up. Use a small, consistent set:

CodeUse it when
200 OKA read or update succeeded and you return a body
201 CreatedPOST created a resource; set Location: /v1/articles/43
204 No ContentSuccess with no body (typical for DELETE)
400 Bad RequestThe request is malformed: invalid JSON, wrong types, unknown query parameter
401 UnauthorizedNo credentials, or invalid ones. The client should authenticate
403 ForbiddenAuthenticated, but not allowed to do this
404 Not FoundThe resource doesn't exist (or the caller may not know it exists)
409 ConflictThe request clashes with current state, such as a duplicate email
422 Unprocessable ContentWell-formed JSON that fails validation rules
429 Too Many RequestsRate limited; include Retry-After
500 Internal Server ErrorYour bug. Never put internal details in the body

Two pairs get confused constantly. 401 vs 403: 401 means "I don't know who you are", 403 means "I know who you are and the answer is no". 400 vs 422: pick a rule and apply it everywhere. A common one is 400 when the body can't be parsed into your struct at all, and 422 when it parses but the values are wrong (an empty title, a negative quantity).

A consistent error shape

Clients write one error-handling path, so every error should look the same. Rather than inventing a format, use Problem Details (RFC 9457), served as application/problem+json:

json
{
  "type": "https://api.example.com/problems/validation",
  "title": "Request body failed validation",
  "status": 422,
  "detail": "2 fields are invalid",
  "instance": "/v1/articles",
  "errors": [
    {"field": "title", "message": "must not be empty"},
    {"field": "status", "message": "must be one of draft, published"}
  ]
}

type is a stable identifier clients can switch on, title a short summary, status repeats the HTTP code, and detail explains this occurrence. Extension members such as the per-field errors list are allowed. An omitted type defaults to about:blank, meaning "see the status code".

A small helper keeps every handler consistent:

go
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
}

type Problem struct {
	Type     string       `json:"type,omitempty"`
	Title    string       `json:"title"`
	Status   int          `json:"status"`
	Detail   string       `json:"detail,omitempty"`
	Instance string       `json:"instance,omitempty"`
	Errors   []FieldError `json:"errors,omitempty"`
}

func writeProblem(w http.ResponseWriter, r *http.Request, p Problem) {
	if p.Instance == "" {
		p.Instance = r.URL.Path
	}
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(p.Status)
	_ = json.NewEncoder(w).Encode(p)
}

A 404 then becomes one line: writeProblem(w, r, Problem{Title: "Resource not found", Status: http.StatusNotFound}).

For 500s, log the real error with a request ID and return only a generic title plus that ID, never stack traces or SQL.

Pagination

A list endpoint that returns every row works in development and falls over in production. Always paginate, with a default page size and a maximum (say, 20 and 100).

Offset pagination

?limit=20&offset=40 maps to LIMIT 20 OFFSET 40. It's simple and supports "jump to page 7", but the database still reads and discards every skipped row, so deep pages get slow, and a row inserted mid-browse shifts everything by one, so users see duplicates or miss items.

Cursor (keyset) pagination

Instead of "skip N rows", the client says "give me rows after this one". The server returns an opaque next_cursor that encodes the sort key of the last item:

json
{
  "data": [{"id": 981, "title": "..."}, {"id": 975, "title": "..."}],
  "next_cursor": "eyJ0IjoiMjAyNi0xMC0wMVQwOToxMjowMFoiLCJpZCI6OTc1fQ"
}

The query uses a row comparison on a unique, indexed sort key. Adding id as a tie-breaker makes the order total even when two articles share a timestamp:

sql
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3;

With an index on (created_at, id), page 1,000 costs the same as page 1, and concurrent inserts can't shift results. The trade-off: clients can't jump to an arbitrary page, which rarely matters for infinite scroll or data syncing. Encode the cursor so clients treat it as opaque and can't come to depend on its format:

go
type cursor struct {
	CreatedAt time.Time `json:"t"`
	ID        int64     `json:"id"`
}

func encodeCursor(c cursor) string {
	b, _ := json.Marshal(c) // cannot fail for this struct
	return base64.RawURLEncoding.EncodeToString(b)
}

func decodeCursor(s string) (cursor, error) {
	var c cursor
	b, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil {
		return c, err
	}
	err = json.Unmarshal(b, &c)
	return c, err
}

To know whether there's a next page without a separate COUNT(*), fetch limit + 1 rows. If you get the extra row, there is more data: drop it and emit a cursor for the last row you keep.

Filtering, sorting and evolving the API

Filters and sorting belong in the query string: GET /v1/articles?status=published&sort=-created_at (a leading - for descending is a common convention). Never pass sort into SQL directly; map it through an allowlist:

go
var sortColumns = map[string]string{
	"created_at":  "created_at ASC, id ASC",
	"-created_at": "created_at DESC, id DESC",
	"title":       "title ASC, id ASC",
}

An unknown value gets a 400 with a Problem explaining the allowed options.

APIs outlive the code behind them, so plan for change. Version in the path (/v1/...): it's visible and easy to route. Additive changes are free: a new optional field or endpoint doesn't break clients that ignore unknown fields. Breaking changes need a new version: removing or renaming a field, changing a type, or making an optional field required. Run both versions side by side during a deprecation window.

Practice

Implement GET /v1/articles with cursor pagination over an in-memory slice (so you can focus on the API, not the database).

  1. Seed 50 articles with descending CreatedAt times and unique IDs.
  2. Accept limit (default 20, max 100) and an optional cursor.
  3. Return {"data": [...], "next_cursor": "..."}, omitting next_cursor on the last page.
  4. Return a Problem Details 400 for a non-numeric or out-of-range limit, or an undecodable cursor.

Test it with curl by following next_cursor until it disappears.

Show solution

This reuses cursor, encodeCursor, decodeCursor, Problem and writeProblem from the lesson. The articles are sorted newest first, and the "after cursor" test mirrors the SQL row comparison.

go
type Article struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	CreatedAt time.Time `json:"created_at"`
}

type page struct {
	Data       []Article `json:"data"`
	NextCursor string    `json:"next_cursor,omitempty"`
}

// articles is sorted by (CreatedAt DESC, ID DESC).
var articles []Article

func listArticles(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()

	limit := 20
	if s := q.Get("limit"); s != "" {
		n, err := strconv.Atoi(s)
		if err != nil || n < 1 || n > 100 {
			writeProblem(w, r, Problem{Title: "Invalid limit", Status: http.StatusBadRequest,
				Detail: "limit must be an integer between 1 and 100"})
			return
		}
		limit = n
	}

	start := 0
	if s := q.Get("cursor"); s != "" {
		c, err := decodeCursor(s)
		if err != nil {
			writeProblem(w, r, Problem{Title: "Invalid cursor", Status: http.StatusBadRequest})
			return
		}
		// Skip everything at or before the cursor position.
		for start < len(articles) {
			a := articles[start]
			if a.CreatedAt.Before(c.CreatedAt) || (a.CreatedAt.Equal(c.CreatedAt) && a.ID < c.ID) {
				break
			}
			start++
		}
	}

	end := min(start+limit+1, len(articles)) // fetch one extra
	rows := articles[start:end]

	resp := page{Data: rows}
	if len(rows) > limit {
		resp.Data = rows[:limit]
		last := resp.Data[limit-1]
		resp.NextCursor = encodeCursor(cursor{CreatedAt: last.CreatedAt, ID: last.ID})
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(resp)
}

func main() {
	base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
	for i := 50; i >= 1; i-- {
		articles = append(articles, Article{
			ID:        int64(i),
			Title:     fmt.Sprintf("Article %d", i),
			CreatedAt: base.Add(time.Duration(i) * time.Minute),
		})
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /v1/articles", listArticles)
	log.Fatal(http.ListenAndServe(":8080", mux))
}

Request ?limit=2, then pass the returned next_cursor back as &cursor=...; ?limit=500 returns a 400 application/problem+json response.

Key takeaways

  • URLs name resources (plural nouns, shallow nesting); HTTP methods supply the verbs.
  • GET is safe; GET, PUT and DELETE are idempotent; POST is neither, so use an Idempotency-Key where duplicates hurt.
  • Use a small, consistent set of status codes: 201 with Location for creation, 401 for "who are you", 403 for "not allowed".
  • Return every error as Problem Details (application/problem+json) so clients need one error path.
  • Prefer cursor (keyset) pagination on a unique, indexed sort key; fetch limit + 1 to detect the next page.
  • Version in the path, make additive changes freely, and ship breaking changes only as a new version.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Which HTTP method is idempotent but not safe?

  2. 2.A logged-in user with the 'viewer' role calls DELETE /v1/articles/42, which only editors may do. What status should the API return?

  3. 3.Why does cursor (keyset) pagination stay fast on page 1,000 while OFFSET pagination slows down?

  4. 4.Which change to a public v1 API is safe to make without a new version?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.