🐍 Backend with Python · Chapter 5
Designing a REST API people can use
An API is a promise you can't take back: the day a mobile app ships against your URLs, every odd name and wrong status code is yours to support for years.
Study this lesson free in the app →
The basics
A REST API exposes your data as resources — nouns with addresses like /v1/rides/42 — and lets clients act on them with the handful of HTTP methods they already know.
The problem it solves: without conventions every endpoint is a one-off (/getRideById, /cancelRideNow) that each client team must learn, guess at and break on, and that no cache, retry or monitoring tool understands.
The mental model: a railway parcel office. Every parcel has an address, and the counter does only a few things — accept, look up, update, return. Nobody opens a new counter per parcel. Your URLs are the addresses; the methods are the counter.
Words you must know 5
- Resource
- a thing with an identity: a ride, a driver, a payment. Plural noun in the URL:
/rides/42, never/getRide?id=42. - Status code
- the first thing every client reads. 201 created, 404 not there, 409 clashes with current state, 422 your input is wrong.
- Pagination
- a list never returns everything; it returns a page plus a cursor for the next one.
- Error envelope
- every error has the same JSON shape, so clients write one handler.
- Versioning
- a
/v1/prefix so you can change your mind without breaking apps already on lakhs of phones.
Explained
POST /api/getRideDetails {"id": 42}
200 OK {"success": false, "error": "not found"}The mobile client's retry logic sees 200 and doesn't retry. The CDN caches nothing because it's a POST. The dashboard counts it as a success. Three tools, three wrong answers — because the API said one thing in the status line and another in the body.
GET /v1/rides/42 → 404 tells all of them the truth with zero custom code. Good API design is mostly being boring on purpose.
The parcel office counter
At a railway parcel office in Vijayawada, every parcel gets a docket number. The counter clerk does exactly four things: accept a parcel and hand you a docket, look up a docket, update where it's going, hand it back.
A trader who has never been to this office still knows how to use it, because every parcel office works the same way. If the clerk invented a new form for "parcels going to Guntur on Tuesdays", every trader would need training.
And when a docket doesn't exist, the clerk says "no such docket" — he doesn't smile, say "done!", and hand you an empty box.
That is a REST API: addresses for things (dockets → URLs), a tiny fixed set of actions (methods), and honest answers (status codes).
Nouns, methods, codes
| You want to… | Request | Success | | --- | --- | --- | | list rides | GET /v1/rides?limit=20 | 200 + page | | book a ride | POST /v1/rides | 201 + Location | | read one | GET /v1/rides/42 | 200 / 404 | | change some fields | PATCH /v1/rides/42 | 200 | | replace it whole | PUT /v1/rides/42 | 200 | | delete it | DELETE /v1/rides/42 | 204 | | a driver's rides | GET /v1/drivers/7/rides | 200 |
Actions that aren't plain CRUD — cancel, refund, assign — become either a state change (PATCH with {"status": "cancelled"}) or a sub-resource you create (POST /v1/rides/42/cancellation). If cancelling isn't allowed in the current state (already completed), answer 409 Conflict, not 400.
Safe, idempotent and retryable methods were be-002's lesson; here they become naming rules: reads are GET, so they can be cached and retried for free.
Create and read, the boring way
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field
app = FastAPI()
RIDES: dict[int, dict] = {}
class RideIn(BaseModel):
rider_id: int
pickup: str = Field(min_length=1)
drop: str = Field(min_length=1)
class Ride(RideIn):
id: int
status: str = "requested"
@app.post("/v1/rides", status_code=201, response_model=Ride)
def create_ride(body: RideIn, response: Response):
ride_id = len(RIDES) + 1
ride = Ride(id=ride_id, **body.model_dump())
RIDES[ride_id] = ride.model_dump()
response.headers["Location"] = f"/v1/rides/{ride_id}"
return ride
@app.get("/v1/rides/{ride_id}", response_model=Ride)
def get_ride(ride_id: int):
if ride_id not in RIDES:
raise HTTPException(status_code=404, detail="ride not found")
return RIDES[ride_id]Run against TestClient:
POST /v1/rides {valid} → 201, Location: /v1/rides/1
GET /v1/rides/1 → 200
GET /v1/rides/99 → 404 {'detail': 'ride not found'}
POST /v1/rides {"pickup": ""} → 422 ['string_too_short', 'missing']
DELETE /v1/rides/1 → 405 (no DELETE route on that path)Follow one booking through the endpoint
Step 2 is the one to remember: validation is the framework's job, and a 422 means your handler never ran.
Which status code? Ask in this order
Every check before the last step is cheap and touches nothing. Fail as early as you can, with the most specific code you can.
The ride that appeared twice
The rides list is newest-first, paged with ?limit=3&offset=…. Rides 100–109 exist.
p1 = page_offset(limit=3, offset=0) # [109, 108, 107]
# a new ride, 110, is booked right now
p2 = page_offset(limit=3, offset=3)How APIs become painful
- Verbs in URLs —
/createRide,/getRides. The method is already the verb. - 200 with an error inside — breaks retries, caches, alerts. Use the real code.
- One error shape per endpoint —
{"detail": …}here,{"msg": …}there. Pick one envelope and install it as an exception handler. - Unbounded lists —
GET /ridesthat returns all 2 lakh rows works in dev with 12. Defaultlimit=20, cap it at 100. - PATCH that wipes fields —
body.model_dump()includes every field you didn't send asNone. Usemodel_dump(exclude_unset=True). - Leaking your table — returning the ORM row exposes
password_hashand internal flags. Always setresponse_model. - Breaking without a version — renaming
faretofare_paisein place breaks every installed app. Add the new field, keep the old one, retire it in/v2/.
Words to know
| Resource | A thing with an identity the client cares about — a ride, a driver — addressed by a URL like /v1/rides/42. |
|---|---|
| Collection | The plural URL (/v1/rides): GET lists it, POST adds to it. |
| Location header | Sent with 201 Created; the URL of the resource just made, so clients never build URLs themselves. |
| Cursor pagination | Asking for the next page relative to the last item seen (id < 107) instead of by position; stable under inserts. |
| Error envelope | One JSON shape for every error, e.g. {"error": {"code": 404, "message": "…"}}. |
| Breaking change | Any change that makes an existing client fail: removing or renaming a field, changing a type or a status code. |
| 409 Conflict | The request is valid but clashes with the resource's current state. |
In two minutes
- Model the domain as resources with plural noun URLs; let the HTTP method be the verb.
- Status codes are the contract: 201 + Location on create, 404 missing, 405 wrong method, 409 wrong state, 422 bad input.
- Non-CRUD actions become a state change (PATCH status) or a sub-resource you POST.
- Every list paginates: default limit, hard cap, and a cursor rather than an offset.
- PATCH applies only the fields the client sent — model_dump(exclude_unset=True).
- One error envelope for every failure, installed once as exception handlers.
- response_model decides what leaves the server; version with /v1/ and make only additive changes inside a version.
Test yourself
1. What should POST /v1/rides return when it successfully books a ride?
- 200 with the ride id in the body
- 204 with no body
- 201 with the ride and a Location header
- 202 Accepted
Show the answer
201 with the ride and a Location header — 201 Created says a new resource exists, and Location says where. 200 works but throws away information; 204 has no body to read the id from; 202 means 'queued, not done yet' — be-010 territory.
2. Why is GET /v1/rides/42 better than POST /getRide with {"id": 42}?
- GET is safe, so caches, browsers and retry logic can handle it with no custom code
- POST bodies are limited to 1 KB
- GET is encrypted and POST is not
- FastAPI can't read POST bodies
Show the answer
GET is safe, so caches, browsers and retry logic can handle it with no custom code — A read declared as GET is understood by every HTTP tool: it can be cached, prefetched and retried safely. Hiding a read behind POST forfeits all of that. Both are equally encrypted under HTTPS.
3. Why should a missing ride return 404 instead of 200 {"success": false}?
- 404 responses are smaller
- Browsers block 200 responses with errors
- FastAPI raises if you return success: false
- Clients, caches and monitoring read the status code, and 200 tells all of them it worked
Show the answer
Clients, caches and monitoring read the status code, and 200 tells all of them it worked — The status line is the part every tool understands. A 200 with an error body is counted as success by dashboards, cached by proxies and never retried — the information is in the one place nobody generic looks.
4. How should you expose 'cancel a ride' in a resource-oriented API?
- GET /v1/rides/42/cancel
- PATCH /v1/rides/42 with {"status": "cancelled"}, or POST /v1/rides/42/cancellation
- POST /v1/cancelRide?id=42
- DELETE /v1/rides — with the id in the body
Show the answer
PATCH /v1/rides/42 with {"status": "cancelled"}, or POST /v1/rides/42/cancellation — Cancel is a state change of an existing ride, or the creation of a cancellation record. A GET that changes state is dangerous — crawlers and prefetchers follow GETs. Verbs in URLs throw away the uniform interface.
5. What is the difference between PUT and PATCH on /v1/rides/42?
- PUT replaces the whole resource; PATCH changes only the fields sent
- PUT creates, PATCH deletes
- They are aliases in FastAPI
- PATCH must send every field; PUT may send some
Show the answer
PUT replaces the whole resource; PATCH changes only the fields sent — PUT says 'here is the full new state'; fields you leave out are gone. PATCH says 'change just these'. That's why a PATCH handler must use model_dump(exclude_unset=True).
6. What happens when an endpoint declares ride_id: int and a client calls GET /v1/rides/abc?
- The handler runs with ride_id = 0
- 500 Internal Server Error
- 422, before the handler runs, with error type int_parsing
- 404 Not Found
Show the answer
422, before the handler runs, with error type int_parsing — FastAPI validates path parameters against the type hint first. 'abc' fails int parsing, so the client gets 422 with the field location — your code never sees it. 404 is tempting, but the route matched; only the value was bad.
There are 9 questions, 2 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →