FastAPI Guide
1

What is FastAPI & Why Use It

Beginner

FastAPI is a modern Python web framework for building APIs. Unlike Flask, it uses Python type hints to do three things at once: validate incoming data, serialize outgoing data, and generate interactive API documentation — all automatically, with no extra code. It runs on ASGI (Asynchronous Server Gateway Interface) so it handles thousands of concurrent connections efficiently, putting it performance-wise alongside Node.js and Go.

# ── FLASK (traditional) ─────────────────────────────────────────────────────
from flask import Flask, jsonify, request

flask_app = Flask(__name__)

@flask_app.route("/items/<int:item_id>", methods=["GET"])
def get_item(item_id):
    # No type checking on output — anything goes in the dict
    return jsonify({"id": item_id, "name": "Widget"})

# ── FASTAPI ──────────────────────────────────────────────────────────────────
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):          # Type definition drives validation AND docs
    id: int
    name: str
    price: float

@app.get("/items/{item_id}", response_model=Item)
def get_item(item_id: int):     # Type hint ← FastAPI reads this at startup
    # FastAPI will:
    # 1. Validate that {item_id} in the URL is an integer
    # 2. Validate the returned dict matches Item schema
    # 3. Show this endpoint in /docs Swagger UI automatically
    # 4. Return 422 with a clear error if types don't match
    return {"id": item_id, "name": "Widget", "price": 9.99}
📖 Concept Breakdown
FastAPI()
Creates an ASGI application instance. This is the object you pass to your server (uvicorn app.main:app). Think of it as both a router and an OpenAPI document generator combined.
ASGI
Asynchronous Server Gateway Interface — the modern Python server standard (like WSGI but async). Uvicorn is an ASGI server; FastAPI is an ASGI application. This is what enables concurrent request handling without threads.
type hints
Python’s : int, : str, : float annotations. FastAPI reads these at runtime (not just for editors) to build validation logic and the OpenAPI schema. This is FastAPI’s core innovation.
BaseModel
Pydantic’s base class for data schemas. Fields with type hints become validated attributes. A dict passed to this class is validated against the schema, raising an error on mismatch.
response_model=Item
Tells FastAPI: “filter and validate the response through the Item schema.” Any extra fields not in Item are stripped — this is how you prevent accidentally returning passwords or internal fields.

💬 Interview Tip

When asked “What makes FastAPI different from Flask?”, say: “FastAPI uses Python type hints to power three things simultaneously: runtime validation (Pydantic), async support (Starlette/ASGI), and auto-generated OpenAPI documentation. Flask requires separate libraries for each of those. FastAPI’s performance is also comparable to Node.js because it runs on Uvicorn, an ASGI server, not WSGI.”

2

Installation & Project Setup

Beginner

FastAPI itself is just a Python package — but it needs an ASGI server to actually serve HTTP requests. The standard server is Uvicorn. The recommended install is fastapi[standard] which bundles both together. Always use a virtual environment so your project’s dependencies are isolated from other projects on your machine.

# Step 1: Create a virtual environment (isolated Python environment)
python -m venv .venv

# Step 2: Activate it
source .venv/bin/activate        # macOS / Linux
# .venv\Scripts\activate         # Windows

# Step 3: Install FastAPI with all standard extras
pip install "fastapi[standard]"
# This installs: fastapi, uvicorn[standard], pydantic, httpx, python-multipart, etc.

# Step 4: Verify installation
python -c "import fastapi; print(fastapi.__version__)"
my_api/
├── app/
│   ├── __init__.py          # Makes this a Python package
│   ├── main.py              # FastAPI app instance + app-level config
│   ├── config.py            # Pydantic Settings (env vars)
│   ├── dependencies.py      # Shared Depends() functions (auth, pagination)
│   ├── routers/             # One file per resource (items, users, orders)
│   │   ├── __init__.py
│   │   ├── items.py
│   │   └── users.py
│   ├── models/              # SQLAlchemy ORM models (database tables)
│   ├── schemas/             # Pydantic schemas (request/response shapes)
│   └── services/            # Business logic (kept separate from routes)
├── tests/
│   └── test_items.py
├── .env                     # Environment variables (NEVER commit to git)
├── .env.example             # Template showing required vars (safe to commit)
├── requirements.txt
└── alembic/                 # Database migrations
📖 Concept Breakdown
venv
A virtual environment is a self-contained Python installation for your project. Without it, packages from different projects would conflict. The .venv folder stores your project’s packages separate from the system Python.
fastapi[standard]
Square bracket syntax installs optional extras. Without [standard], you’d need to separately install uvicorn, python-multipart (for forms/files), email-validator, and httpx. It’s a convenience bundle.
routers/ directory
Each file holds routes for one resource. This keeps files small and focused. As your API grows, you’ll add orders.py, payments.py etc. without touching main.py.
schemas/ vs models/
schemas/ = Pydantic classes (what the API sends and receives). models/ = SQLAlchemy ORM classes (what the database stores). Keeping them separate prevents leaking database internals (like hashed passwords) into API responses.

