FastAPI Mastery
Topic 4 of 22 — FastAPI Core
Overall progress
18.2%
Topic 4 · Section 4.1

Application Creation

Creating a FastAPI app is one line of code — but those options matter. Learn what FastAPI() does under the hood and how to configure your application with metadata.

4.1.1

FastAPI()

🚀
What is FastAPI()?

FastAPI() creates the central application object. It's a class that inherits from Starlette (the ASGI framework underneath FastAPI) and adds automatic validation, serialization, and docs generation on top.

🏗 Real-world analogy
FastAPI() is like opening a restaurant. The moment you open it, you get a building (ASGI server), a front-desk (request routing), a menu board (OpenAPI docs), and a kitchen (your endpoint functions). Everything is set up by that one call.
python — minimal FastAPI app
from fastapi import FastAPI

# Create the app — this is the ASGI application
app = FastAPI()

# Define an endpoint (route)
@app.get("/")
async def root():
    return {"message": "Hello World"}

# Run it:  uvicorn main:app --reload
# Visit:   http://localhost:8000        → {"message": "Hello World"}
# Docs:    http://localhost:8000/docs   → Swagger UI (FREE!)
# Redoc:   http://localhost:8000/redoc  → ReDoc UI (also FREE!)
💡
FastAPI generates interactive API documentation automatically from your code. No extra work needed — just define your endpoints and types.
📋
Metadata — Title, Version, Description

Pass metadata to FastAPI() to customize your API's documentation page. All of these show up in the auto-generated Swagger UI and OpenAPI schema.

python — FastAPI with metadata
from fastapi import FastAPI

app = FastAPI(
    # Shown at top of Swagger UI
    title="My Awesome API",

    # Shown below the title — supports Markdown!
    description="""
## My Awesome API

This API does **amazing things**.

### Features
- 🚀 Fast
- 🔒 Secure
- 📖 Auto-documented
    """,

    # Shown in the top-right of Swagger UI
    version="1.0.0",

    # Contact info for your API consumers
    contact={
        "name": "Alice Dev",
        "email": "alice@example.com",
        "url": "https://example.com",
    },

    # License info
    license_info={
        "name": "MIT",
        "url": "https://opensource.org/licenses/MIT",
    },

    # Terms of service URL
    terms_of_service="https://example.com/terms",

    # Custom docs URL (default: /docs)
    docs_url="/api/docs",

    # Custom redoc URL (default: /redoc)
    redoc_url="/api/redoc",

    # Custom OpenAPI schema URL (default: /openapi.json)
    openapi_url="/api/openapi.json",

    # Disable docs entirely in production:
    # docs_url=None, redoc_url=None
)

@app.get("/")
async def root():
    return {"status": "ok"}
titleAPI name shown at the top of Swagger UI
descriptionMarkdown-supported description below the title
versionAPI version string (e.g. "2.1.0")
docs_urlURL for Swagger UI. Set to None to disable.
redoc_urlURL for ReDoc UI. Set to None to disable.
openapi_urlURL for raw OpenAPI JSON schema
⚠️
In production, it's good practice to disable the docs endpoints (docs_url=None, redoc_url=None) so external users can't explore your API schema.
Topic 4 · Section 4.2

Lifespan

Run code on startup (load ML models, open DB connections) and on shutdown (close connections, flush buffers) — cleanly and reliably.

4.2.1

Startup & Shutdown

🌅
The Lifespan Context Manager

The modern way to handle startup and shutdown in FastAPI is the lifespan context manager (FastAPI 0.93+). Everything before yield = startup. Everything after = shutdown.

🏬 Real-world analogy
Lifespan is like a restaurant's opening and closing routine. Before opening (startup): turn on the grills, stock the fridge, brief the staff. After closing (shutdown): turn off the grills, clean up, lock the doors.
python — lifespan context manager (modern approach)
from contextlib import asynccontextmanager
from fastapi import FastAPI

