The same note showed up twice as I scrolled
See the problem
Section titled “See the problem”A new developer joins the mobile team. They open your API’s list of URLs:
POST /getNotes POST /createNote POST /deleteNote?id=3GET /notes/all POST /updateNoteTitle GET /fetchNote?noteId=3They can’t guess the next one. Is it /getNote or /fetchNote? id or noteId? Then a user writes in: “When I scroll my notes, the same note shows up twice.”
Both problems come from the same place: the API was named action by action, and its list was split into pages by counting.
Define the task
Section titled “Define the task”Design the Notes API so a client can guess every URL. Then write its list endpoint, list_notes(notes, limit, after).
- Input:
notes, a list of{"id": ..., "title": ...}dicts.limit, how many to send (1 to 100).after, a note id, orNonefor the first page. - Output:
{"items": [...], "next": ...}. The items are the newest notes older thanafter, newest first.nextis the link to the following page, orNoneon the last page. - The hard part: a note added or deleted between two pages must not make a note show twice, or not at all.
Example: with notes 1 to 10, list_notes(notes, 5, None) gives notes 10 to 6, and "next": "/notes?limit=5&after=6".
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
Every note moves down one place. The 5th note is now the one that was 6th before.
Hint 2 of 3
Counting places breaks when the list changes. What could page 2 start from that doesn’t move?
Hint 3 of 3
The last note you showed. “Notes older than note 6” means the same thing, whatever arrives on top.
Understand the idea
Section titled “Understand the idea”REST names things, not actions. A resource is a thing the API holds, like a note. Its URL is a noun. The HTTP method is the verb. Then the whole API is a small grid that a client can guess:
| Method and URL | Does | Answers |
|---|---|---|
GET /notes | List your notes | 200 and a page of notes |
POST /notes | Make a note | 201, with Location: /notes/11 |
GET /notes/11 | Read one note | 200, or 404 |
PUT /notes/11 | Replace the whole note | 200, or 204 |
PATCH /notes/11 | Change some fields | 200 |
DELETE /notes/11 | Delete it | 204 |
GET /notes/11/tags | List one note’s tags (a child resource) | 200 |
Rules that make URLs easy to guess:
- Plural nouns:
/notes,/notes/11. No verbs: not/getNotesor/deleteNote. - Filters, sorting and paging go in the query:
/notes?tag=work&sort=-created&limit=20. - Nest only one level:
/notes/11/tags, not/users/3/notes/11/tags/2. - For an action that isn’t create, read, change or delete, make it a noun you
POSTto:POST /notes/11/shares. - Use one style everywhere: one way to spell ids, dates and errors, like
{"error": {"code": "not_found", "message": "..."}}. - Plan for change: put a version in the path (
/v1/notes) or a header. Add fields freely, but never rename or remove one inside a version.
A list needs pages, and pages need a fixed point. Sending every note gets slower each month. Offset paging (?page=2) skips a count of rows, so it moves when rows are added or removed. Cursor paging (?after=6) starts after a row you already have, so it can’t move. Watch both.
By page number: GET /notes?page=1&limit=5
Parts:
- Phone app (the request is here)
- Notes API
- Database
Arrows:
- Phone app sends requests to Notes API
- Notes API sends requests to Database
Step 1 of 9. Ana has 10 notes, ids 1 to 10. Her app shows the newest first, 5 at a time.
Climb the ladder
Section titled “Climb the ladder”Three versions of GET /notes. All three list the newest first. What changes is how much they send, and what happens when the list changes between pages.
Send every note
The idea: GET /notes sends all of the user’s notes in one response.
For 10 notes, it’s the simplest API there is. The client has the whole list.
Note = dict[str, int | str]
def list_notes(notes: list[Note], limit: int = 20, after: int | None = None) -> dict: """Okay: GET /notes sends every note, newest first.""" items = sorted(notes, key=lambda n: n["id"], reverse=True) return {"items": items, "next": None}- Sends
Every note- Repeats or skips
Never- Why
- One response holds the whole list, so it can’t repeat anything. But it grows with every note.
Where it breaks: a user with 5,000 notes gets all 5,000 every time they open the app. The response is slow, and the phone’s memory fills up.
Page numbers
The idea: GET /notes?page=2&limit=5 skips (page − 1) × limit notes and sends the next limit.
The waste in Okay: it sends thousands of notes when the screen shows ten.
Highlighted lines are new in this rung.
Note = dict[str, int | str]
def list_notes(notes: list[Note], limit: int = 20, page: int = 1) -> dict: """Better: GET /notes?page=2&limit=20 skips (page - 1) * limit notes.""" items = sorted(notes, key=lambda n: n["id"], reverse=True) start = (page - 1) * limit chunk = items[start : start + limit] more = start + limit < len(items) return {"items": chunk, "next": f"/notes?page={page + 1}&limit={limit}" if more else None}- Sends
One pagewas Every note- Repeats or skips
When the list changeswas Never- Why
- In SQL, it is LIMIT 5 OFFSET 5. Easy, and a client can jump to any page.
What changed from the previous rung
Note = dict[str, int | str]
def list_notes(notes: list[Note], limit: int = 20, after: int | None = None) -> dict: """Okay: GET /notes sends every note, newest first."""def list_notes(notes: list[Note], limit: int = 20, page: int = 1) -> dict: """Better: GET /notes?page=2&limit=20 skips (page - 1) * limit notes.""" items = sorted(notes, key=lambda n: n["id"], reverse=True) return {"items": items, "next": None} start = (page - 1) * limit chunk = items[start : start + limit] more = start + limit < len(items) return {"items": chunk, "next": f"/notes?page={page + 1}&limit={limit}" if more else None}Where it breaks: a new note between page 1 and page 2 pushes every note down. Page 2 starts with note 6, again. A deleted note pulls them up, so one is never shown. The tests show both. Deep pages are slow too: OFFSET 100000 still reads 100,000 rows.
A cursor: start after the last one you sent
The idea: GET /notes?limit=5&after=6 sends the 5 newest notes older than note 6. Its next link starts after the last of those.
The waste in Better: it counted places, and places move. A note’s id doesn’t.
The limit is capped at 100, so ?limit=1000000 can’t make one huge response.
Highlighted lines are new in this rung.
Note = dict[str, int | str]MAX_LIMIT = 100
def list_notes(notes: list[Note], limit: int = 20, after: int | None = None) -> dict: """Best: GET /notes?limit=20&after=57 gives the notes older than note 57.""" limit = max(1, min(limit, MAX_LIMIT)) items = sorted( (n for n in notes if after is None or n["id"] < after), key=lambda n: n["id"], reverse=True, ) chunk = items[:limit] more = len(items) > limit return { "items": chunk, "next": f"/notes?limit={limit}&after={chunk[-1]['id']}" if more else None, }- Sends
One page- Repeats or skips
Neverwas When the list changes- Why
- WHERE id < 6 ORDER BY id DESC LIMIT 5. A fixed starting point can’t move, and an index finds it at once.
What changed from the previous rung
Note = dict[str, int | str]MAX_LIMIT = 100
def list_notes(notes: list[Note], limit: int = 20, page: int = 1) -> dict: """Better: GET /notes?page=2&limit=20 skips (page - 1) * limit notes.""" items = sorted(notes, key=lambda n: n["id"], reverse=True) start = (page - 1) * limit chunk = items[start : start + limit] more = start + limit < len(items) return {"items": chunk, "next": f"/notes?page={page + 1}&limit={limit}" if more else None}def list_notes(notes: list[Note], limit: int = 20, after: int | None = None) -> dict: """Best: GET /notes?limit=20&after=57 gives the notes older than note 57.""" limit = max(1, min(limit, MAX_LIMIT)) items = sorted( (n for n in notes if after is None or n["id"] < after), key=lambda n: n["id"], reverse=True, ) chunk = items[:limit] more = len(items) > limit return { "items": chunk, "next": f"/notes?limit={limit}&after={chunk[-1]['id']}" if more else None, }When Best isn’t best: a cursor can’t jump to page 7. For an admin table where people want page numbers and the data changes slowly, offset paging is fine. Sorting by something other than the id needs a cursor of two values, like (created, id), because two notes can share a time.
Write it yourself
Section titled “Write it yourself”Write list_notes(notes, limit, after) with a cursor. Press Run tests to check your code.
- Newest first, and only notes with an id below
after(all of them whenafterisNone). limitis kept between 1 and 100.nextis"/notes?limit=<limit>&after=<last id sent>", orNonewhen no older notes are left.
Page through notes with a cursor
Ctrl + Enter runs- Not run yet:
Notes 1 to 10, limit 5: the first page→[[10, 9, 8, 7, 6], '/notes?limit=5&after=6'] - Not run yet:
The next page: after=6→[[5, 4, 3, 2, 1], None] - Not run yet:
Note 11 arrived and note 9 was deleted: still 5 to 1→[5, 4, 3, 2, 1] - Not run yet:
limit=10000 is capped at 100→[100, '/notes?limit=100&after=401'] - Not run yet:
No notes at all→{'items': [], 'next': None} - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Sends | Good | Not so good | Use it when |
|---|---|---|---|---|
| Okay: everything | Every note | Simple; nothing repeats | Grows forever | Small, fixed lists, like a list of countries |
| Better: page numbers | One page | Can jump to any page | Repeats or skips when the list changes; deep pages are slow | Admin tables over data that rarely changes |
| Best: a cursor | One page | No repeats or skips; fast at any depth | No jumping to page 7; needs a stable sort | Feeds, timelines, infinite scroll, most APIs |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- A cursor needs a sort that never ties. Sort by
createdalone, and two notes from the same second can repeat. Add the id as a tie-breaker. - Cap the limit. Without a cap,
?limit=1000000is a way to slow your server down. - Don’t put secrets in URLs. URLs end up in logs and browser history. Tokens go in headers.
PUTreplaces,PATCHchanges. APUTwithout a field removes it. The third practice problem builds aPATCH.- Return the same shape every time. An empty list is
{"items": [], "next": null}, notnullor 404. - Changing a field’s name breaks old apps. Phones run old versions for months. Add a new field, keep the old one, and remove it only in
/v2.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Who uses the API: our app, partners, or the public? Partners make breaking changes very costly.
- How big can a list get, and does it change while people scroll?
- Do clients need to jump to a page number, or only to load more?
A 60-second walkthrough
“I name resources with plural nouns and let the method be the verb: GET /notes to list, POST /notes to create with a 201 and a Location header, and GET, PATCH and DELETE on /notes/42. Filters, sorting and paging go in the query string, and sort fields come from an allow list. For lists I use cursor paging: the response carries a next link like ?after=57, and the query is WHERE id < 57 ORDER BY id DESC LIMIT 20. Unlike page numbers, it never repeats or skips when notes are added between pages, and it’s fast at any depth with an index. I cap the limit, version the API in the path, and use one error format everywhere.”
Likely follow-ups
- Offset or cursor? Cursor for feeds and anything that changes. Offset when users need page numbers and the data is stable.
- How do you version?
/v1/in the path is the easiest to see and to route. A header works too. Within a version, only add fields. - What about actions like “archive”?
PATCH /notes/11with{"archived": true}, or a noun likePOST /notes/11/archive. Pick one style and keep it. - REST or GraphQL? GraphQL lets clients ask for exactly the fields they need in one call. It costs caching and server complexity. REST is simpler for most CRUD APIs.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. Build the next link (easy)
Section titled “1. Build the next link (easy)”Write next_link(path, params). It builds a URL from a path and a dict of query values. Encode the values, leave out any that are None or "", and add no ? when nothing is left.
Example: next_link("/notes", {"tag": "to do & done", "after": 6}) is "/notes?tag=to+do+%26+done&after=6".
Hint 1 of 3
A space or an & inside a value would break the URL. Values must be encoded.
Hint 2 of 3
urllib.parse.urlencode takes a dict and does the joining and the encoding.
Hint 3 of 3
Filter the dict first. If it is empty, return the path alone.
Build the next link
Ctrl + Enter runs- Not run yet:
next_link('/notes', {'limit': 20, 'after': 57})→'/notes?limit=20&after=57' - Not run yet:
A value with spaces and an &→'/notes?tag=to+do+%26+done&after=6' - Not run yet:
Nothing left: no ?→'/notes' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Join the pairs by hand
The idea: Write each pair as key=value and join them with &.
It is right for numbers, which is most of a next link.
def next_link(path: str, params: dict[str, str]) -> str: """Okay: glue the pairs together with & and =.""" return path + "?" + "&".join(f"{k}={v}" for k, v in params.items())- Works for
Plain values- Breaks on
Spaces, &, empty values- Why
- The values go into the URL as they are.
Where it breaks: to do & done puts a raw & in the URL, so the server reads a second, broken pair.
Encode each value
The idea: Run each value through urllib.parse.quote first.
The waste in Okay: the value was never made safe for a URL.
Highlighted lines are new in this rung.
from urllib.parse import quote
def next_link(path: str, params: dict[str, str]) -> str: """Better: encode each value, so spaces and & can't break the link.""" return path + "?" + "&".join(f"{k}={quote(str(v))}" for k, v in params.items())- Works for
Any valuewas Plain values- Breaks on
Empty valueswas Spaces, &, empty values- Why
- quote turns a space into %20 and & into %26.
What changed from the previous rung
from urllib.parse import quote
def next_link(path: str, params: dict[str, str]) -> str: """Okay: glue the pairs together with & and =.""" return path + "?" + "&".join(f"{k}={v}" for k, v in params.items()) """Better: encode each value, so spaces and & can't break the link.""" return path + "?" + "&".join(f"{k}={quote(str(v))}" for k, v in params.items())Where it breaks: None becomes the text None, and an empty dict leaves a stray ?.
urlencode, without the empty ones
The idea: Drop None and empty values, then let urlencode join and encode the rest.
The waste in Better: a hand-made join of what the standard library already does.
Highlighted lines are new in this rung.
from urllib.parse import urlencode
def next_link(path: str, params: dict[str, str | int | None]) -> str: """Best: urlencode the pairs, leave out empty ones, and no ? when nothing is left.""" kept = {k: v for k, v in params.items() if v is not None and v != ""} return f"{path}?{urlencode(kept)}" if kept else path- Works for
Any value- Breaks on
Nothing in the taskwas Empty values- Why
- One standard call, given only the values that mean something.
What changed from the previous rung
from urllib.parse import quotefrom urllib.parse import urlencode
def next_link(path: str, params: dict[str, str]) -> str: """Better: encode each value, so spaces and & can't break the link.""" return path + "?" + "&".join(f"{k}={quote(str(v))}" for k, v in params.items())def next_link(path: str, params: dict[str, str | int | None]) -> str: """Best: urlencode the pairs, leave out empty ones, and no ? when nothing is left.""" kept = {k: v for k, v in params.items() if v is not None and v != ""} return f"{path}?{urlencode(kept)}" if kept else pathWhen Best isn’t best: frameworks build URLs for you: url_for in Flask, reverse in Django, request.url_for in FastAPI. Use them, so a renamed route can’t leave a broken link.
2. Filter and sort from the query (medium)
Section titled “2. Filter and sort from the query (medium)”Write apply_query(notes, query). query["tag"], if there, keeps only notes with that tag. query["sort"] is a list of fields like "-created,title": a - means newest or largest first. Only id, title and created may be sorted on. Any other field raises ValueError, which the API turns into a 400. Without sort, sort by id.
Example: apply_query(notes, {"sort": "-created,title"}) sorts by created, newest first, and by title where two share a date.
Hint 1 of 3
Sorting by a field the client names can leak data, like a private field, and a typo crashes with a 500. Keep an allow list.
Hint 2 of 3
Split sort on commas, and check every field before you sort.
Hint 3 of 3
Python sorts are stable. Sort by the last key first, then the one before, and the first key wins.
Filter and sort from the query
Ctrl + Enter runs- Not run yet:
tag=work&sort=-created→[1, 3] - Not run yet:
sort=-created,title: two keys→[2, 1, 3] - Not run yet:
sort=secret is not allowed→ raisesValueError - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Sort by whatever the client names
The idea: Filter by tag, then sort by the field in the query.
It handles every sort the app asks for today.
def apply_query(notes: list[dict], query: dict[str, str]) -> list[dict]: """Okay: filter by tag, then sort by whatever field the client names.""" out = [n for n in notes if "tag" not in query or query["tag"] in n["tags"]] field = query.get("sort", "id") return sorted(out, key=lambda n: n[field.lstrip("-")], reverse=field.startswith("-"))- Sorts by
Any field- Risk
500s, leaks- Why
- The query picks a dict key straight from the client.
Where it breaks: sort=colour raises KeyError, a 500. sort=secret sorts by a private field, so the order gives it away.
An allow list
The idea: Sort only by fields in SORTABLE, and raise ValueError for any other.
The waste in Okay: it trusted the client to name only safe fields.
Highlighted lines are new in this rung.
SORTABLE = {"id", "title", "created"}
def apply_query(notes: list[dict], query: dict[str, str]) -> list[dict]: """Better: sort only by fields on the allow list, or raise ValueError (a 400).""" out = [n for n in notes if "tag" not in query or query["tag"] in n["tags"]] field = query.get("sort", "id") if field.lstrip("-") not in SORTABLE: raise ValueError(f"can't sort by {field!r}") return sorted(out, key=lambda n: n[field.lstrip("-")], reverse=field.startswith("-"))- Sorts by
One allowed fieldwas Any field- Risk
Nonewas 500s, leaks- Why
- A bad field is now the client’s fault, a 400 with a clear message.
What changed from the previous rung
SORTABLE = {"id", "title", "created"}
def apply_query(notes: list[dict], query: dict[str, str]) -> list[dict]: """Okay: filter by tag, then sort by whatever field the client names.""" """Better: sort only by fields on the allow list, or raise ValueError (a 400).""" out = [n for n in notes if "tag" not in query or query["tag"] in n["tags"]] field = query.get("sort", "id") if field.lstrip("-") not in SORTABLE: raise ValueError(f"can't sort by {field!r}") return sorted(out, key=lambda n: n[field.lstrip("-")], reverse=field.startswith("-"))Where it breaks: sort=-created,title is a fair request, but Better reads it as one field and turns it away.
Several keys, each allowed
The idea: Split on commas, check every key, then sort by the last key first.
The waste in Better: it had the safe list, but only took one key.
Highlighted lines are new in this rung.
SORTABLE = {"id", "title", "created"}
def apply_query(notes: list[dict], query: dict[str, str]) -> list[dict]: """Best: several sort keys, like "-created,title", each checked against the allow list.""" out = [n for n in notes if "tag" not in query or query["tag"] in n["tags"]] keys = [k.strip() for k in query.get("sort", "id").split(",")] for key in keys: if key.lstrip("-") not in SORTABLE: raise ValueError(f"can't sort by {key!r}") for key in reversed(keys): # sort by the last key first; Python sorts are stable field = key.lstrip("-") out.sort(key=lambda n: n[field], reverse=key.startswith("-")) return out- Sorts by
Several allowed fieldswas One allowed field- Risk
None- Why
- A stable sort keeps the earlier order on ties, so sorting from the last key to the first gives the right order.
What changed from the previous rung
SORTABLE = {"id", "title", "created"}
def apply_query(notes: list[dict], query: dict[str, str]) -> list[dict]: """Better: sort only by fields on the allow list, or raise ValueError (a 400).""" """Best: several sort keys, like "-created,title", each checked against the allow list.""" out = [n for n in notes if "tag" not in query or query["tag"] in n["tags"]] field = query.get("sort", "id") if field.lstrip("-") not in SORTABLE: raise ValueError(f"can't sort by {field!r}") return sorted(out, key=lambda n: n[field.lstrip("-")], reverse=field.startswith("-")) keys = [k.strip() for k in query.get("sort", "id").split(",")] for key in keys: if key.lstrip("-") not in SORTABLE: raise ValueError(f"can't sort by {key!r}") for key in reversed(keys): # sort by the last key first; Python sorts are stable field = key.lstrip("-") out.sort(key=lambda n: n[field], reverse=key.startswith("-")) return outWhen Best isn’t best: with a database, sort there, with ORDER BY created DESC, title, and only on indexed fields. Sorting a whole table in Python is slow.
3. Change some fields with PATCH (medium)
Section titled “3. Change some fields with PATCH (medium)”Write apply_patch(note, patch) for PATCH /notes/<id>. It gives back a new note with the patch applied, the JSON Merge Patch way (RFC 7396):
- A value replaces the field.
null(None) removes it. - A dict inside a dict merges, by the same rules.
idandownercan’t change: raiseValueErrornaming them.
Example: with note["meta"] == {"pinned": True, "rank": 2}, the patch {"meta": {"rank": 5}} gives {"pinned": True, "rank": 5}.
Hint 1 of 3
{**note, **patch} is a start, but it sets a field to None instead of removing it.
Hint 2 of 3
Check the read-only fields first, before you change anything.
Hint 3 of 3
When both the old value and the new value are dicts, call your merge on them. That is recursion.
Change some fields with PATCH
Ctrl + Enter runs- Not run yet:
apply_patch({'id': 3, 'title': 'Milk'}, {'title': 'Oat milk'})→{'id': 3, 'title': 'Oat milk'} - Not run yet:
null removes the field→{'id': 3, 'title': 'Milk'} - Not run yet:
A dict inside merges→{'id': 3, 'meta': {'pinned': True, 'rank': 5}} - Not run yet:
owner can’t change→ raisesValueError - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Copy the patch on top
The idea: {**note, **patch}: every field in the patch replaces the note’s.
The most common patch, a new title, works.
def apply_patch(note: dict, patch: dict) -> dict: """Okay: copy every field of the patch onto the note.""" return {**note, **patch}- Handles
Changed values- Gets wrong
null, nested dicts, owner- Why
- It only knows one rule: replace.
Where it breaks: {"colour": null} keeps the field as None. A nested dict replaces the old one. And {"owner": "ben"} gives Ana’s note to Ben.
null removes, owner is locked
The idea: Loop over the patch: None removes the field, id and owner raise, anything else replaces.
The waste in Okay: it treated null as a value, and every field as open to change.
Highlighted lines are new in this rung.
def apply_patch(note: dict, patch: dict) -> dict: """Better: null removes a field; the id and owner can't change.""" out = dict(note) for key, value in patch.items(): if key in ("id", "owner"): raise ValueError(f"{key} can't be changed") if value is None: out.pop(key, None) else: out[key] = value return out- Handles
null, read-only fieldswas Changed values- Gets wrong
Nested dictswas null, nested dicts, owner- Why
- Each value gets a rule, not just a copy.
What changed from the previous rung
def apply_patch(note: dict, patch: dict) -> dict: """Okay: copy every field of the patch onto the note.""" return {**note, **patch} """Better: null removes a field; the id and owner can't change.""" out = dict(note) for key, value in patch.items(): if key in ("id", "owner"): raise ValueError(f"{key} can't be changed") if value is None: out.pop(key, None) else: out[key] = value return outWhere it breaks: {"meta": {"rank": 5}} replaces all of meta, so pinned is lost.
Merge all the way down
The idea: Check read-only fields first, then merge: dicts merge with the same rules, None removes, anything else replaces.
The waste in Better: one level of rules for data that has more than one level.
Highlighted lines are new in this rung.
READ_ONLY = {"id", "owner"}
def merge(target: dict, patch: dict) -> dict: """JSON Merge Patch (RFC 7396): dicts merge, null removes, anything else replaces.""" out = dict(target) for key, value in patch.items(): if value is None: out.pop(key, None) elif isinstance(value, dict) and isinstance(out.get(key), dict): out[key] = merge(out[key], value) else: out[key] = value return out
def apply_patch(note: dict, patch: dict) -> dict: """Best: merge nested fields too, and refuse to change read-only ones.""" bad = READ_ONLY & patch.keys() if bad: raise ValueError(f"{', '.join(sorted(bad))} can't be changed") return merge(note, patch)- Handles
All of RFC 7396was null, read-only fields- Gets wrong
Nothing in the taskwas Nested dicts- Why
- A short recursive merge follows the standard, so clients know what a patch will do.
What changed from the previous rung
def apply_patch(note: dict, patch: dict) -> dict: """Better: null removes a field; the id and owner can't change.""" out = dict(note)READ_ONLY = {"id", "owner"}
def merge(target: dict, patch: dict) -> dict: """JSON Merge Patch (RFC 7396): dicts merge, null removes, anything else replaces.""" out = dict(target) for key, value in patch.items(): if key in ("id", "owner"): raise ValueError(f"{key} can't be changed") if value is None: out.pop(key, None) elif isinstance(value, dict) and isinstance(out.get(key), dict): out[key] = merge(out[key], value) else: out[key] = value return out
def apply_patch(note: dict, patch: dict) -> dict: """Best: merge nested fields too, and refuse to change read-only ones.""" bad = READ_ONLY & patch.keys() if bad: raise ValueError(f"{', '.join(sorted(bad))} can't be changed") return merge(note, patch)When Best isn’t best: Merge Patch can’t change one item in a list. For that, JSON Patch (RFC 6902) sends operations like {"op": "replace", "path": "/tags/0"}. In a real app, a schema such as Pydantic or a DRF serializer checks each field’s type too.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- Plural nouns for URLs, methods for verbs:
GET /notes,POST /notes,PATCH /notes/11. - Filters, sorting and paging go in the query. Sort only by fields on an allow list.
- Page with a cursor (
?after=57), not a page number, for anything that changes. - Cap
limit. Return the same shape for an empty list. - Version in the path; inside a version, only add fields.
Go deeper
Section titled “Go deeper”- Roy Fielding’s dissertation, chapter 5, where REST was first described.
- RFC 7396, JSON Merge Patch, and RFC 6902, JSON Patch.
- RFC 9457, problem details, a standard shape for API errors.
- Next on the Frameworks roadmap: sessions, JWT and OAuth2, how the API knows who is asking.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started