Introduction
The fastest way to learn FastAPI isn’t reading the docs. It’s building one real project that hits every concept you’ll use later. This walkthrough builds a photo and video sharing app — think early-Instagram minimum — from a single empty file to a working backend with auth, a real database, file uploads, JWT-protected routes, and a Streamlit front-end for testing.
Aimed at developers who know Python but haven’t built a real API yet. By the end you’ll understand the FastAPI mental model: how routes work, what dependency injection actually does, how to model data with Pydantic and SQLAlchemy, how async session management works, and how to plug in authentication without rewriting half the codebase.
📚 Table of contents
- The big picture: front-end, API, database, auth
- Project setup with uv
- Hello world FastAPI and the auto-generated docs
- First feature: text posts with an in-memory dict
- Pydantic models for request and response validation
- Adding SQLAlchemy and async SQLite
- The data model: User, Post, foreign keys
- File uploads and media storage
- Dependency injection: sessions, current user, permissions
- Auth with FastAPI Users and JWT
- Updating and deleting posts safely (IDOR defense)
- Building the Streamlit test UI
- Common mistakes
- FAQs
The big picture: front-end, API, database, auth
Every modern web app splits into four layers. Build them in this order:
- API (FastAPI) — HTTP routes that accept requests and return JSON.
- Database (SQLAlchemy + SQLite/Postgres) — persistent storage with structured types.
- Auth (FastAPI Users + JWT) — identify who’s making each request.
- Front-end (Streamlit / React / anything) — the UI that calls your API.
For this tutorial: SQLite (file-based, zero setup), JWT-based auth, and Streamlit as the throwaway test UI. Swap any layer later without rewriting the others.
Project setup with uv
mkdir photo-app && cd photo-app
uv init .
uv add fastapi uvicorn[standard] python-multipart
uv add sqlalchemy aiosqlite
uv add fastapi-users[sqlalchemy]
uv add streamlit # for the test UI later
Hello world FastAPI and the auto-generated docs
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def hello():
return {"message": "hello world"}
uv run uvicorn app.main:app --reload
# open http://localhost:8000/docs — auto-generated Swagger UI
The auto-generated Swagger UI at /docs is FastAPI’s killer feature for
learning. Every route you add appears there, with a clickable interface to test it. No Postman
needed during development.
First feature: text posts with an in-memory dict
Skip the database temporarily. Start with a Python dict to get the route shape right:
text_posts: dict[str, str] = {}
@app.get("/posts")
def get_posts():
return text_posts
@app.post("/posts/{post_id}")
def create_post(post_id: str, content: str):
text_posts[post_id] = content
return {"ok": True}
Visit /docs, click each route, “Try it out,” see real responses. The
routes work, but type safety and validation are weak — content as a raw query
parameter falls apart the moment you need a structured body.
Pydantic models for request and response validation
Pydantic models define the shape of data crossing your API boundary. FastAPI uses them for validation, parsing, and (importantly) the auto-docs:
from datetime import datetime
from pydantic import BaseModel
class PostCreate(BaseModel):
caption: str
class PostRead(BaseModel):
id: str
caption: str
url: str
file_type: str
file_name: str
created_at: str
Now the route signature carries types. FastAPI auto-validates incoming JSON against
PostCreate and shapes the response against PostRead:
@app.post("/posts", response_model=PostRead)
def create_post(payload: PostCreate):
...
Adding SQLAlchemy and async SQLite
Dicts in memory die on restart. Move to a real database:
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DATABASE_URL = "sqlite+aiosqlite:///./app.db"
engine = create_async_engine(DATABASE_URL)
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_async_session() -> AsyncSession:
async with SessionLocal() as session:
yield session
Swap to Postgres later by changing only the DATABASE_URL — everything else is
portable.
The data model: User, Post, foreign keys
import uuid
from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, DateTime, ForeignKey
from fastapi_users.db import SQLAlchemyBaseUserTableUUID
from .db import Base
class User(SQLAlchemyBaseUserTableUUID, Base):
pass
class Post(Base):
__tablename__ = "posts"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id"))
caption: Mapped[str] = mapped_column(String(280))
url: Mapped[str] = mapped_column(String(500))
file_type: Mapped[str] = mapped_column(String(50))
file_name: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
On app startup, create the tables once:
@app.on_event("startup")
async def on_startup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
File uploads and media storage
Photos and videos arrive as multipart/form-data. FastAPI handles this with
UploadFile:
from fastapi import UploadFile, File, Form, Depends
from pathlib import Path
MEDIA = Path("media"); MEDIA.mkdir(exist_ok=True)
@app.post("/posts", response_model=PostRead)
async def create_post(
caption: str = Form(...),
file: UploadFile = File(...),
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
saved_name = f"{uuid.uuid4()}_{file.filename}"
target = MEDIA / saved_name
target.write_bytes(await file.read())
post = Post(
user_id=user.id, caption=caption,
url=f"/media/{saved_name}",
file_type=file.content_type, file_name=file.filename,
)
session.add(post)
await session.commit()
await session.refresh(post)
return PostRead(
id=str(post.id), caption=post.caption, url=post.url,
file_type=post.file_type, file_name=post.file_name,
created_at=post.created_at.isoformat(),
)
For real production use, send files to S3, R2, or ImageKit instead of disk — serverless deployments have read-only filesystems and even traditional servers don’t scale well with local uploads.
Dependency injection: sessions, current user, permissions
The Depends(...) calls above are FastAPI’s dependency injection in action. Each
dependency is a function FastAPI calls before your route runs — opens a DB session, fetches
the current user from the JWT, raises 401 if missing. Your route only runs if all dependencies
succeed.
This means your route bodies stay clean. No if not user: raise 401 at the top of
every function. The auth happens in the dependency.
Auth with FastAPI Users and JWT
FastAPI Users gives you registration, login, password reset, and a current_active_user
dependency out of the box. Wire it up with a JWT backend:
from fastapi_users import FastAPIUsers, schemas
from fastapi_users.authentication import (
AuthenticationBackend, BearerTransport, JWTStrategy,
)
from .models import User
from .user_manager import get_user_manager # standard FastAPI-Users boilerplate
SECRET = "change-me-in-production"
bearer = BearerTransport(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=SECRET, lifetime_seconds=3600)
backend = AuthenticationBackend(
name="jwt", transport=bearer, get_strategy=get_jwt_strategy,
)
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [backend])
class UserRead(schemas.BaseUser[uuid.UUID]): pass
class UserCreate(schemas.BaseUserCreate): pass
class UserUpdate(schemas.BaseUserUpdate): pass
current_active_user = fastapi_users.current_user(active=True)
Mount the standard auth routers in main.py:
app.include_router(fastapi_users.get_auth_router(backend), prefix="/auth/jwt")
app.include_router(fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth")
Free for you: POST /auth/register, POST /auth/jwt/login returning a JWT,
POST /auth/jwt/logout. The login response includes a token your frontend stores and
sends back as Authorization: Bearer ... on subsequent requests.
Updating and deleting posts safely (IDOR defense)
The most common security bug in CRUD apps: forgetting to check that the resource belongs to the
current user. Anyone can pass a different post_id and delete someone else’s
data.
from sqlalchemy import select
from fastapi import HTTPException
@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(
post_id: str,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
pid = uuid.UUID(post_id)
result = await session.execute(select(Post).where(Post.id == pid))
post = result.scalars().first()
if not post:
raise HTTPException(404, "post not found")
if post.user_id != user.id:
raise HTTPException(403, "not your post")
await session.delete(post)
await session.commit()
Two checks: existence (404) and ownership (403). Run them on every endpoint that mutates user-owned data.
Building the Streamlit test UI
Skip building a React frontend for development — Streamlit is good enough for testing every endpoint:
import streamlit as st, requests
API = "http://localhost:8000"
token = st.session_state.get("token")
if not token:
email = st.text_input("email")
pw = st.text_input("password", type="password")
if st.button("login"):
r = requests.post(f"{API}/auth/jwt/login",
data={"username": email, "password": pw})
if r.ok:
st.session_state.token = r.json()["access_token"]
st.rerun()
else:
headers = {"Authorization": f"Bearer {token}"}
f = st.file_uploader("upload photo or video")
caption = st.text_input("caption")
if st.button("post") and f:
requests.post(f"{API}/posts", headers=headers,
data={"caption": caption},
files={"file": (f.name, f.read(), f.type)})
feed = requests.get(f"{API}/posts", headers=headers).json()
for p in feed:
st.image(f"{API}{p['url']}") if "image" in p["file_type"] else st.video(f"{API}{p['url']}")
st.caption(p["caption"])
Run with uv run streamlit run ui.py. You now have a working photo-sharing app from
nothing in a few hundred lines of code.
❌ Common mistakes
- Skipping IDOR checks. A user-owned resource must verify ownership on every read/write/delete.
- Storing files on local disk in production. Use S3, R2, ImageKit, or another object store.
- Returning SQLAlchemy models directly instead of Pydantic schemas. Leaks internal fields and breaks the docs.
- Forgetting to use
response_model=. Without it, FastAPI returns whatever your function returns — including fields you didn’t mean to expose. - Hardcoding the JWT secret. Read from environment variables and rotate periodically.
- Not adding indexes on foreign-key columns. Queries slow down with no obvious cause as the table grows.
- Mixing sync and async carelessly. If you use
aiosqlite+AsyncSession, every DB call should beawaited.
💡 Pro tips
- Lean on
/docsheavily during development — faster feedback than writing curl commands. - Use Alembic for migrations the moment the schema changes more than once.
- For multi-developer projects, switch from SQLite to Postgres early. Connection-pool behavior differs enough to matter.
- Set
--reloadonly in dev. Production runsuvicornbehindgunicornwith multiple workers. - Add structured logging from day one (
structlogor stdliblogging). Debugging async code without logs is painful. - Pair FastAPI with pytest +
httpx.AsyncClientfor endpoint tests. Fast to run, catches regressions cheaply.
Conclusion
One project covers most of what you’ll do with FastAPI in real work: routes, models, database, auth, file uploads, dependency injection, security. The patterns repeat for every other API you build — only the entities change.
Next steps for the photo app: pagination, comments, likes, search, S3 upload, deployment to Render or Fly.io, React frontend instead of Streamlit. Each one is a small extension of what you already have.
Related reading
-
Building AI Agents for Production — Day 4
FastAPI routes and LangGraph in one project—the Day 4 agent backend uses the same SQLAlchemy + dependency injection patterns from this tutorial.
-
7 Python Anti-Patterns That Quietly Kill Your Code
The Python habits that show up in FastAPI projects—mutable defaults, bare excepts, and missing context managers in SQLAlchemy sessions.
-
Ghost.build: AI-Native Database for Claude Code
When the photo app’s SQLite database needs to become Postgres—Ghost gives your AI agent instant fork-based environments for safe schema migrations.