Skip to content

A user made someone else the owner of a note by adding one field to the JSON

  • Frameworks · Core
  • 30 min read, about 1 h 10 min with practice
  • Concept
  • Asked often: medium
  • Covers Django REST Framework 3.18, Django 5.2, Python 3.11+
  • Reviewed 2026-11

The Notes API takes a PATCH with JSON and saves it on the note:

note = Note.objects.get(pk=pk, owner=request.user)
for key, value in json.loads(request.body).items():
setattr(note, key, value)
note.save()

Ana sends {"title": "Mine now", "owner_id": 2} for her own note. The note moves to Ben’s list. With {"id": 1} she could overwrite note 1. A blank title of spaces is saved too. The client chose which fields to change, and the server never said which ones it may.

Write update_note(note, data): apply a partial update, as a DRF serializer does for PATCH.

  • Input: the note as a dict (id, owner, title, body), and the JSON the client sent.
  • Output: (note_after, errors). Only title and body can change; anything else in the JSON is ignored.
  • The rules: a title is trimmed, not blank, at most 200 characters. A body must be text. Fields not sent stay as they are.
  • The hard part: if anything is invalid, nothing changes. And the note passed in is never changed in place.

Example: update_note(NOTE, {"owner": 2, "title": "Mine now"}) gives ({"id": 7, "owner": 1, "title": "Mine now", "body": ""}, {}).

Hints open one at a time:

Hint 1 of 3

Not the client’s. Anyone can add a key to JSON.

Hint 2 of 3

The server needs a list of writable fields, and must ignore the rest.

Hint 3 of 3

Check every writable field first, and save only if all are fine. That is what a serializer’s is_valid() and save() split does.

DRF (Django REST Framework) builds JSON APIs on Django. The DRF version of the Notes API is in examples/django/notes/api_v2.py, served at /api/v2/notes/. Compare it with notes/views.py, which does each step by hand.

A serializer turns a model into JSON, and JSON into a checked model. A ModelSerializer reads the field types and rules from the model:

class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = ("id", "title", "body", "created_at", "updated_at")
read_only_fields = ("id", "created_at", "updated_at")

Only fields in fields, minus read_only_fields, can be set. owner isn’t listed, so a client can’t send it. This stops mass assignment: saving fields the client should not control. serializer.is_valid() checks every field and fills serializer.errors; serializer.save() writes. A validate_title method adds your own rule.

A viewset is the five actions in one class. ModelViewSet gives list, create, retrieve, update, partial update and destroy. You set two things that matter:

  • get_queryset(): Note.objects.filter(owner=self.request.user), so other users’ notes are 404.
  • perform_create(): serializer.save(owner=self.request.user), so the owner comes from the session.

A router writes the URLs. router.register("notes", NoteViewSet) gives /notes/ and /notes/<pk>/ with the right methods.

Permissions, throttling and pagination are classes, set per view or for the whole API in REST_FRAMEWORK in settings:

  • Permissions answer “may this user do this?”: IsAuthenticated, IsAdminUser, or your own has_object_permission. The first practice problem writes one.
  • Throttling answers “too many requests?” with 429 and a Retry-After header. UserRateThrottle with "1000/hour". The second practice problem builds one.
  • Pagination answers “which slice?”: PageNumberPagination adds count, next and previous. The third practice problem builds the links.
A PATCH through DRF

PATCH /api/v2/notes/7/ in DRF

Parts:

  • Client (the request is here)
  • Auth, permissions, throttle
  • NoteViewSet.partial_update
  • NoteSerializer
  • Database

Arrows:

  • Client sends requests to Auth, permissions, throttle
  • Auth, permissions, throttle sends requests to NoteViewSet.partial_update
  • NoteViewSet.partial_update sends requests to NoteSerializer
  • NoteSerializer sends requests to Database

Step 1 of 6. Ben sends PATCH /api/v2/notes/7/ with {"title": "Mine now", "owner": 2}. Note 7 is Ana’s.

Three versions of update_note. The tests slip in owner and id, send a blank title, and check the original isn’t touched.

Okay

Save every key

The idea: note.update(data), and return it.

A title change works.

def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]:
note.update(data) # every key the client sends is saved
return note, {}
Client sets owner
Yes
Bad values
Saved
Why
The client decides which fields change.

Where it breaks: {"owner": 2} gives the note away, and {"id": 1} points it at another row. This is mass assignment.

Better

Only writable fields

The idea: Copy just title and body from the data, if sent.

The waste in Okay: it let the client pick the fields.

Highlighted lines are new in this rung.

WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]:
for field in WRITABLE:
if field in data:
note[field] = data[field] # only fields clients may set, but no checks
return note, {}
Client sets owner
Nowas Yes
Bad values
Saved
Why
The server now owns the list of fields that can change.
What changed from the previous rung
WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]:
note.update(data) # every key the client sends is saved
for field in WRITABLE:
if field in data:
note[field] = data[field] # only fields clients may set, but no checks
return note, {}

Where it breaks: a blank title, or a body that is a number, is saved. And it changes the note it was given, so a failed save leaves it half changed.

Best

Check every field, then build a new note

The idea: Collect changes and errors for the writable fields; if no errors, return a new dict with the changes, else the note as it was.

The waste in Better: it trusted the values of the fields it allowed.

Highlighted lines are new in this rung.

WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]:
errors: dict[str, list[str]] = {}
changes: dict = {}
if "title" in data:
title = data["title"].strip() if isinstance(data["title"], str) else ""
if not title:
errors["title"] = ["This field may not be blank."]
elif len(title) > 200:
errors["title"] = ["Ensure this field has no more than 200 characters."]
changes["title"] = title
if "body" in data:
if isinstance(data["body"], str):
changes["body"] = data["body"]
else:
errors["body"] = ["Not a valid string."]
if errors:
return note, errors # nothing changes unless everything is valid
return {**note, **changes}, {} # read-only fields like id and owner are ignored
Client sets owner
No
Bad values
Refused, with errorswas Saved
Why
All or nothing, like is_valid() before save().
What changed from the previous rung
WRITABLE = ("title", "body")
def update_note(note: dict, data: dict) -> tuple[dict, dict[str, list[str]]]:
for field in WRITABLE:
if field in data:
note[field] = data[field] # only fields clients may set, but no checks
return note, {}
errors: dict[str, list[str]] = {}
changes: dict = {}
if "title" in data:
title = data["title"].strip() if isinstance(data["title"], str) else ""
if not title:
errors["title"] = ["This field may not be blank."]
elif len(title) > 200:
errors["title"] = ["Ensure this field has no more than 200 characters."]
changes["title"] = title
if "body" in data:
if isinstance(data["body"], str):
changes["body"] = data["body"]
else:
errors["body"] = ["Not a valid string."]
if errors:
return note, errors # nothing changes unless everything is valid
return {**note, **changes}, {} # read-only fields like id and owner are ignored

When Best isn’t best: write the ModelSerializer instead. It reads max_length and blank from the model, trims CharFields, drops read-only and unknown fields, and gives these same error messages.

Write update_note(note, data) from the task. Press Run tests to check your code.

  • Look only at title and body in data.
  • Title: trim; blank means ["This field may not be blank."]; over 200 means ["Ensure this field has no more than 200 characters."]. Body: not text means ["Not a valid string."].
  • With errors, return (note, errors) unchanged. Without, return a new dict: {**note, **changes}.

A partial update, like a serializer

Ctrl + Enter runs

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

  1. Not run yet: update_note({'id': 7, 'owner': 1, 'title': 'Tea', 'body': ''}, {'title': 'Milk'}) → [{'id': 7, 'owner': 1, 'title': 'Milk', 'body': ''}, {}]
  2. Not run yet: An owner slipped into the JSON → [{'id': 7, 'owner': 1, 'title': 'Mine now', 'body': ''}, {}]
  3. Not run yet: A blank title → [{'id': 7, 'owner': 1, 'title': 'Tea', 'body': ''}, {'title': ['This field may not be blank.']}]
  4. Not run yet: The note passed in is not changed → 'Tea'
  5. Not run yet: Extra test
  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
WayClient sets ownerBad valuesGoodNot so goodUse it when
Okay: save every keyYesSavedOne lineMass assignmentNever on user input
Better: writable fieldsNoSavedStops the worst holeNo checks; changes in placeData you already trust
Best: check, then buildNoRefused, with errorsAll or nothingYou maintain the rules by handNever by hand with DRF: use a serializer
  • queryset = Note.objects.all() on a viewset. Every user can list and open every note. Override get_queryset() to filter by the user.
  • Owner from the JSON. Set it in perform_create() from request.user; never make it a writable field.
  • A serializer in a loop that reads relations. Nested serializers send one query per row. Use select_related and prefetch_related in get_queryset().
  • No pagination. A list of 100,000 notes in one response. Set DEFAULT_PAGINATION_CLASS and PAGE_SIZE.
  • Throttling as security. It slows abuse, but the default cache is per process. Use Redis for throttles that hold across servers.