⚠ Gotcha

pip install fastapi alone does not install Uvicorn. You need an ASGI server to actually run anything. Either use fastapi[standard] or explicitly add uvicorn[standard] to your requirements.

3

First App & Running with Uvicorn

Beginner

A FastAPI application starts with creating a FastAPI() instance and decorating Python functions with route decorators. Uvicorn serves the app. In development, --reload watches for file changes and restarts automatically. In production, you use Gunicorn to manage multiple Uvicorn workers (covered in Topic 44).

from fastapi import FastAPI

# Create the application instance.
# Everything flows from this object — routes, middleware, docs, startup hooks.
app = FastAPI(
    title="My First API",            # Shown in /docs header
    description="Learning FastAPI",  # Markdown is supported here
    version="0.1.0",
)

# Route decorator syntax: @app.{HTTP_METHOD}("{PATH}")
# The function name doesn't matter to FastAPI — the decorator defines the route.
@app.get("/")                        # Handles: GET /
def root():
    # Return any dict — FastAPI automatically:
    # 1. Converts it to JSON  ({"message": "Hello, FastAPI!"})
    # 2. Sets Content-Type: application/json header
    # 3. Returns HTTP 200 OK
    return {"message": "Hello, FastAPI!"}

@app.get("/about")
def about():
    return {"name": "My API", "version": "0.1.0"}
# Development — auto-reloads when you save files
uvicorn app.main:app --reload --port 8000
# │         │    │       │
# │         │    │       └── Watch files for changes (dev only!)
# │         │    └────────── Variable name in main.py (the FastAPI instance)
# │         └─────────────── Module path (app/main.py → app.main)
# └───────────────────────── The ASGI server

# Or use FastAPI's built-in CLI (comes with fastapi[standard])
fastapi dev app/main.py             # development (auto-reload)
fastapi run app/main.py             # production (no reload)
📖 Concept Breakdown
app = FastAPI()
Creates the ASGI application object. This is what Uvicorn receives when you write uvicorn app.main:app. All routes, middleware, and configuration are attached to this single object.
@app.get('/')
A decorator that registers the function below it as the handler for GET / requests. FastAPI reads this at import time and adds it to its internal route table AND the OpenAPI schema.
return {'key': 'val'}
FastAPI automatically serializes Python dicts (and Pydantic models) to JSON. You never call json.dumps() or set headers manually — this is handled by Starlette’s JSONResponse under the hood.
app.main:app
Uvicorn expects module:variable format. app.main = the Python module path (file at app/main.py), app = the name of the FastAPI variable inside that file.
--reload
Tells Uvicorn to watch your Python files for changes and restart automatically. Never use in production — it has performance overhead and uses file watchers that aren’t suited for production.

💬 Interview Tip

Know that FastAPI itself does NOT serve HTTP — Uvicorn does. FastAPI is an ASGI application; Uvicorn is the ASGI server. The app is just a callable that receives requests and returns responses. This is why the same FastAPI app can also run on Hypercorn or Daphne — the server is swappable.

4

Path Parameters

Beginner

Path parameters are variable parts of the URL path, defined with curly braces: /items/{item_id}. FastAPI matches the variable name to a function parameter of the same name. Adding a type annotation does two things: it automatically converts the string from the URL to that type, and rejects invalid values with a clear 422 error before your code even runs.

from fastapi import FastAPI, Path
from enum import Enum
from typing import Annotated

app = FastAPI()

# ── Basic typed path parameter ───────────────────────────────────────────────
@app.get("/items/{item_id}")
def get_item(item_id: int):
    # URL: GET /items/42   → item_id = 42  (int, not string "42")
    # URL: GET /items/abc  → 422 error: "value is not a valid integer"
    return {"item_id": item_id, "type": type(item_id).__name__}  # "int"

# ── Enum: restrict to a fixed set of values ──────────────────────────────────
class Color(str, Enum):      # inherit from str for JSON serialization
    red   = "red"
    green = "green"
    blue  = "blue"