# Simulating a database connection pool
db_pool = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # ── STARTUP ── (runs before first request)
    print("🚀 Starting up...")
    db_pool["conn"] = await create_db_connection()
    print("✅ Database connected!")

    yield  # ← app runs here, handling requests

    # ── SHUTDOWN ── (runs after last request)
    print("🛑 Shutting down...")
    await db_pool["conn"].close()
    print("✅ Database connection closed.")


# Pass lifespan to the app
app = FastAPI(lifespan=lifespan)

@app.get("/")
async def root():
    conn = db_pool["conn"]  # available everywhere!
    return {"status": "ready"}

async def create_db_connection():
    return {"host": "localhost"}
🗄️
Resource Initialization

Common things to initialize on startup and clean up on shutdown:

python — real-world lifespan example
from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx

# Global state shared across requests
state = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    # ── STARTUP ──
    # 1. Load an ML model (expensive — do it once!)
    state["model"] = load_model("model.pkl")

    # 2. Create a shared HTTP client (reuses connections)
    state["http_client"] = httpx.AsyncClient()

    # 3. Connect to Redis cache
    state["cache"] = await connect_redis("redis://localhost")

    # 4. Warm up the cache
    await state["cache"].ping()
    print("✅ All resources initialized")

    yield  # ← requests are handled here

    # ── SHUTDOWN ──
    await state["http_client"].aclose()
    await state["cache"].close()
    print("✅ All resources cleaned up")

app = FastAPI(lifespan=lifespan)

@app.get("/predict")
async def predict(text: str):
    model = state["model"]        # model loaded at startup ✓
    result = model.predict(text)
    return {"prediction": result}
ℹ️
Old way (deprecated): FastAPI used to have @app.on_event("startup") and @app.on_event("shutdown") decorators. They still work but the lifespan approach is now preferred as it's cleaner and more Pythonic.
🎮
Interactive: Lifespan Simulation
App Lifecycle
→ Click "Start Server" to begin...
Topic 4 · Section 4.3

Routing

Define routes for each HTTP method, configure them with tags and prefixes, and organize large APIs using APIRouter for clean modular code.

4.3.1

HTTP Methods

🛣️
All HTTP Methods

FastAPI has decorators for every standard HTTP method. Choose the right one based on what your endpoint does:

DecoratorMethodPurposeHas Body?
@app.getGETRead/retrieve dataNo
@app.postPOSTCreate a new resourceYes
@app.putPUTReplace a resource entirelyYes
@app.patchPATCHUpdate part of a resourceYes
@app.deleteDELETERemove a resourceUsually no
@app.headHEADLike GET but no response bodyNo
@app.optionsOPTIONSWhat methods are allowed (CORS)No
python — all HTTP methods in action
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str
    price: float

# GET   — retrieve a list
@app.get("/items")
async def list_items():
    return [{"id": 1, "name": "Widget"}]

# GET   — retrieve one
@app.get("/items/{item_id}")
async def get_item(item_id: int):
    return {"id": item_id, "name": "Widget"}

# POST  — create
@app.post("/items", status_code=201)
async def create_item(item: Item):
    return {"id": 42, **item.model_dump()}

# PUT   — replace
@app.put("/items/{item_id}")
async def replace_item(item_id: int, item: Item):
    return {"id": item_id, **item.model_dump()}

# PATCH — partial update
@app.patch("/items/{item_id}")
async def update_item(item_id: int, item: Item):
    return {"id": item_id, "updated": item.model_dump()}

# DELETE
@app.delete("/items/{item_id}", status_code=204)
async def delete_item(item_id: int):
    return None  # 204 No Content
4.3.2

Route Configuration

⚙️
Prefixes, Tags & Names

Route decorators accept several configuration options that affect how the route appears in docs and how it behaves:

python — route configuration options
@app.get(
    "/items/{item_id}",

    # Groups this endpoint in Swagger UI under "Items"
    tags=["items"],

    # Summary shown in the endpoint list
    summary="Get a single item by ID",

    # Longer description (supports Markdown)
    description="Returns the item with the given ID. Raises 404 if not found.",

    # HTTP status code for the "success" response (default: 200)
    status_code=200,

    # Unique name for reverse URL generation
    name="get_item",

    # Mark as deprecated in docs (shown with strikethrough)
    deprecated=False,

    # Hide from docs entirely
    include_in_schema=True,
)
async def get_item(item_id: int):
    return {"id": item_id}

# You can also use docstrings as the description:
@app.get("/users", tags=["users"])
async def list_users():
    """
    List all active users.

    Returns a paginated list of users sorted by creation date.
    """
    return []
4.3.3

APIRouter — Modular Routing

📦
Why APIRouter?

Defining all routes on app directly doesn't scale. APIRouter lets you split routes into separate files and include them in the main app — like Express Router or Django's urlconf.

🗂 Real-world analogy
APIRouter is like dividing a large cookbook into chapters. The main app is the book. Each router is a chapter (Users, Items, Orders). You can pick and choose which chapters to include, and each chapter has its own prefix.
python — routers/users.py (separate file)
# routers/users.py
from fastapi import APIRouter

# Create a router (mini-app)
router = APIRouter(
    prefix="/users",          # all routes start with /users
    tags=["users"],           # group in Swagger UI under "users"
    responses={404: {"description": "Not found"}},
)

@router.get("/")             # → GET /users/
async def list_users():
    return [{"id": 1, "name": "Alice"}]

@router.get("/{user_id}")    # → GET /users/{user_id}
async def get_user(user_id: int):
    return {"id": user_id, "name": "Alice"}

@router.post("/")            # → POST /users/
async def create_user():
    return {"id": 99}
python — main.py (include routers)
# main.py
from fastapi import FastAPI
from routers import users, items, orders

app = FastAPI()

# Include each router — optionally override their prefix/tags
app.include_router(users.router)
app.include_router(items.router)
app.include_router(
    orders.router,
    prefix="/api/v1",   # additional prefix: /api/v1/orders/...
    tags=["orders"],
)

# Result:
# GET  /users/         ← from users router
# GET  /users/{id}     ← from users router
# POST /users/         ← from users router
# GET  /items/         ← from items router
# GET  /api/v1/orders/ ← from orders router (extra prefix)
🏗️
Nested Routers

Routers can include other routers — useful for versioned APIs or deeply nested resources:

python — nested routers (versioned API)
# api/v1/router.py
from fastapi import APIRouter
from api.v1 import users, items

v1_router = APIRouter(prefix="/v1")
v1_router.include_router(users.router)  # → /v1/users/
v1_router.include_router(items.router)  # → /v1/items/

# api/v2/router.py
v2_router = APIRouter(prefix="/v2")
v2_router.include_router(users.router)  # → /v2/users/

# main.py
app = FastAPI()
app.include_router(v1_router, prefix="/api")  # → /api/v1/users/
app.include_router(v2_router, prefix="/api")  # → /api/v2/users/
💡
Recommended project structure:
my_api/
├── main.py
├── routers/
│   ├── users.py
│   ├── items.py
│   └── orders.py
├── models/
├── services/
└── database.py
🔨
Interactive: Route Builder

Build a route definition interactively:

Route Builder
Method Path
Tags Status
Summary
Topic 4 · Section 4.4

Request Handling

Extract data from path parameters, query strings, headers, and cookies — with automatic type validation and conversion.

4.4.1

Path Parameters

🔑
Basic Path Parameters

Path parameters are variable parts of the URL path wrapped in {curly_braces}. FastAPI automatically extracts and validates them.

python — path parameters
from fastapi import FastAPI, Path

app = FastAPI()

# Basic: type annotation = automatic validation + conversion
@app.get("/users/{user_id}")
async def get_user(user_id: int):
    # FastAPI auto-converts "42" (string) to 42 (int)
    # GET /users/42   → user_id = 42  ✓
    # GET /users/abc  → 422 Validation Error ✗ (not an int!)
    return {"user_id": user_id}

# String path param
@app.get("/files/{filename}")
async def get_file(filename: str):
    return {"file": filename}