Questions to ask first

  • Who calls the API: our own front end, mobile apps, or outside developers?
  • Session cookies or tokens?
  • Which fields may each kind of user see and change?

A 60-second walkthrough

“In DRF, a ModelSerializer defines the JSON: which fields show and which a client may set. Read-only fields like id and owner can’t be written, which stops mass assignment. is_valid checks everything at once, and save writes. A ModelViewSet gives the CRUD actions; I override get_queryset to filter by request.user, and perform_create to set the owner. A router builds the URLs. Then permissions decide who may do what, throttling returns 429 with Retry-After, and pagination adds count, next and previous. Most of that goes in REST_FRAMEWORK settings, so every view is safe by default.”

Likely follow-ups

  • has_permission vs has_object_permission? The first runs for every request; the second runs when get_object() loads one row, so list views don’t call it.
  • Why does a viewset return 404, not 403, for someone else’s note? The note isn’t in that user’s queryset, so it doesn’t exist for them. That also hides which ids exist.
  • How do you version a DRF API? A URL prefix like /api/v2/, or DRF’s versioning classes. Never change a field’s meaning in place.
  • Serializer validate_<field> vs validate? The first checks one field; validate(self, data) checks fields together, like start before end.

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

Write allowed(user, method, note), like a DRF permission. Nobody logged in (None): no. Safe methods (GET, HEAD, OPTIONS, any case): yes if the note is shared, or the user owns it. Other methods: only the owner.

Example: Ben may GET Ana’s shared note, but not PATCH it.

Hint 1 of 3

Check for no user first.

Hint 2 of 3

method.upper() in SAFE_METHODS separates reading from changing.

Hint 3 of 3

Reading needs shared or owner; changing needs owner.

Owner, or read only

Ctrl + Enter runs

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

  1. Not run yet: Ben reads Ana’s shared note → True
  2. Not run yet: Ben edits Ana’s shared note → False
  3. Not run yet: Ben reads Ana’s private note → False
  4. Not run yet: Ana deletes her own note → True
  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

Logged in

The idea: Allow anyone who is logged in.

Visitors are kept out.

def allowed(user: dict | None, method: str, note: dict) -> bool:
return user is not None
Ben edits Ana’s note
Allowed
Ben reads a shared note
Allowed
Why
It is IsAuthenticated, and nothing more.

Where it breaks: any user can change or delete anyone’s note.

Better

Owner only

The idea: Allow only the owner, for every method.

The waste in Okay: it never looked at the note.

Highlighted lines are new in this rung.

def allowed(user: dict | None, method: str, note: dict) -> bool:
return user is not None and note["owner"] == user["id"] # owners only, even to read
Ben edits Ana’s note
Refusedwas Allowed
Ben reads a shared note
Refusedwas Allowed
Why
Nobody touches a note they don’t own.
What changed from the previous rung
def allowed(user: dict | None, method: str, note: dict) -> bool:
return user is not None
return user is not None and note["owner"] == user["id"] # owners only, even to read

Where it breaks: sharing stops working: Ben can’t read a note Ana shared with him.

Best

Read if shared or owned; change if owned

The idea: Split on safe methods: reading needs shared or owner, changing needs owner.

The waste in Better: it treated a look the same as a change.

Highlighted lines are new in this rung.

SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
def allowed(user: dict | None, method: str, note: dict) -> bool:
if user is None:
return False # IsAuthenticated
if method.upper() in SAFE_METHODS:
return note["shared"] or note["owner"] == user["id"] # reading a shared note is fine
return note["owner"] == user["id"] # only the owner may change it
Ben edits Ana’s note
Refused
Ben reads a shared note
Allowedwas Refused
Why
Reading and changing are different questions, with different answers.
What changed from the previous rung
SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
def allowed(user: dict | None, method: str, note: dict) -> bool:
return user is not None and note["owner"] == user["id"] # owners only, even to read
if user is None:
return False # IsAuthenticated
if method.upper() in SAFE_METHODS:
return note["shared"] or note["owner"] == user["id"] # reading a shared note is fine
return note["owner"] == user["id"] # only the owner may change it

When Best isn’t best: in DRF this is has_object_permission(self, request, view, obj), checking request.method in permissions.SAFE_METHODS. List views don’t call it, so filter the queryset too.

Write throttle(history, now, rate). history holds the times, in seconds, of this user’s earlier requests, in any order. rate is like "3/min", with the period s, sec, m, min, h, hour, d or day. Allow the request if fewer than the limit fell in the last period. Return (allowed, wait), where wait is how many seconds until the oldest request in the window drops out, or 0.0 when allowed.

