Build an API with Python and FastAPI
Build the Brew & Bean ordering API with Python and FastAPI. Pydantic models give you automatic validation and free interactive docs. Same four endpoints as the Express lesson, so you can compare the two.
What you will build: the Brew & Bean API with GET /menu, POST /orders, GET /orders/{id} and PATCH /orders/{id}/status. Orders are kept in memory for now; the *SQL and PostgreSQL* and *ORMs and migrations* lessons add a database.
Prerequisites: basic Python (functions, dicts, lists, classes) and Python 3.10 or newer (check with python --version). You should have read *HTTP in depth* and *Designing REST APIs*.
Why FastAPI? It uses Python type hints to validate input, converts data automatically and generates interactive API documentation. It is widely used for modern Python APIs and for AI backends.
1. Set up the project
- 1Create a folder and a virtual environment (a private set of packages for this project).
- 2Activate it.
- 3Install FastAPI with the standard extras, which include the
fastapicommand and the Uvicorn server.
mkdir brew-and-bean-fastapi
cd brew-and-bean-fastapi
python -m venv .venv
# Windows PowerShell:
.venv\Scripts\Activate.ps1
# macOS / Linux / Git Bash:
# source .venv/bin/activate (Git Bash on Windows: source .venv/Scripts/activate)
pip install "fastapi[standard]"If you use uv, you can instead run uv init and uv add "fastapi[standard]".
2. The smallest app
from fastapi import FastAPI
app = FastAPI(title="Brew & Bean API")
@app.get("/menu")
def get_menu():
return [{"id": "espresso", "name": "Espresso", "category": "coffee", "price": 120}]@app.get("/menu") is a decorator that registers the function as the handler for GET /menu. Whatever you return is converted to JSON.
fastapi dev main.pyfastapi dev runs the app with auto-reload, so saving the file restarts it.
FastAPI Starting development server...
...
Server started at http://127.0.0.1:8000
Documentation at http://127.0.0.1:8000/docsOpen http://127.0.0.1:8000/docs in your browser. This is Swagger UI, interactive documentation that FastAPI builds for you. You can click an endpoint, choose Try it out and send real requests. It will be handy for the rest of this lesson.
3. The complete app
Replace main.py with the full version below. The comments are in the explanation after it.
from datetime import datetime, timezone
from typing import Literal
from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel, Field
app = FastAPI(title="Brew & Bean API")
MENU = [
{"id": "espresso", "name": "Espresso", "category": "coffee", "price": 120},
{"id": "cappuccino", "name": "Cappuccino", "category": "coffee", "price": 180},
{"id": "cold-brew", "name": "Cold Brew", "category": "coffee", "price": 220},
{"id": "filter-coffee", "name": "Filter Coffee", "category": "coffee", "price": 80},
{"id": "masala-chai", "name": "Masala Chai", "category": "tea", "price": 90},
{"id": "croissant", "name": "Butter Croissant", "category": "bakery", "price": 150},
{"id": "banana-bread", "name": "Banana Bread", "category": "bakery", "price": 130},
{"id": "veg-sandwich", "name": "Veg Grilled Sandwich", "category": "food", "price": 160},
]
PRICES = {item["id"]: item["price"] for item in MENU}
NEXT_STATUS = {"received": "preparing", "preparing": "ready", "ready": "picked_up"}
orders: dict[int, dict] = {}
next_id = 1
class OrderLine(BaseModel):
item_id: str
qty: int = Field(ge=1, le=20)
class OrderIn(BaseModel):
customer_name: str = Field(min_length=1, max_length=80)
phone: str = Field(pattern=r"^\d{10}$")
items: list[OrderLine] = Field(min_length=1)
pickup_time: datetime | None = None
class StatusUpdate(BaseModel):
status: Literal["preparing", "ready", "picked_up", "cancelled"]
@app.get("/menu")
def get_menu(category: str | None = None):
if category:
return [item for item in MENU if item["category"] == category]
return MENU
@app.post("/orders", status_code=201)
def create_order(order_in: OrderIn, response: Response):
global next_id
total = 0
for line in order_in.items:
if line.item_id not in PRICES:
raise HTTPException(status_code=422, detail=f"Unknown item: {line.item_id}")
total += PRICES[line.item_id] * line.qty
order = {
"id": next_id,
"customer_name": order_in.customer_name,
"phone": order_in.phone,
"items": [line.model_dump() for line in order_in.items],
"total": total,
"status": "received",
"pickup_time": order_in.pickup_time,
"created_at": datetime.now(timezone.utc),
}
orders[next_id] = order
response.headers["Location"] = f"/orders/{next_id}"
next_id += 1
return order
@app.get("/orders/{order_id}")
def get_order(order_id: int):
order = orders.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return order
@app.patch("/orders/{order_id}/status")
def update_status(order_id: int, update: StatusUpdate):
order = orders.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
if update.status == "cancelled":
allowed = order["status"] == "received"
else:
allowed = NEXT_STATUS.get(order["status"]) == update.status
if not allowed:
raise HTTPException(
status_code=409,
detail=f"Cannot move from {order['status']} to {update.status}",
)
order["status"] = update.status
return orderThe data models (OrderIn, OrderLine, StatusUpdate) describe what the client may send. FastAPI validates each request against them before your function runs.
4. How it works
Key parts of the code
| Piece | What it does | Why |
|---|---|---|
class OrderIn(BaseModel) | Describes the JSON body of POST /orders | Pydantic checks types and rules and returns 422 with details if they fail |
Field(ge=1, le=20) | Quantity must be 1 to 20 | Rules live next to the data |
Field(pattern=r"^\d{10}$") | Phone must be exactly 10 digits | A regular expression check |
Literal[...] | Status must be one of the listed words | Rejects typos such as redy |
order_id: int in the path | Converts /orders/5 to the integer 5 | A non-number gives 422 automatically |
Optional category parameter (default None) | An optional query parameter | ?category=tea filters; leaving it out means no filter |
status_code=201 | Default success status for that route | Created, not 200 |
HTTPException | Stops and returns an error status | 404 and 409 here |
Notice what you did not write: no code to parse JSON, check that the phone is present or that qty is a number. Pydantic did it. You only wrote the business rules: unknown items are rejected, the total is computed from the server's own prices, and status changes follow NEXT_STATUS.
def or async def?
The function is declared with def, not async def. FastAPI runs normal def handlers in a thread pool, which is the right choice when you call blocking code such as most database drivers. Use async def only when everything you call inside is also async.
5. Test every endpoint
Use curl (in Windows PowerShell use curl.exe, or use Git Bash), or click through /docs.
curl -i -X POST http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"cappuccino","qty":1},{"item_id":"croissant","qty":2}]}'HTTP/1.1 201 Created
location: /orders/1
content-type: application/json
...
{"id":1,"customer_name":"Asha","phone":"9876543210","items":[{"item_id":"cappuccino","qty":1},{"item_id":"croissant","qty":2}],"total":480,"status":"received","pickup_time":null,"created_at":"2026-..."}curl http://127.0.0.1:8000/orders/1
curl -X PATCH http://127.0.0.1:8000/orders/1/status \
-H "Content-Type: application/json" \
-d '{"status":"preparing"}'
curl -X PATCH http://127.0.0.1:8000/orders/1/status \
-H "Content-Type: application/json" \
-d '{"status":"picked_up"}'The second PATCH returns 409 Cannot move from preparing to picked_up.
curl -X POST http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{"phone":"1","items":[{"item_id":"espresso","qty":0}]}'{"detail":[{"type":"missing","loc":["body","customer_name"],"msg":"Field required",...},{"type":"string_pattern_mismatch","loc":["body","phone"],...},{"type":"greater_than_equal","loc":["body","items",0,"qty"],"msg":"Input should be greater than or equal to 1",...}]}FastAPI's default error body is a list under detail, with loc showing where the problem is. It is already detailed. If you want the exact error format from the REST lesson, add an exception handler (see the *Validation and error handling* lesson).
Checklist: does it behave?
/docsloads and shows four endpoints.- A valid order returns 201 with total 480 for one cappuccino and two croissants.
- An unknown item id returns 422 with
Unknown item: .... GET /orders/999returns 404.GET /orders/abcreturns 422 because the id is not an integer.
6. Express or FastAPI?
| Topic | Express (Node.js) | FastAPI (Python) |
|---|---|---|
| Validation | You write it, or add a library | Built in through Pydantic models |
| Docs | Add a tool such as Swagger yourself | Generated automatically at /docs |
| Style | Very minimal and flexible | Type hints drive behaviour |
| Best fit | JavaScript teams, full-stack JS | Python teams, data and AI backends |
Both are widely used. Learn one properly; the concepts you have just practised (routing, validation, status codes, error shapes) are identical in the other.
I'm building a REST API with FastAPI and Pydantic v2. I get this error or this 422 response:
---
[paste the full error or the response JSON]
---
Here is my model and route:
---
[paste the code]
---
Explain what it means in plain English, point to the exact field or line causing it and give the smallest fix. Then give a curl command to confirm the fix.What to expect: A good answer reads the loc path in a 422 response, explains the failed rule and shows corrected model or request data.
Try it: Add a status filter
Add GET /orders that lists all orders with an optional status query parameter (for example ?status=ready). Use a Literal type for the parameter so a bad value gives 422.
Try it: Response model
Right now the response is a plain dict. Create a Pydantic model OrderOut with the order fields and use it as response_model=OrderOut on POST /orders and GET /orders/{order_id}. Check how /docs changes.
Try it: Opening hours
Reject orders with a pickup_time outside 8 am to 10 pm (a 422 with a clear message). Use a field_validator from Pydantic or a check inside the route.
Key takeaways
- FastAPI turns Python functions into endpoints with decorators such as
@app.getand@app.post. - Pydantic models validate request bodies automatically and return 422 with exact error locations.
- Type hints on path and query parameters convert and validate them (
order_id: int). fastapi dev main.pyruns the app with reload;/docsgives free interactive documentation.- Use
HTTPExceptionfor 404 and 409; compute totals from the server's own prices. - Use plain
defhandlers when calling blocking code;async defonly with async libraries.
Check yourself
Finishing the quiz marks the lesson as done.
1. What does FastAPI do when a POST body fails the Pydantic model's rules?
2. What does order_id: int in def get_order(order_id: int) give you?
3. Where can you try your endpoints in the browser without extra tools?
4. Why does the code compute total from PRICES instead of accepting it from the request?
5. When should you declare a handler with async def?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.