@app.get("/colors/{color}")
def get_color(color: Color):
    return {"color": color, "hex": {"red": "#FF0000", "green": "#00FF00", "blue": "#0000FF"}[color]}

# ── Path() for extra validation and documentation ────────────────────────────
@app.get("/users/{user_id}")
def get_user(
    user_id: Annotated[int, Path(
        title="User ID",
        description="Must be between 1 and 9999",
        ge=1,     # greater-than-or-equal-to 1
        le=9999,  # less-than-or-equal-to 9999
    )]
):
    return {"user_id": user_id}

# ── :path type — captures slashes (for file paths) ───────────────────────────
@app.get("/files/{file_path:path}")
def read_file(file_path: str):
    # GET /files/reports/2024/january.csv
    # file_path = "reports/2024/january.csv"  ← slashes are included!
    return {"path": file_path}
📖 Concept Breakdown
{item_id}
Curly braces in the path define a path parameter. FastAPI extracts whatever text appears at that position in the URL and passes it to the matching function parameter. The name inside {} must exactly match the function parameter name.
item_id: int
The type annotation tells FastAPI to coerce and validate the raw URL string. “42” → 42 (int). If coercion fails (e.g., “abc” → int), FastAPI returns a 422 with a detailed error message — before your function body ever executes.
class Color(str, Enum)
Inheriting from both str AND Enum makes the enum values JSON-serializable as strings. Without str, returning an enum value would fail JSON serialization or return the enum object representation.
Path(ge=1, le=9999)
Path() adds metadata on top of the type hint. ge = greater-or-equal, le = less-or-equal. This data appears in the Swagger UI docs AND enforces the constraint at runtime. Annotated[int, Path(...)] is the modern Pydantic v2 way to attach this metadata.
{file_path:path}
The :path converter is a Starlette feature that tells the router “capture everything after this point, including forward slashes.” Without it, /files/a/b.txt would not match /files/{file_path}.

⚠ Gotcha — Route Order Matters

FastAPI matches routes in the order they are declared. If you define /users/{user_id} before /users/me, then a request to /users/me will try to match "me" as a user_id and fail. Always put static routes before parameterized ones.

5

Query Parameters

Beginner

Query parameters appear after ? in URLs: /search?q=python&limit=10. In FastAPI, any function parameter that is not in the path template automatically becomes a query parameter. Optional parameters use = None as default; required ones have no default. Type validation and conversion work the same as path parameters.

from fastapi import FastAPI, Query
from typing import Annotated

app = FastAPI()

# ── Required and optional with defaults ─────────────────────────────────────
@app.get("/items")
def list_items(
    skip: int = 0,         # optional — defaults to 0 if not provided
    limit: int = 10,       # optional — defaults to 10
):
    return {"skip": skip, "limit": limit}

# ── Optional nullable parameter ──────────────────────────────────────────────
@app.get("/search")
def search(q: str | None = None):
    if q:
        return {"results": [f"Result for: {q}"]}
    return {"results": []}

# ── Query() for validation and documentation ────────────────────────────────
@app.get("/products")
def list_products(
    search: Annotated[str | None, Query(
        min_length=2,
        max_length=100,
        description="Search term",
        alias="q",                  # the URL param is ?q= not ?search=
    )] = None,
    min_price: float = Query(default=0, ge=0, description="Minimum price"),
    max_price: float = Query(default=9999, gt=0),
):
    return {"search": search, "price_range": [min_price, max_price]}

# ── List / multi-value query params ─────────────────────────────────────────
@app.get("/items/tagged")
def items_by_tag(tag: list[str] = Query(default=[])):
    # GET /items/tagged?tag=python&tag=fastapi&tag=web
    return {"tags": tag}
📖 Concept Breakdown
skip: int = 0
Any parameter not in the path is treated as a query parameter. The = 0 makes it optional. The : int annotation means FastAPI will convert the URL string “20” to integer 20.
str | None = None
Python 3.10+ union syntax. Declares the parameter as “either a string or None, defaulting to None if not provided.” This is how you make an optional, nullable query parameter. Equivalent to Optional[str] = None.
Query(alias='q')
The alias lets you name the URL parameter differently from the Python variable. Here the URL uses ?q= but the Python variable is search. Useful when the URL convention (short) differs from what’s clear in Python code (descriptive).
list[str] = Query([])
To receive multiple values for the same key (?tag=a&tag=b), declare the type as list[str] and use Query() as the default. Without Query(default=[]), FastAPI wouldn’t know this is a query param.
Annotated[type, Query()]
The modern Pydantic v2 pattern. Annotated carries metadata alongside the type hint. Preferred over the older param: type = Query(...) because it clearly separates the type from the default value.

