Step 11 of 24
REST API design
Design resource-oriented JSON APIs with correct methods and status codes, consistent errors and pagination that scales.
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.
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 42RPC-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/commentsis fine;/users/7/articles/42/comments/3is not. Once you have a comment's ID,/comments/3is enough. - Model actions such as publishing as a state change (
PATCH /v1/articles/42with{"status": "published"}) or a sub-resource (POST /v1/articles/42/publication), not/publishArticle.
Go's ServeMux maps directly onto this style:
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.
| Method | Purpose | Safe | Idempotent | Typical success |
|---|---|---|---|---|
| GET | Read a resource | Yes | Yes | 200 |
| POST | Create in a collection, or trigger processing | No | No | 201 + Location |
| PUT | Replace the whole resource | No | Yes | 200 or 204 |
| PATCH | Change part of a resource | No | Not guaranteed | 200 |
| DELETE | Remove a resource | No | Yes | 204 |
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":
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:
| Code | Use it when |
|---|---|
| 200 OK | A read or update succeeded and you return a body |
| 201 Created | POST created a resource; set Location: /v1/articles/43 |
| 204 No Content | Success with no body (typical for DELETE) |
| 400 Bad Request | The request is malformed: invalid JSON, wrong types, unknown query parameter |
| 401 Unauthorized | No credentials, or invalid ones. The client should authenticate |
| 403 Forbidden | Authenticated, but not allowed to do this |
| 404 Not Found | The resource doesn't exist (or the caller may not know it exists) |
| 409 Conflict | The request clashes with current state, such as a duplicate email |
| 422 Unprocessable Content | Well-formed JSON that fails validation rules |
| 429 Too Many Requests | Rate limited; include Retry-After |
| 500 Internal Server Error | Your 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:
{
"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:
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:
{
"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:
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:
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:
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).
- Seed 50 articles with descending
CreatedAttimes and unique IDs. - Accept
limit(default 20, max 100) and an optionalcursor. - Return
{"data": [...], "next_cursor": "..."}, omittingnext_cursoron the last page. - 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.
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-Keywhere duplicates hurt. - Use a small, consistent set of status codes: 201 with
Locationfor 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 + 1to 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.Which HTTP method is idempotent but not safe?
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.Why does cursor (keyset) pagination stay fast on page 1,000 while OFFSET pagination slows down?
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.
- REST, gRPC and GraphQLThree API styles and where each one fits: public APIs, service-to-service, and client-shaped reads.
- Idempotency keysMake a retried POST do its work exactly once by remembering the client’s request key.
- Keyset paginationPage through large tables with a cursor on an indexed key instead of OFFSET.
- Hyrum's lawWith enough users, every observable behavior of your API is depended on by someone, whatever the contract says.
Finished reading?
Mark it complete to fill in your roadmap.