What is FastAPI & Why Use It
BeginnerFastAPI 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}uvicorn app.main:app). Think of it as both a router and an OpenAPI document generator combined.: 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.💬 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.”
Installation & Project Setup
BeginnerFastAPI 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.venv folder stores your project’s packages separate from the system Python.[standard], you’d need to separately install uvicorn, python-multipart (for forms/files), email-validator, and httpx. It’s a convenience bundle.orders.py, payments.py etc. without touching main.py.⚠ 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.
First App & Running with Uvicorn
BeginnerA 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)uvicorn app.main:app. All routes, middleware, and configuration are attached to this single object.GET / requests. FastAPI reads this at import time and adds it to its internal route table AND the OpenAPI schema.json.dumps() or set headers manually — this is handled by Starlette’s JSONResponse under the hood.module:variable format. app.main = the Python module path (file at app/main.py), app = the name of the FastAPI variable inside that file.💬 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.
Path Parameters
BeginnerPath 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}{} must exactly match the function parameter name.42 (int). If coercion fails (e.g., “abc” → int), FastAPI returns a 422 with a detailed error message — before your function body ever executes.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() 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.: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.
Query Parameters
BeginnerQuery 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}= 0 makes it optional. The : int annotation means FastAPI will convert the URL string “20” to integer 20.Optional[str] = None.?q= but the Python variable is search. Useful when the URL convention (short) differs from what’s clear in Python code (descriptive).?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 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.
Request Body with Pydantic
BeginnerWhen 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 resultBaseModel 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.BaseModel type, FastAPI knows to read the request body as JSON and parse it into that model. It never conflicts with path/query params.= 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..model_dump() or json.dumps()."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).
Response Models
BeginnerThe 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 itemUserOut before sending. Any field not declared in UserOut is silently stripped. This is the primary mechanism for preventing data leaks.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.response_model. It’s the preferred modern style because it enables type checker (mypy/pyright) validation.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.
HTTP Status Codes
BeginnerHTTP 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"}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 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 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=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)
Auto Docs — Swagger UI & ReDoc
BeginnerOne 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])tags=['pets'] appear under a collapsible “pets” section. Makes large APIs navigable.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.
Form Data & File Uploads
BeginnerHTML 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 resultForm(), FastAPI would try to interpret it as a query param.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.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 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.