Modern Python FastAPI #1 Getting Started and Setup

7 min read

This is where the 21 posts of tools — Modern Python BasicsIntermediateAdvanced — 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.

DjangoFlaskFastAPI
StyleFull-stack (batteries included)MicroMicro + types
AsyncPartial (4.0+)External libraryNative
Type-hint usageAuxiliaryAuxiliaryCore behavior
Auto docs (OpenAPI)Separate librarySeparateBuilt-in
Learning curveSteepVery flatFlat
Data validationDRF + manualManualPydantic, 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.

Create the project
uv init todo-api --python 3.14
cd todo-api

Install dependencies #

Runtime 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-validatorEmailStr validation
  • python-multipart — form / file uploads
  • fastapi-cli — the fastapi CLI command

Next, dev tools.

Dev tools
uv add --dev pytest pytest-asyncio ruff pyright

On top of the toolbox from Advanced #7, the standard for FastAPI testing is pytest + httpx (covered in detail in #6).

First endpoint #

app/main.py
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 #

Dev mode
uv run fastapi dev app/main.py

What fastapi dev does:

  • Spins up uvicorn
  • Auto-reloads on code changes
  • Debug logs
  • Doesn’t expose to the LAN (127.0.0.1 only)

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 #

Multiple methods/paths
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 argument item_id: int
    • If it’s not an int, 422 Unprocessable Entity is returned automatically
  • Query parameters skip, limit → function arguments with defaults
    • Auto-parsed in the form ?skip=10&limit=20
  • 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:

🚫 Compared to 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 #

Advanced 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>= 1
  • le=10000<= 10000
  • min_length=3 — at least 3 characters
  • description=... — 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 #

Returning a dataclass / Pydantic model
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 #

Error responses
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.

Base structure (the shape this series fills in)
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.py

In 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.

Install
uv add pydantic-settings
app/core/config.py
from 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.

.env
JWT_SECRET=production-secret-here
DATABASE_URL=postgresql+asyncpg://user:pw@localhost/todo

Exclude .env from git (add it to .gitignore). Keep it out of version control for security.

A quick recap — the result of this post #

app/main.py — wrap-up 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"}
Run
uv run fastapi dev app/main.py
# → http://127.0.0.1:8000
# → http://127.0.0.1:8000/docs

Two 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.

X