# Multiple path params
@app.get("/users/{user_id}/posts/{post_id}")
async def get_post(user_id: int, post_id: int):
    return {"user_id": user_id, "post_id": post_id}
Validation & Constraints

Use Path() to add constraints like min/max values and regex patterns:

python — Path() with constraints
from fastapi import FastAPI, Path
from typing import Annotated

app = FastAPI()

@app.get("/users/{user_id}")
async def get_user(
    user_id: Annotated[int, Path(
        title="User ID",          # shown in docs
        description="The ID of the user",
        ge=1,                     # ge = greater than or equal to 1
        le=999999,               # le = less than or equal to
        example=42,              # example value in docs
    )]
):
    # GET /users/0   → 422 (fails ge=1)
    # GET /users/42  → ✓
    # GET /users/abc → 422 (not an int)
    return {"user_id": user_id}

# Regex constraint
@app.get("/items/{item_code}")
async def get_item(
    item_code: Annotated[str, Path(
        pattern=r"^[A-Z]{2}-\d{4}$",  # e.g. "AB-1234"
        example="AB-1234",
    )]
):
    # GET /items/AB-1234  → ✓
    # GET /items/abc      → 422 (doesn't match pattern)
    return {"code": item_code}
ℹ️
Constraint shortcuts: ge (≥), gt (>), le (≤), lt (<), min_length, max_length, pattern (regex).
4.4.2

Query Parameters

Query Parameters

Query parameters appear after the ? in the URL. Any function parameter that's not in the path is automatically treated as a query parameter.

python — query parameters
from fastapi import FastAPI, Query
from typing import Annotated, Optional

app = FastAPI()

# Required query param (no default)
@app.get("/search")
async def search(q: str):
    # GET /search?q=python  → q="python"
    # GET /search           → 422 (q is required!)
    return {"query": q}

# Optional query param (has default)
@app.get("/items")
async def list_items(
    page: int = 1,
    limit: int = 10,
    active: Optional[bool] = None,   # truly optional
):
    # GET /items                        → page=1, limit=10, active=None
    # GET /items?page=2&limit=5         → page=2, limit=5
    # GET /items?active=true            → active=True (auto bool!)
    return {"page": page, "limit": limit, "active": active}

# List query param (repeated key)
@app.get("/filter")
async def filter_items(
    tags: Annotated[list[str], Query()] = []
):
    # GET /filter?tags=python&tags=web&tags=api
    # → tags=["python", "web", "api"]
    return {"tags": tags}

# Query with validation
@app.get("/users")
async def list_users(
    q: Annotated[Optional[str], Query(
        min_length=3,
        max_length=50,
        description="Search query string",
    )] = None
):
    return {"q": q}
💡
FastAPI automatically converts boolean query params: ?active=true, ?active=1, ?active=yes → all become Python True.
4.4.3

Headers

📩
Reading Headers

Use Header() to read HTTP headers. FastAPI automatically converts User-Agentuser_agent (hyphen to underscore).

python — reading headers
from fastapi import FastAPI, Header
from typing import Annotated, Optional

app = FastAPI()

@app.get("/info")
async def get_info(
    # "User-Agent" header → user_agent param
    user_agent: Annotated[Optional[str], Header()] = None,

    # "X-Request-ID" header → x_request_id param
    x_request_id: Annotated[Optional[str], Header()] = None,
):
    return {"user_agent": user_agent, "request_id": x_request_id}

# Authorization header (most common use case)
@app.get("/secure")
async def secure_endpoint(
    authorization: Annotated[str, Header(
        description="Bearer token: 'Bearer your-token-here'"
    )]
):
    # Reads the "Authorization" header
    # Value: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
    token = authorization.replace("Bearer ", "")
    return {"token_preview": token[:10] + "..."}

# List header (same header sent multiple times)
@app.get("/multi-header")
async def multi(
    x_token: Annotated[Optional[list[str]], Header()] = None
):
    return {"x_tokens": x_token}
