Backend Development
Hands-onBeginner 40 min· Foundations · 5 of 20

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

  1. 1Create a folder and a virtual environment (a private set of packages for this project).
  2. 2Activate it.
  3. 3Install FastAPI with the standard extras, which include the fastapi command and the Uvicorn server.
Terminal
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

main.py (first version)
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.

Terminal
fastapi dev main.py

fastapi dev runs the app with auto-reload, so saving the file restarts it.

What you should see (details vary)
FastAPI   Starting development server...
...
Server started at http://127.0.0.1:8000
Documentation at http://127.0.0.1:8000/docs

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

main.py (complete)
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 order

The 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

PieceWhat it doesWhy
class OrderIn(BaseModel)Describes the JSON body of POST /ordersPydantic checks types and rules and returns 422 with details if they fail
Field(ge=1, le=20)Quantity must be 1 to 20Rules live next to the data
Field(pattern=r"^\d{10}$")Phone must be exactly 10 digitsA regular expression check
Literal[...]Status must be one of the listed wordsRejects typos such as redy
order_id: int in the pathConverts /orders/5 to the integer 5A 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=201Default success status for that routeCreated, not 200
HTTPExceptionStops and returns an error status404 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.

Place an order
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}]}'
Expected
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-..."}
Read it, then move its status
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.

Send bad data
curl -X POST http://127.0.0.1:8000/orders \
  -H "Content-Type: application/json" \
  -d '{"phone":"1","items":[{"item_id":"espresso","qty":0}]}'
Expected (status 422)
{"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?

  1. /docs loads and shows four endpoints.
  2. A valid order returns 201 with total 480 for one cappuccino and two croissants.
  3. An unknown item id returns 422 with Unknown item: ....
  4. GET /orders/999 returns 404.
  5. GET /orders/abc returns 422 because the id is not an integer.

6. Express or FastAPI?

TopicExpress (Node.js)FastAPI (Python)
ValidationYou write it, or add a libraryBuilt in through Pydantic models
DocsAdd a tool such as Swagger yourselfGenerated automatically at /docs
StyleVery minimal and flexibleType hints drive behaviour
Best fitJavaScript teams, full-stack JSPython 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.

Prompt · Explain a FastAPI error
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

  1. FastAPI turns Python functions into endpoints with decorators such as @app.get and @app.post.
  2. Pydantic models validate request bodies automatically and return 422 with exact error locations.
  3. Type hints on path and query parameters convert and validate them (order_id: int).
  4. fastapi dev main.py runs the app with reload; /docs gives free interactive documentation.
  5. Use HTTPException for 404 and 409; compute totals from the server's own prices.
  6. Use plain def handlers when calling blocking code; async def only 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.

Start free