💬 Interview Tip

Interviewers sometimes ask: “How does FastAPI know whether a parameter is a path param, query param, or body?” Answer: If the name matches a path variable in the URL template → path parameter. If the type is a Pydantic BaseModel → body. Otherwise → query parameter. FastAPI inspects function signatures at startup to build this mapping automatically.

6

Request Body with Pydantic

Beginner

When clients send data in the request body (typically as JSON in POST/PUT requests), you declare a Pydantic BaseModel as the parameter type. FastAPI reads the JSON, validates every field against the model’s type hints, and gives you a fully typed Python object. Fields with defaults are optional; fields without are required.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):
    name: str                        # required (no default)
    price: float                     # required
    is_available: bool = True        # optional, defaults to True
    description: str | None = None   # optional, defaults to None

# Client sends JSON: {"name": "Widget", "price": 9.99}
@app.post("/items/")
def create_item(item: Item):
    # item is a fully validated Python object, not a raw dict
    tax = item.price * 0.1
    return {"item": item, "price_with_tax": item.price + tax}

# ── Nested models ────────────────────────────────────────────────────────────
class Address(BaseModel):
    street: str
    city: str
    country: str = "US"

class Customer(BaseModel):
    name: str
    email: str
    address: Address       # nested model — FastAPI handles recursively

@app.post("/customers/")
def create_customer(customer: Customer):
    return customer   # Pydantic models serialize to JSON automatically

# ── Mix path + query + body ──────────────────────────────────────────────────
@app.put("/items/{item_id}")
def update_item(
    item_id: int,            # ← from path
    q: str | None = None,   # ← from query string
    item: Item = None,       # ← from request body (JSON)
):
    result = {"item_id": item_id}
    if q:    result["q"] = q
    if item: result["item"] = item
    return result
📖 Concept Breakdown
class Item(BaseModel)
Defining a class that inherits from BaseModel creates a Pydantic schema. Pydantic reads the class body at definition time and builds a validator. When you instantiate it, all fields are validated and coerced to their declared types.
item: Item in function
When a function parameter has a BaseModel type, FastAPI knows to read the request body as JSON and parse it into that model. It never conflicts with path/query params.
str | None = None
A field with = None as default is optional in the request body. A field with no default (like name: str) is required — the request will fail with 422 if it’s missing.
return item
FastAPI serializes Pydantic model instances to JSON automatically. You can return the model object directly — no need to call .model_dump() or json.dumps().
Nested Address inside Customer
Pydantic handles nested models recursively. FastAPI expects the JSON to have "address": {"street": "...", "city": "..."} and automatically creates the Address instance.

⚠ Gotcha — Single vs multiple body params

With one body param, FastAPI expects the JSON directly: {"name": "Widget", "price": 9.99}. With multiple body params, it wraps them: {"item": {...}, "user": {...}}. To force a single param to be embedded (wrapped), use Body(embed=True).

7

Response Models

Beginner

The response_model parameter tells FastAPI what shape your response should have. It filters out fields not in the model (critical for security — prevents leaking passwords or internal data) and validates/serializes the output. Think of it as the “output schema” that’s separate from your “input schema.”

from fastapi import FastAPI
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

class UserIn(BaseModel):
    username: str
    email: str
    password: str           # client provides this to create account

class UserOut(BaseModel):
    id: int
    username: str
    email: str
    created_at: datetime
    # 'password' is intentionally absent — never send it back!

@app.post("/users/", response_model=UserOut)
def create_user(user: UserIn):
    saved = {
        "id": 1,
        "username": user.username,
        "email": user.email,
        "password": "hashed_password_here",  # ← exists in DB record
        "created_at": datetime.now(),
    }
    # FastAPI strips "password" before sending — only UserOut fields go out
    return saved

# ── Return type annotation (modern style, Pydantic v2) ──────────────────────
@app.get("/me")
def get_me() -> UserOut:
    return {"id": 1, "username": "alice", "email": "alice@x.com", "created_at": datetime.now()}

# ── response_model_exclude_unset — only return fields that were set ──────────
class ItemPartial(BaseModel):
    name: str | None = None
    price: float | None = None

@app.patch("/items/{id}", response_model=ItemPartial, response_model_exclude_unset=True)
def patch_item(id: int, item: ItemPartial):
    # If client sends {"price": 9.99}, only {"price": 9.99} is returned
    return item