4.4.4

Cookies

🍪
Reading & Setting Cookies

Use Cookie() to read cookies from incoming requests and Response to set cookies in the response.

python — reading cookies
from fastapi import FastAPI, Cookie, Response
from typing import Annotated, Optional

app = FastAPI()

# Read a cookie
@app.get("/me")
async def get_current_user(
    session_id: Annotated[Optional[str], Cookie()] = None
):
    # Reads the "session_id" cookie from the request
    if not session_id:
        return {"user": "anonymous"}
    return {"session_id": session_id}

# Set a cookie in the response
@app.post("/login")
async def login(response: Response):
    # Inject Response to set cookies
    response.set_cookie(
        key="session_id",
        value="abc123xyz",
        max_age=3600,        # expire in 1 hour
        httponly=True,       # JS can't access it
        secure=True,         # HTTPS only
        samesite="lax",      # CSRF protection
    )
    return {"message": "Logged in!"}

# Delete a cookie (logout)
@app.post("/logout")
async def logout(response: Response):
    response.delete_cookie("session_id")
    return {"message": "Logged out!"}
🔬
Interactive: Request Inspector

Click a URL to see how FastAPI parses the path, query, and header parameters:

parsed parameters

          
🧠
Section Quiz
Q1: In FastAPI, how does it know a function parameter is a query parameter vs a path parameter?
A) You must annotate it with Query() explicitly
B) If it's NOT in the path pattern {}, it's treated as a query parameter automatically
C) You have to use a @query_param decorator
D) By its position in the function signature
Q2: What happens if you send GET /users/abc when the route expects user_id: int?
A) user_id becomes None
B) FastAPI returns 500 Internal Server Error
C) FastAPI automatically returns 422 Unprocessable Entity
D) It raises a Python ValueError
Topic 4 · Section 4.5

Request Bodies

Handle JSON bodies, HTML forms, multipart form data, and file uploads — all with automatic validation.

4.5.1

JSON Bodies

📨
Parsing JSON with Pydantic

Declare a Pydantic model as a parameter and FastAPI automatically reads the request body as JSON, validates it, and passes the typed object to your function.

python — JSON request body
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class CreateUserRequest(BaseModel):
    username: str
    email: str
    age: Optional[int] = None

@app.post("/users")
async def create_user(user: CreateUserRequest):
    # FastAPI automatically:
    # 1. Reads request body as JSON
    # 2. Validates against CreateUserRequest
    # 3. Returns 422 if validation fails
    # 4. Passes typed Python object to your function

    print(user.username)   # "alice"  ← typed!
    print(user.email)      # "alice@example.com"
    print(user.age)        # None or int
    return {"created": user.model_dump()}

# Mixing path + query + body:
# All 3 can coexist in one endpoint!
@app.put("/users/{user_id}")
async def update_user(
    user_id: int,              # ← path param
    notify: bool = False,     # ← query param
    user: CreateUserRequest = ...,  # ← body
):
    return {
        "id": user_id,
        "notify": notify,
        "data": user.model_dump()
    }
4.5.2

Forms

📝
URL-Encoded & Multipart Forms

Use Form() to receive HTML form data (application/x-www-form-urlencoded or multipart/form-data). Install python-multipart first.

bash — install dependency
pip install python-multipart
python — form data
from fastapi import FastAPI, Form
from typing import Annotated

app = FastAPI()

# URL-encoded form (typical HTML form POST)
@app.post("/login")
async def login(
    username: Annotated[str, Form()],
    password: Annotated[str, Form()],
):
    # Content-Type: application/x-www-form-urlencoded
    # Body: username=alice&password=secret
    return {"username": username, "logged_in": True}

# Form with validation
@app.post("/register")
async def register(
    username: Annotated[str, Form(min_length=3, max_length=20)],
    email: Annotated[str, Form()],
    age: Annotated[int, Form(ge=18)],  # must be 18+
):
    return {"registered": username}

# ⚠️ You cannot mix Form() and JSON body in the same endpoint
# They use different Content-Types
4.5.3