Example: requests at 100, 110 and 130, now=140, "3/min" gives (False, 20.0).

Hint 1 of 3

A dict from period name to seconds handles all eight names.

Hint 2 of 3

Keep the times where now - t < seconds, sorted.

Hint 3 of 3

If blocked, the oldest kept time leaves the window at oldest + seconds; wait is that minus now.

A rate throttle

Ctrl + Enter runs

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

  1. Not run yet: Three in the last minute → [False, 20]
  2. Not run yet: The oldest just left the window → [True, 0]
  3. Not run yet: Three, long ago → [True, 0]
  4. Not run yet: Out of order, "3/m" → [False, 20]
  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

Count everything

The idea: Compare the number of requests ever with the limit.

It does stop a burst.

def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]:
limit = int(rate.split("/")[0])
return len(history) < limit, 0.0 # counts every request, ever
Old requests
Count for ever
Retry hint
None
Why
It has a limit, but no window.

Where it breaks: after three requests, a user is blocked for ever.

Better

A window, for minutes and hours

The idea: Count requests in the last 60 or 3,600 seconds.

The waste in Okay: it never forgot a request.

Highlighted lines are new in this rung.

def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]:
limit, period = rate.split("/")
seconds = 60 if period == "min" else 3600
recent = [t for t in history if now - t < seconds]
return len(recent) < int(limit), 0.0 # no hint for when to retry
Old requests
Drop outwas Count for ever
Retry hint
None
Why
Old requests leave the window, so users get back in.
What changed from the previous rung
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]:
limit = int(rate.split("/")[0])
return len(history) < limit, 0.0 # counts every request, ever
limit, period = rate.split("/")
seconds = 60 if period == "min" else 3600
recent = [t for t in history if now - t < seconds]
return len(recent) < int(limit), 0.0 # no hint for when to retry

Where it breaks: "3/day" is read as an hour, "3/m" isn’t known, and blocked clients get no Retry-After, so they retry at once, over and over.

Best

Every period, and a wait time

The idea: Map period names to seconds; count the window; if full, wait until the oldest request leaves it.

The waste in Better: it knew two periods, and told the client nothing.

Highlighted lines are new in this rung.

PERIODS = {"s": 1, "sec": 1, "m": 60, "min": 60, "h": 3600, "hour": 3600, "d": 86400, "day": 86400}
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]:
limit, period = rate.split("/")
seconds = PERIODS[period]
recent = sorted(t for t in history if now - t < seconds)
if len(recent) < int(limit):
return True, 0.0
wait = seconds - (now - recent[0]) # when the oldest request in the window drops out
return False, wait
Old requests
Drop out
Retry hint
Exact secondswas None
Why
This is DRF’s SimpleRateThrottle: a sliding window, and wait() for the Retry-After header.
What changed from the previous rung
PERIODS = {"s": 1, "sec": 1, "m": 60, "min": 60, "h": 3600, "hour": 3600, "d": 86400, "day": 86400}
def throttle(history: list[float], now: float, rate: str) -> tuple[bool, float]:
limit, period = rate.split("/")
seconds = 60 if period == "min" else 3600
recent = [t for t in history if now - t < seconds]
return len(recent) < int(limit), 0.0 # no hint for when to retry
seconds = PERIODS[period]
recent = sorted(t for t in history if now - t < seconds)
if len(recent) < int(limit):
return True, 0.0
wait = seconds - (now - recent[0]) # when the oldest request in the window drops out
return False, wait

When Best isn’t best: keeping every time costs memory per user. At scale, a fixed window counter or a token bucket in Redis is cheaper. In DRF, set DEFAULT_THROTTLE_RATES and a shared cache.

Write page_links(url, page, count, size), like DRF’s PageNumberPagination. Return count, next and previous. A link is None when there is no such page. Keep the URL’s other query parts, like ?search=tea. The link to page 1 has no page at all.

Example: page_links(URL + "?search=tea&page=2", 2, 45, 20) gives next ending ?search=tea&page=3 and previous ending ?search=tea.

Hint 1 of 3

