Modern Python FastAPI #1 Getting Started and Setup
This is where the 21 posts of tools — Modern Python Basics → Intermediate → Advanced — all come together. While building one project with FastAPI, you’ll bring everything you’ve sharpened so far into one place: type hints, dataclass/Pydantic, async/await, dependency injection, testing.
- #1 Setup and start, OpenAPI ← this post
- #2 Routing, Pydantic models, dependency injection
- #3 DB integration — SQLAlchemy 2.x + Alembic
- #4 Authentication — OAuth2 password flow + JWT
- #5 Async and background work
- #6 Testing and deployment — pytest, Docker, Railway/Fly
What you’ll build is a small Todo API — user signup/login, todo CRUD, async background work, testing, and deployment. Each post stacks one more layer on top.
Why FastAPI #
There are several Python web frameworks. Django, Flask, FastAPI, Litestar, Starlette, etc. Each has its place.
| Django | Flask | FastAPI | |
|---|---|---|---|
| Style | Full-stack (batteries included) | Micro | Micro + types |
| Async | Partial (4.0+) | External library | Native |
| Type-hint usage | Auxiliary | Auxiliary | Core behavior |
| Auto docs (OpenAPI) | Separate library | Separate | Built-in |
| Learning curve | Steep | Very flat | Flat |
| Data validation | DRF + manual | Manual | Pydantic, automatic |
FastAPI’s strength is that type hints just work. Write a function signature and it becomes:
- Input validation
- Serialization/deserialization
- An OpenAPI schema (Swagger UI auto-generated)
- Editor autocomplete
- Static type checking
All five at once. Other frameworks require separate configuration for each. FastAPI is where the type hints sharpened in the Modern Python series shine the brightest, which is why we chose it as the convergence point of the track.
Project setup #
Start with the uv flow from Basics #1.
uv init todo-api --python 3.14
cd todo-apiInstall dependencies #
uv add "fastapi[standard]"What that single fastapi[standard] line brings in:
- FastAPI — the framework itself
- uvicorn — the ASGI server (dev/prod)
- pydantic — data validation
- httpx — test client (for later posts)
- email-validator —
EmailStrvalidation - python-multipart — form / file uploads
- fastapi-cli — the
fastapiCLI command
Next, dev tools.
uv add --dev pytest pytest-asyncio ruff pyrightOn top of the toolbox from Advanced #7, the standard for FastAPI testing is pytest + httpx (covered in detail in #6).
First endpoint #
from fastapi import FastAPI
app = FastAPI(
title="Todo API",
version="0.1.0",
description="Example API for the Modern Python in Practice series",
)
@app.get("/")
def read_root() -> dict[str, str]:
return {"hello": "fastapi"}That’s it. Seven lines of Python and you have an HTTP server.
Run it #
uv run fastapi dev app/main.pyWhat fastapi dev does:
- Spins up uvicorn
- Auto-reloads on code changes
- Debug logs
- Doesn’t expose to the LAN (
127.0.0.1only)
Open http://127.0.0.1:8000 in a browser:
{"hello": "fastapi"}More importantly — automatic docs #
Go to /docs on the same server. Swagger UI is sitting there automatically. The endpoint list, input schemas, response models, and even a “Try it out” button that calls them right there. Without writing a single extra line.
Visit /redoc and you also get a ReDoc-style document.
This is what FastAPI’s auto docs really mean. Function signatures + type hints + docstrings are converted to an OpenAPI spec as-is.
Richer endpoints #
from fastapi import FastAPI
app = FastAPI(title="Todo API", version="0.1.0")
@app.get("/")
def read_root() -> dict[str, str]:
"""Root — for health check."""
return {"hello": "fastapi"}
@app.get("/items/{item_id}")
def read_item(item_id: int) -> dict[str, int]:
"""Look up a single item by ID."""
return {"item_id": item_id}
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10) -> dict[str, int]:
"""Pagination query parameters."""
return {"skip": skip, "limit": limit}The automatic conversion happening here:
- Path parameter
{item_id}→ function argumentitem_id: int- If it’s not an
int, 422 Unprocessable Entity is returned automatically
- If it’s not an
- Query parameters
skip,limit→ function arguments with defaults- Auto-parsed in the form
?skip=10&limit=20
- Auto-parsed in the form
- Return type
-> dict[str, int]→ JSON response - docstring → description in Swagger UI
The key is that you wrote int and the conversion / validation happens automatically. In Flask:
@app.route("/items/<int:item_id>")
def read_item(item_id):
return jsonify({"item_id": item_id})You have to write the type in the route like <int:item_id>, and handle validation-failure responses yourself. In FastAPI, the function signature is the route specification.
Path, Query — finer-grained validation
#
from fastapi import FastAPI, Path, Query
from typing import Annotated
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(
item_id: Annotated[int, Path(ge=1, le=10000, description="Item ID")],
q: Annotated[str | None, Query(min_length=3, max_length=50)] = None,
):
return {"item_id": item_id, "q": q}Path() and Query() add validation rules and metadata.
ge=1—>= 1le=10000—<= 10000min_length=3— at least 3 charactersdescription=...— shown in Swagger docs
The Annotated[T, ...] pattern is exactly the one from Advanced #6 — the standard for binding metadata to a type. Almost all of FastAPI’s dependency injection is built on this pattern.
Responses — automatic serialization #
from datetime import datetime, timezone
from pydantic import BaseModel
class Todo(BaseModel):
id: int
title: str
done: bool
created_at: datetime
@app.get("/todos/1")
def get_todo() -> Todo:
return Todo(
id=1,
title="Learn FastAPI",
done=False,
created_at=datetime.now(timezone.utc),
)Response:
{
"id": 1,
"title": "Learn FastAPI",
"done": false,
"created_at": "2026-05-01T12:00:00+00:00"
}Non-JSON types like datetime are auto-converted. Pydantic handles serialization. Doing this by hand means writing conversion code for every route, but with FastAPI you only write the return type.
For details on the Todo model, see #2.
Errors — HTTPException
#
from fastapi import HTTPException
todos: dict[int, Todo] = {1: Todo(...)}
@app.get("/todos/{todo_id}")
def get_todo(todo_id: int) -> Todo:
if todo_id not in todos:
raise HTTPException(status_code=404, detail="Todo not found")
return todos[todo_id]A single line of raise HTTPException(...) produces a JSON error response.
{"detail": "Todo not found"}With the appropriate HTTP status code (404, 400, 500, etc.). Custom exception handlers come up in later posts.
Project structure — tidy from the start #
Even for a small API, splitting things into modules/packages from the start carries forward the flow from Basics #7.
todo-api/
├── pyproject.toml
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI instance, register routers
│ ├── core/
│ │ ├── config.py # environment variables
│ │ └── security.py # JWT, passwords (#4)
│ ├── api/
│ │ ├── __init__.py
│ │ ├── deps.py # dependency injection (#2, #4)
│ │ ├── todos.py # todo router
│ │ └── auth.py # auth router (#4)
│ ├── models/ # SQLAlchemy models (#3)
│ │ └── todo.py
│ ├── schemas/ # Pydantic schemas (#2)
│ │ └── todo.py
│ └── db/ # DB connection (#3)
│ └── session.py
├── alembic/ # migrations (#3)
└── tests/ # pytest (#6)
└── test_todos.pyIn this post it’s a single app/main.py, but from the next post on we’ll fill in one directory at a time.
Environment variables — pydantic-settings
#
It’s standard not to bake settings (DB URL, JWT secret, etc.) into code, but separate them as environment variables. The standard tool in the FastAPI ecosystem is pydantic-settings.
uv add pydantic-settingsfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Todo API"
database_url: str = "sqlite+aiosqlite:///./todo.db"
jwt_secret: str = "dev-only-change-me"
jwt_expire_minutes: int = 60
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()It automatically reads from a .env file, and environment variables override whatever is there. Type validation is included, so a bad environment variable is caught at startup.
JWT_SECRET=production-secret-here
DATABASE_URL=postgresql+asyncpg://user:pw@localhost/todoExclude .env from git (add it to .gitignore). Keep it out of version control for security.
A quick recap — the result of this post #
from fastapi import FastAPI, HTTPException
from app.core.config import settings
app = FastAPI(
title=settings.app_name,
version="0.1.0",
)
@app.get("/")
def read_root() -> dict[str, str]:
return {"app": settings.app_name, "status": "ok"}
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "healthy"}uv run fastapi dev app/main.py
# → http://127.0.0.1:8000
# → http://127.0.0.1:8000/docsTwo endpoints + auto-generated docs + environment variables separated. The starting point for the next post.
Wrap-up #
What this post nailed down:
- Why FastAPI — type hints work for validation/serialization/docs/autocomplete as-is
- A single
uv add "fastapi[standard]"brings in uvicorn/pydantic/httpx - Auto-reload dev mode with
fastapi dev - Function signatures are the route spec — automatic type conversion for path/query parameters
/docs,/redoc— auto-generated OpenAPI/Swagger UI- Fine-grained validation with
Annotated[T, Path(...)],Annotated[T, Query(...)] - Returning Pydantic models — automatic serialization of datetime, etc.
- Error responses with
HTTPException - Separate environment variables with
pydantic-settings - Module/package structure from the start
The next post (#2 Routing, Pydantic, Dependency Injection) covers splitting routes with APIRouter, using Pydantic schemas in earnest, and the pattern for cleanly resolving common logic (authenticated user, DB session, etc.) across routes via dependency injection.