File Uploads

📁
Single & Multiple File Uploads

Use UploadFile for file uploads. It gives you metadata (filename, content type) and async file reading.

python — file uploads
from fastapi import FastAPI, File, UploadFile, Form
from typing import Annotated

app = FastAPI()

# Single file upload
@app.post("/upload")
async def upload_file(file: UploadFile):
    print(file.filename)      # "photo.jpg"
    print(file.content_type)  # "image/jpeg"
    print(file.size)          # file size in bytes

    # Read entire file into memory
    contents = await file.read()

    # Or stream it in chunks (better for large files!)
    while chunk := await file.read(1024):  # 1KB chunks
        process_chunk(chunk)

    return {"filename": file.filename, "size": file.size}

# Multiple files
@app.post("/upload-many")
async def upload_many(files: list[UploadFile]):
    results = []
    for file in files:
        contents = await file.read()
        results.append({"name": file.filename, "size": len(contents)})
    return results

# File + form fields together (use multipart/form-data)
@app.post("/upload-with-meta")
async def upload_with_meta(
    file: UploadFile,
    description: Annotated[str, Form()],
    public: Annotated[bool, Form()] = False,
):
    return {
        "filename": file.filename,
        "description": description,
        "public": public,
    }
⚠️
For large file uploads, always use await file.read(chunk_size) in a loop instead of await file.read() — reading the entire file at once can exhaust server memory.
🌊
Streaming Uploads

For very large files, stream directly from the raw request instead of loading into memory:

python — streaming upload to disk
from fastapi import FastAPI, Request
import aiofiles

app = FastAPI()

@app.post("/stream-upload")
async def stream_upload(request: Request):
    # Stream directly from request body to disk
    # Never loads entire file in memory!
    async with aiofiles.open("output.bin", "wb") as f:
        async for chunk in request.stream():
            await f.write(chunk)

    return {"status": "uploaded"}

# Client sends:
# curl -X POST http://localhost:8000/stream-upload \
#      --data-binary @huge-file.zip
🧠
Topic 4 Final Quiz
Q1: What is the modern way to run startup/shutdown code in FastAPI?
A) @app.on_event("startup") decorator
B) @asynccontextmanager lifespan function passed to FastAPI(lifespan=...)
C) Override app.startup() method
D) Use middleware for startup tasks
Q2: You have a large team building a FastAPI app with 50+ endpoints. What's the best way to organize routes?
A) Put all routes in main.py on the app object
B) Create separate APIRouter files per domain and include_router() in main.py
C) Create multiple FastAPI() instances
D) Use separate Python processes per domain
Q3: Can you mix Form() fields and a Pydantic JSON body in the same endpoint?
A) Yes, FastAPI handles both automatically
B) No — they use different Content-Types and can't be mixed
C) Yes, if you use Body(media_type="multipart")
D) Only if both are optional
📌
Topic 4 Cheat Sheet
FastAPI Core — quick reference
━━━ App Creation ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
app = FastAPI(title="API", version="1.0", description="...")

━━━ Lifespan ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@asynccontextmanager
async def lifespan(app):
    # startup
    yield
    # shutdown
app = FastAPI(lifespan=lifespan)

━━━ HTTP Methods ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@app.get / post / put / patch / delete / head / options

━━━ APIRouter ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
router = APIRouter(prefix="/users", tags=["users"])
app.include_router(router)

━━━ Parameter Sources ━━━━━━━━━━━━━━━━━━━━━━━━━━
def endpoint(
    user_id: int,                      # path  (in URL pattern {})
    page: int = 1,                     # query (not in path = query)
    auth: Annotated[str, Header()],   # header
    sid:  Annotated[str, Cookie()],   # cookie
    body: MyModel,                      # JSON body (Pydantic)
    form: Annotated[str, Form()],     # form field
    file: UploadFile,                   # file upload
)

━━━ Constraints ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Path(ge=1, le=999, pattern=r"...")
Query(min_length=3, max_length=50)