Pages: -(-count // size), at least 1.

Hint 2 of 3

urlsplit, parse_qsl, urlencode and urlunsplit rebuild a URL with one query part changed.

Hint 3 of 3

Drop the old page, then add the new one only if it is above 1.

Next and previous links

Ctrl + Enter runs

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

  1. Not run yet: Page 1 of 3 → {'count': 45, 'next': 'http://api.example.com/api/v2/notes/?page=2', 'previous': None}
  2. Not run yet: Page 3 of 3 → {'count': 45, 'next': None, 'previous': 'http://api.example.com/api/v2/notes/?page=2'}
  3. Not run yet: Page 2: the link back has no page → {'count': 45, 'next': 'http://api.example.com/api/v2/notes/?page=3', 'previous': 'http://api.example.com/api/v2/notes/'}
  4. Not run yet: Page 2 of a search → {'count': 45, 'next': 'http://api.example.com/api/v2/notes/?search=tea&page=3', 'previous': 'http://api.example.com/api/v2/notes/?search=tea'}
  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
Better

None at the ends

The idea: Count the pages; give None before the first and after the last.

The waste in Okay: it linked to pages that don’t exist.

Highlighted lines are new in this rung.

def page_links(url: str, page: int, count: int, size: int) -> dict:
pages = max(1, -(-count // size))
return {
"count": count,
"next": f"{url}?page={page + 1}" if page < pages else None,
"previous": f"{url}?page={page - 1}" if page > 1 else None,
}
Ends
Nonewas ?page=0, ?page=4
?search=tea
Lost
Why
A client can loop until next is None.
What changed from the previous rung
def page_links(url: str, page: int, count: int, size: int) -> dict:
return {"count": count, "next": f"{url}?page={page + 1}", "previous": f"{url}?page={page - 1}"}
pages = max(1, -(-count // size))
return {
"count": count,
"next": f"{url}?page={page + 1}" if page < pages else None,
"previous": f"{url}?page={page - 1}" if page > 1 else None,
}

Where it breaks: it builds the link from the bare URL, so a search loses its filter on page 2, and url?page=2 becomes url?page=2?page=3.

Best

Rebuild the query

The idea: Parse the URL, swap only page (none for page 1), and put it back together.

The waste in Better: it treated the URL as text, not as parts.

Highlighted lines are new in this rung.

from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
def with_page(url: str, page: int) -> str:
parts = urlsplit(url)
query = [(k, v) for k, v in parse_qsl(parts.query) if k != "page"] # keep ?search=...
if page > 1:
query.append(("page", str(page))) # page 1 is the plain URL, as in DRF
return urlunsplit(parts._replace(query=urlencode(query)))
def page_links(url: str, page: int, count: int, size: int) -> dict:
pages = max(1, -(-count // size))
return {
"count": count,
"next": with_page(url, page + 1) if page < pages else None,
"previous": with_page(url, page - 1) if page > 1 else None,
}
Ends
None
?search=tea
Keptwas Lost
Why
Every other query part survives, and page 1 has one clean URL.
What changed from the previous rung
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
def with_page(url: str, page: int) -> str:
parts = urlsplit(url)
query = [(k, v) for k, v in parse_qsl(parts.query) if k != "page"] # keep ?search=...
if page > 1:
query.append(("page", str(page))) # page 1 is the plain URL, as in DRF
return urlunsplit(parts._replace(query=urlencode(query)))
def page_links(url: str, page: int, count: int, size: int) -> dict:
pages = max(1, -(-count // size))
return {
"count": count,
"next": f"{url}?page={page + 1}" if page < pages else None,
"previous": f"{url}?page={page - 1}" if page > 1 else None,
"next": with_page(url, page + 1) if page < pages else None,
"previous": with_page(url, page - 1) if page > 1 else None,
}

When Best isn’t best: set DEFAULT_PAGINATION_CLASS and DRF builds these from request.build_absolute_uri(). For big or fast-changing lists, CursorPagination avoids the repeats that page numbers show.

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

  1. Spot the bugWhat is wrong with this serializer for user input?
    class NoteSerializer(serializers.ModelSerializer):
        class Meta:
            model = Note
            fields = "__all__"
  2. Multiple choiceA ModelViewSet has queryset = Note.objects.all(). What is the risk?
  3. Multiple choiceA client sends too many requests. What does DRF’s throttling answer?
  4. Pick the patternUsers may read shared notes but only change their own. Where does that rule go in DRF?
  5. Fill in the answerFields clients may see but never set go in Meta.____

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

  • ModelSerializer: list fields by name; read_only_fields for id, timestamps; owner set in perform_create.
  • is_valid() checks all fields; errors by field; save() writes. validate_<field> for your own rules.
  • ModelViewSet + router = CRUD URLs. Override get_queryset() to filter by request.user.
  • Permissions (who), throttling (how often, 429 + Retry-After), pagination (which slice). Defaults in REST_FRAMEWORK.
  • Never fields = "__all__" on user input; never trust ids or owners from the JSON.

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