Your first FastAPI app

Path operations, type-driven validation, automatic OpenAPI docs, and the route-ordering trap that silently returns 422 instead of 404.

The smallest app

# main.py
from fastapi import FastAPI, HTTPException, Query

app = FastAPI(title="Notes API", version="1.0.0")

NOTES: dict[int, str] = {1: "buy milk", 2: "ship release"}

@app.get("/health")
def health() -> dict[str, bool]:
    return {"ok": True}

@app.get("/notes/{note_id}")
def get_note(note_id: int, q: str | None = Query(default=None, max_length=50)):
    if note_id not in NOTES:
        raise HTTPException(status_code=404, detail="note not found")
    body = NOTES[note_id]
    if q:
        body = body.replace(q, "[" + q + "]")
    return {"id": note_id, "body": body}
pip install "fastapi[standard]"
fastapi dev main.py            # reload on save, http://127.0.0.1:8000
uvicorn main:app --reload      # equivalent, explicit ASGI server

curl http://127.0.0.1:8000/notes/1
curl "http://127.0.0.1:8000/notes/1?q=milk"
  • Interactive docs at /docs (Swagger UI), /redoc (ReDoc), schema at /openapi.json.
  • Path parameters are named in the decorator and typed in the signature — the type drives parsing and validation.
  • Query parameters are any function argument that is not in the path and not a body model.
  • Returning a dict produces JSON; the status code defaults to 200 unless declared.

Path operations and ordering

from fastapi import Body, status

# static routes MUST be declared before the dynamic one
@app.get("/notes/latest")
def latest_note():
    return {"id": max(NOTES, default=0)}

@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create_note(body: str = Body(embed=True)):
    new_id = max(NOTES, default=0) + 1
    NOTES[new_id] = body
    return {"id": new_id, "body": body}

@app.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_note(note_id: int) -> None:
    NOTES.pop(note_id, None)
DeclarationBecomesExample
/{note_id} with note_id: intRequired path param/notes/7
q: str | None = NoneOptional query param?q=text
limit: int = Query(20, ge=1)Query param with validation?limit=5
body: NoteIn (Pydantic model)JSON request bodyPOST/PUT payload
Header(...), Cookie(...)Header / cookieX-Token: abc
⚠️
Route order matters. Because /notes/{note_id} is declared first, a request to /notes/latest matches it, fails to parse latest as an int, and returns 422 — not a 404 and not your static handler. Declare fixed paths before parameterised ones.

Errors and responses

from fastapi import Request
from fastapi.responses import JSONResponse

class NoteMissing(Exception):
    def __init__(self, note_id: int):
        self.note_id = note_id

@app.exception_handler(NoteMissing)
def note_missing_handler(request: Request, exc: NoteMissing):
    return JSONResponse(status_code=404, content={"detail": f"note {exc.note_id} not found"})

@app.get("/status")
def app_status():
    return JSONResponse(
        status_code=200,
        content={"ok": True},
        headers={"Cache-Control": "no-store"},
    )
  • Validation failures return 422 with a machine-readable detail array: field, location and message.
  • HTTPException is for one-off errors inside a handler; an exception handler is for a domain error raised from anywhere.
  • Set the success code on the decorator (status_code=) rather than mutating the response after the fact.

FAQ

Why does FastAPI return 422 instead of 400?
422 Unprocessable Entity is the default for request-validation errors. If your clients expect 400 you can override it with a RequestValidationError exception handler.
Do I need uvicorn if I use fastapi dev?
No — fastapi dev runs uvicorn for you with reload enabled. In production run the ASGI server yourself (uvicorn or gunicorn with uvicorn workers) so you control workers, timeouts and logging.

Pydantic request and response models Your first Flask app

Last refreshed 2026-09-18.