Spot the bug: Flask notes API
Ten short Flask endpoints, one bug each. Tap the line that breaks, then see the request that exposes it, the fix, and the one rule behind it.
Every card is part of a small notes API. store is an in-memory store with create, get, update and delete. It trusts whatever it is given, so checking the request is the endpoint's job.
Create a note
Should: POST /notes creates a note and answers 201 Created with the note.
Tap the line with the bug.
The request that exposes it
POST /notes {"text": "Buy milk"}
| Expected | 201 Created {"id": 1, "text": "Buy milk"} |
|---|---|
| Got | 200 OK {"id": 1, "text": "Buy milk"} |
Why
Returning just a dict makes Flask answer 200 OK. A request that created something should answer 201 Created, and tests check the status as closely as the body.
Fix
Something was created → return 201, not the default 200.
Read a note by id
Should: GET /notes/<id> returns the note, or 404 if there is none.
The store already holds note 1: “Buy milk”.
Tap the line with the bug.
The request that exposes it
GET /notes/1
| Expected | 200 OK {"id": 1, "text": "Buy milk"} |
|---|---|
| Got | 404 Not Found {"error": "not found"} |
Why
Without a converter, Flask passes note_id as the string "1". The store keeps notes under the integer 1, and "1" is a different key, so every note looks missing.
Fix
Parts of a URL arrive as strings: use <int:note_id> for numeric ids.
Reject blank text
Should: POST /notes answers 400 when text is missing, empty or only spaces.
Tap the line with the bug.
The request that exposes it
POST /notes {"text": " "}
| Expected | 400 Bad Request {"error": "text is required"} |
|---|---|
| Got | 201 Created {"id": 1, "text": " "} |
Why
.strip() always returns a string. For blank input it returns "", never None, so the check never fires. An empty string is falsy, so not text.strip() catches it.
Fix
.strip() never returns None: test for blank with not text.strip().
Found or not found
Should: GET /notes/<id> returns the note, or 404 if there is none.
The store already holds note 1: “Buy milk”.
Tap the line with the bug.
The request that exposes it
GET /notes/1
| Expected | 200 OK {"id": 1, "text": "Buy milk"} |
|---|---|
| Got | 500 Internal Server Error Flask's HTML error page |
Why
The commas win over if/else. Python builds a three-item tuple: (note, 200 if note else {...}, 404). Flask reads the third item as headers, gets the number 404, and crashes with a 500.
Fix
One return per outcome: don't squeeze two responses into one line.
Not a JSON object
Should: POST /notes answers 400 for any body that isn't a JSON object with text.
Tap the line with the bug.
The request that exposes it
POST /notes 42
| Expected | 400 Bad Request {"error": "text is required"} |
|---|---|
| Got | 500 Internal Server Error Flask's HTML error page |
Why
and checks left to right. "text" in data runs before isinstance(data, dict), and in doesn't work on a number, so the body 42 raises TypeError. Put the type check first and the rest only runs on a dict.
Fix
Check the type first: isinstance(data, dict) before in or .get().
Delete a note
Should: DELETE /notes/<id> answers 204, or 404 if there is no such note.
Tap the line with the bug.
The request that exposes it
DELETE /notes/42
| Expected | 404 Not Found {"error": "not found"} |
|---|---|
| Got | 500 Internal Server Error Flask's HTML error page |
Why
The store trusts its input: delete does del on a key that isn't there, which raises KeyError, and Flask turns that into a 500. The endpoint has to check the id first and answer 404 itself.
Fix
Check the id exists (404) before update or delete.
Edit a note
Should: PATCH /notes/<id> replaces the note's text with the text in the request.
The store already holds note 1: “Buy milk”.
Tap the line with the bug.
The request that exposes it
PATCH /notes/1 {"text": "Buy bread"}
| Expected | 200 OK {"id": 1, "text": "Buy bread"} |
|---|---|
| Got | 200 OK {"id": 1, "text": "Buy milk"} |
Why
The new text is taken from the note already in the store, not from the request. The request body is never read, so every edit saves the old text back and still answers 200.
Fix
Validate what the client sent, not what you already stored.
Body that isn't JSON
Should: POST /notes answers 400 {"error": "text is required"} for any bad body.
Tap the line with the bug.
The request that exposes it
POST /notes Buy milk (text/plain)
| Expected | 400 Bad Request {"error": "text is required"} |
|---|---|
| Got | 415 Unsupported Media Type Flask's HTML error page |
Why
Without silent=True, get_json() stops the request itself when the body isn't JSON: Flask answers with its own error page before your check runs. With silent=True it returns None and your 400 handles it.
Fix
Use get_json(silent=True), so bad bodies reach your own 400.
Where ids come from
Should: Every new note gets an id no other note has.
Tap the line with the bug.
Run it
s = Store()
s.create("Buy milk") # id 1
s.create("Call mum") # id 2
s.delete(1)
s.create("Pay rent")
| Expected | new note: {"id": 3, "text": "Pay rent"}notes left: ["Call mum", "Pay rent"] |
|---|---|
| Got | new note: {"id": 2, "text": "Pay rent"}notes left: ["Pay rent"] |
Why
After a delete, len() shrinks, so the next id repeats one that is still in use. Here "Pay rent" gets id 2 and silently overwrites "Call mum". A counter that only goes up never hands out the same id twice.
Fix
Ids come from a counter that only goes up, never from len().
Who picks the id
Should: POST /notes stores the text; the server picks the id.
Tap the line with the bug.
The request that exposes it
POST /notes {"text": "Buy milk", "id": 99}
| Expected | 201 Created {"id": 1, "text": "Buy milk"} |
|---|---|
| Got | 201 Created {"id": 99, "text": "Buy milk"} |
Why
**data copies every field the client sent, and when a key repeats, the last one wins. The client's "id": 99 overwrites the server's id, and any other field it adds gets stored too.
Fix
Copy only the fields you expect; never spread client JSON into a record.
Done
The 10 rules
- Something was created → return 201, not the default 200.
- Parts of a URL arrive as strings: use <int:note_id> for numeric ids.
- .strip() never returns None: test for blank with not text.strip().
- One return per outcome: don't squeeze two responses into one line.
- Check the type first: isinstance(data, dict) before in or .get().
- Check the id exists (404) before update or delete.
- Validate what the client sent, not what you already stored.
- Use get_json(silent=True), so bad bodies reach your own 400.
- Ids come from a counter that only goes up, never from len().
- Copy only the fields you expect; never spread client JSON into a record.
More in Divero School