📖 Concept Breakdown
response_model=UserOut
FastAPI runs the returned data through UserOut before sending. Any field not declared in UserOut is silently stripped. This is the primary mechanism for preventing data leaks.
UserIn vs UserOut
Having separate input and output schemas is a security best practice. UserIn has password (for creating users). UserOut doesn’t (for reading users). Even if your DB query returns the password hash, response_model ensures it never reaches the client.
-> UserOut annotation
In Pydantic v2 / FastAPI 0.95+, the return type annotation on the function works as a response_model. It’s the preferred modern style because it enables type checker (mypy/pyright) validation.
response_model_exclude_unset
For PATCH endpoints, you want to return only what the client actually changed. Setting this to True makes FastAPI skip fields that still have their default values. Crucial for proper partial update semantics.

💬 Interview Tip

“How do you prevent returning sensitive data like passwords?” — The answer is response_model=UserOut where UserOut doesn’t include the password field. FastAPI automatically filters the response. You can also use response_model_exclude={"password", "secret_key"} to exclude specific fields dynamically.

8

HTTP Status Codes

Beginner

HTTP status codes tell the client what happened with their request. FastAPI defaults to 200 OK for successful responses. You should set appropriate codes: 201 for resource creation, 204 for deletion (no content), and use HTTPException to send error codes. FastAPI’s status module provides named constants so you don’t have to memorize numbers.

from fastapi import FastAPI, HTTPException, status

app = FastAPI()
fake_db = {1: "apple", 2: "banana", 3: "cherry"}

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id not in fake_db:
        # Raise, don't return — stops function execution immediately
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item with id={item_id} was not found",
        )
    return {"item": fake_db[item_id]}

# 201 Created — use for POST that creates a resource
@app.post("/items/", status_code=status.HTTP_201_CREATED)
def create_item(name: str):
    new_id = max(fake_db.keys()) + 1
    fake_db[new_id] = name
    return {"id": new_id, "name": name}

# 204 No Content — use for DELETE
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
    if item_id not in fake_db:
        raise HTTPException(status_code=404, detail="Item not found")
    del fake_db[item_id]
    # Return nothing — 204 must have no body

@app.get("/protected")
def protected_resource(token: str | None = None):
    if not token:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Authentication required",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return {"data": "secret"}
📖 Concept Breakdown
status.HTTP_404_NOT_FOUND
FastAPI exports a status module with named constants for all HTTP codes. Using status.HTTP_404_NOT_FOUND instead of 404 makes code more readable and avoids typos.
raise HTTPException
raise is used intentionally — it immediately stops function execution and FastAPI catches it, serializing it to a JSON error response. Using return would continue executing the function.
detail=
The detail field appears in the JSON error body as {"detail": "..."}. It can be a string, dict, or list — whatever shape makes sense for your API’s error format.
status_code on decorator
Setting status_code=201 on the route decorator changes the default success code for that route. This only applies when the function returns normally. If you raise HTTPException inside, the exception’s status code is used instead.

⚠ Gotcha — FastAPI's 422 vs your 400

FastAPI automatically returns 422 Unprocessable Entity (not 400) for validation failures like wrong types or missing required fields. The 422 response includes a detailed errors array with field-level information. Don’t try to change this to 400 unless a client specifically requires it — 422 is more informative.

★ Common Status Codes to Memorize

  • 200 — OK (default success)
  • 201 — Created (POST that makes something)
  • 204 — No Content (DELETE success, no body)
  • 400 — Bad Request (client sent invalid data you caught manually)
  • 401 — Unauthorized (not authenticated — no valid token)
  • 403 — Forbidden (authenticated but not allowed)
  • 404 — Not Found (resource doesn’t exist)
  • 409 — Conflict (e.g., duplicate email on registration)
  • 422 — Unprocessable Entity (FastAPI’s validation error)
  • 500 — Internal Server Error (your code crashed)
9

Auto Docs — Swagger UI & ReDoc

Beginner

One of FastAPI’s most celebrated features: it automatically generates interactive API documentation from your code. No separate YAML files, no manual spec writing. Visit /docs for Swagger UI (try out endpoints directly) and /redoc for ReDoc (clean reading format). The underlying OpenAPI spec JSON is at /openapi.json.

from fastapi import FastAPI
from pydantic import BaseModel, Field

