Skip to content

The same note showed up twice as I scrolled

  • Frameworks · Foundations
  • 25 min read, about 1 h 20 min with practice
  • Concept
  • Asked often: high
  • Covers HTTP 1.1 (RFC 9110), Python 3.11+
  • Reviewed 2026-11

A new developer joins the mobile team. They open your API’s list of URLs:

POST /getNotes POST /createNote POST /deleteNote?id=3
GET /notes/all POST /updateNoteTitle GET /fetchNote?noteId=3

They 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.

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, or None for the first page.
  • Output: {"items": [...], "next": ...}. The items are the newest notes older than after, newest first. next is the link to the following page, or None on 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".

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.

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 URLDoesAnswers
GET /notesList your notes200 and a page of notes
POST /notesMake a note201, with Location: /notes/11
GET /notes/11Read one note200, or 404
PUT /notes/11Replace the whole note200, or 204
PATCH /notes/11Change some fields200
DELETE /notes/11Delete it204
GET /notes/11/tagsList one note’s tags (a child resource)200

Rules that make URLs easy to guess:

  • Plural nouns: /notes, /notes/11. No verbs: not /getNotes or /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 POST to: 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.

Two pages, while a new note arrives

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.

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.

Okay

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.

Better

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.

Best

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 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 when after is None).
  • limit is kept between 1 and 100.
  • next is "/notes?limit=<limit>&after=<last id sent>", or None when no older notes are left.

Page through notes with a cursor

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Notes 1 to 10, limit 5: the first page → [[10, 9, 8, 7, 6], '/notes?limit=5&after=6']
  2. Not run yet: The next page: after=6 → [[5, 4, 3, 2, 1], None]
  3. Not run yet: Note 11 arrived and note 9 was deleted: still 5 to 1 → [5, 4, 3, 2, 1]
  4. Not run yet: limit=10000 is capped at 100 → [100, '/notes?limit=100&after=401']
  5. Not run yet: No notes at all → {'items': [], 'next': None}
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WaySendsGoodNot so goodUse it when
Okay: everythingEvery noteSimple; nothing repeatsGrows foreverSmall, fixed lists, like a list of countries
Better: page numbersOne pageCan jump to any pageRepeats or skips when the list changes; deep pages are slowAdmin tables over data that rarely changes
Best: a cursorOne pageNo repeats or skips; fast at any depthNo jumping to page 7; needs a stable sortFeeds, timelines, infinite scroll, most APIs
  • A cursor needs a sort that never ties. Sort by created alone, and two notes from the same second can repeat. Add the id as a tie-breaker.
  • Cap the limit. Without a cap, ?limit=1000000 is 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.
  • PUT replaces, PATCH changes. A PUT without a field removes it. The third practice problem builds a PATCH.
  • Return the same shape every time. An empty list is {"items": [], "next": null}, not null or 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.

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/11 with {"archived": true}, or a noun like POST /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.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: next_link('/notes', {'limit': 20, 'after': 57}) → '/notes?limit=20&after=57'
  2. Not run yet: A value with spaces and an & → '/notes?tag=to+do+%26+done&after=6'
  3. Not run yet: Nothing left: no ? → '/notes'
  4. 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
Okay

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.

Better

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 ?.

Best

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 quote
from 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 path

When 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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: tag=work&sort=-created → [1, 3]
  2. Not run yet: sort=-created,title: two keys → [2, 1, 3]
  3. Not run yet: sort=secret is not allowed → raises ValueError
  4. Not run yet: Extra test
  5. 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
Okay

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.

Better

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.

Best

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 out

When 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.

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.
  • id and owner can’t change: raise ValueError naming 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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: apply_patch({'id': 3, 'title': 'Milk'}, {'title': 'Oat milk'}) → {'id': 3, 'title': 'Oat milk'}
  2. Not run yet: null removes the field → {'id': 3, 'title': 'Milk'}
  3. Not run yet: A dict inside merges → {'id': 3, 'meta': {'pinned': True, 'rank': 5}}
  4. Not run yet: owner can’t change → raises ValueError
  5. Not run yet: Extra test
  6. 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
Okay

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.

Better

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 out

Where it breaks: {"meta": {"rank": 5}} replaces all of meta, so pinned is lost.

Best

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.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Spot the bugWhich of these URLs breaks REST naming?
  2. Predict the outputNotes 1 to 10, newest first, 5 a page. Page 1 showed 10 to 6. Then note 11 is added. What does ?page=2 show?
  3. Multiple choiceWhy cap the limit in GET /notes?limit=...?
  4. Pick the patternA social feed where new posts arrive every second. Which paging?
  5. Fill in the answerA PATCH body {"colour": ____} removes the colour field, in JSON Merge Patch.

Add these to your review deck. Each card comes back a little later every time you get it right.

  • 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.

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