app = FastAPI(
    title="Pet Store API",
    description="""
## Features
- Manage **pets** and their owners
- Full CRUD with validation
- JWT authentication
    """,
    version="2.0.0",
    docs_url="/docs",       # Swagger UI path (set to None to disable)
    redoc_url="/redoc",     # ReDoc path     (set to None to disable)
)

@app.get(
    "/pets/{pet_id}",
    tags=["pets"],
    summary="Get a pet by ID",
    description="Returns all data for a pet. Raises 404 if not found.",
    response_description="The complete pet object",
)
def get_pet(pet_id: int):
    return {"pet_id": pet_id, "name": "Buddy"}

@app.post("/pets/", tags=["pets"], status_code=201)
def create_pet(name: str, species: str):
    """
    Create a new pet record.

    - **name**: the pet's display name
    - **species**: cat, dog, bird, etc.
    """
    return {"id": 99, "name": name, "species": species}

class Pet(BaseModel):
    name: str    = Field(..., examples=["Buddy"])
    species: str = Field(..., examples=["dog"])
    age: int     = Field(ge=0, le=50, examples=[3])
📖 Concept Breakdown
/docs (Swagger UI)
An interactive HTML page served by FastAPI that lets you try out API endpoints directly in the browser. You can fill in parameters, send requests, and see responses — no Postman or curl needed during development.
/openapi.json
The machine-readable OpenAPI specification that Swagger UI and ReDoc read to render the docs. This same file can be imported into Postman, used to generate client SDKs, or shared with frontend teams.
tags=['pets']
Tags group endpoints together in the Swagger sidebar. All endpoints with tags=['pets'] appear under a collapsible “pets” section. Makes large APIs navigable.
summary vs description
summary = the short one-line title shown next to the method badge. description = the expanded explanation shown when you click the endpoint. Docstrings on the function are used as the description automatically.

★ Rarely Known

Set docs_url=None and redoc_url=None to disable interactive docs entirely in production for security. But also consider disabling just the “Try it out” button via Swagger UI parameters: swagger_ui_parameters={"defaultModelsExpandDepth": -1}. You can also mount the docs at a custom secret URL that only your team knows.

10

Form Data & File Uploads

Beginner

HTML forms and file uploads use multipart/form-data encoding rather than JSON. FastAPI handles this with Form() for text fields and UploadFile for files. You must install python-multipart for this to work (included in fastapi[standard]). The key difference: UploadFile streams files to disk and is async-safe; File() loads everything into memory.

from fastapi import FastAPI, Form, File, UploadFile
from typing import Annotated

app = FastAPI()

# ── Form fields ──────────────────────────────────────────────────────────────
@app.post("/login")
def login(
    username: Annotated[str, Form()],
    password: Annotated[str, Form()],
):
    return {"logged_in_as": username}

# ── Single file upload ────────────────────────────────────────────────────────
@app.post("/upload/")
async def upload_file(file: UploadFile):
    contents = await file.read()
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "size_bytes": len(contents),
    }

# ── Multiple files ────────────────────────────────────────────────────────────
@app.post("/upload/multiple")
async def upload_many(files: list[UploadFile]):
    results = []
    for f in files:
        size = len(await f.read())
        results.append({"filename": f.filename, "size": size})
    return {"uploaded": results}

# ── Mix form fields AND a file ────────────────────────────────────────────────
@app.post("/profile/")
async def update_profile(
    display_name: Annotated[str, Form()],
    bio: Annotated[str, Form()] = "",
    avatar: UploadFile | None = None,
):
    result = {"name": display_name, "bio": bio}
    if avatar:
        result["avatar_filename"] = avatar.filename
    return result
📖 Concept Breakdown
Form()
Tells FastAPI to read this parameter from the form body (multipart or url-encoded), not from a JSON body or query string. Without Form(), FastAPI would try to interpret it as a query param.
UploadFile vs File()
UploadFile: spools large files to disk, provides async read(), has .filename and .content_type attributes. Best for production. File(): loads entire file into bytes in memory immediately. Only use File() for very small files.
await file.read()
File reading is async because UploadFile uses an async file handle internally. If you use a plain def route (not async def), call file.file.read() to access the underlying sync file object.
python-multipart
FastAPI delegates multipart parsing to the python-multipart library. If it’s not installed, you get a clear error when you try to use Form() or UploadFile. It’s included in fastapi[standard].

⚠ Gotcha — Form data and JSON are mutually exclusive

You cannot use both Form() parameters AND a Pydantic BaseModel body in the same endpoint. They use different Content-Types (multipart/form-data vs application/json). If you need both, either put the JSON data into a form field as a JSON string, or split into two separate endpoints.