FastAPI in Practice: Clean Project Structure, Settings, and Database Migrations (Hands-On)
FastAPI makes it easy to ship an API quickly… and just as easy to end up with a single main.py file that grows into a monster. In this hands-on guide, you’ll build a small-but-real FastAPI app with a clean structure, environment-based settings, SQLAlchemy models, and Alembic migrations. The goal: a setup you can copy into real projects without overengineering.
What you’ll build
- A FastAPI app with a sane folder layout
- Config managed via environment variables (dev vs prod)
- SQLAlchemy models + a database session dependency
- Alembic migrations to evolve your schema safely
- Two endpoints: create a note, list notes
Project layout
Create this structure:
fastapi-notes/ app/ __init__.py main.py core/ __init__.py config.py db.py models/ __init__.py note.py schemas/ __init__.py note.py api/ __init__.py routes/ __init__.py notes.py alembic/ alembic.ini .env requirements.txt
This splits responsibilities:
core/for configuration and database setupmodels/for SQLAlchemy modelsschemas/for Pydantic request/response shapesapi/routes/for routers (endpoints)
Install dependencies
Use a virtual environment, then add these packages:
pip install fastapi uvicorn[standard] sqlalchemy alembic pydantic-settings python-dotenv
Create requirements.txt if you want:
pip freeze > requirements.txt
Environment-based settings
FastAPI projects often hardcode a database URL early on, then struggle later when deploying. Instead, define settings once and load them from .env locally.
Create .env:
APP_NAME=FastAPI Notes ENV=dev DATABASE_URL=sqlite:///./notes.db
Now create app/core/config.py:
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): app_name: str = "FastAPI App" env: str = "dev" database_url: str model_config = SettingsConfigDict( env_file=".env", env_prefix="", case_sensitive=False, ) settings = Settings()
Why this helps:
- Local dev uses
.envautomatically - Production can set real environment variables (no file needed)
- Your code never needs to change between environments
Database engine + session dependency
Next, set up SQLAlchemy and expose a dependency you can reuse in routes.
Create app/core/db.py:
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase from .config import settings # SQLite needs this flag for multithreaded access in dev. connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) class Base(DeclarativeBase): pass def get_db(): db = SessionLocal() try: yield db finally: db.close()
This get_db() generator is the standard pattern: FastAPI opens a session per request, then closes it reliably.
Create a model
Let’s store simple notes with a title, optional body, and a timestamp.
Create app/models/note.py:
from sqlalchemy import String, Text, DateTime, func from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base class Note(Base): __tablename__ = "notes" id: Mapped[int] = mapped_column(primary_key=True, index=True) title: Mapped[str] = mapped_column(String(200), nullable=False) body: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
Note the use of server_default=func.now() so the database sets the time consistently.
Create Pydantic schemas
Schemas are how you keep your API contract clean: request shapes differ from response shapes, and you don’t want to expose internal fields accidentally.
Create app/schemas/note.py:
from pydantic import BaseModel, Field from datetime import datetime class NoteCreate(BaseModel): title: str = Field(min_length=1, max_length=200) body: str | None = None class NoteOut(BaseModel): id: int title: str body: str | None created_at: datetime class Config: from_attributes = True
from_attributes = True lets Pydantic read SQLAlchemy objects directly.
Set up Alembic migrations
Migrations are non-negotiable for real projects. Creating tables via Base.metadata.create_all() is fine for demos, but it doesn’t track changes over time.
Initialize Alembic in the project root (where alembic.ini will live):
alembic init alembic
Now edit alembic.ini and set the database URL, or better: wire it to your settings.
Edit alembic/env.py (this is the key part). Replace the config URL section and import your metadata:
from logging.config import fileConfig from sqlalchemy import engine_from_config, pool from alembic import context from app.core.config import settings from app.core.db import Base from app.models.note import Note # ensure model is imported so metadata is populated config = context.config fileConfig(config.config_file_name) # Override Alembic's sqlalchemy.url with our Settings config.set_main_option("sqlalchemy.url", settings.database_url) target_metadata = Base.metadata def run_migrations_offline(): url = config.get_main_option("sqlalchemy.url") context.configure(url=url, target_metadata=target_metadata, literal_binds=True) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): connectable = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online()
Create your first migration:
alembic revision --autogenerate -m "create notes table"
Apply it:
alembic upgrade head
You should now have a notes.db SQLite file and an actual schema created via migration.
Build routes with a router
Routers keep main.py small and make it easy to expand your API.
Create app/api/routes/notes.py:
from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session from app.core.db import get_db from app.models.note import Note from app.schemas.note import NoteCreate, NoteOut router = APIRouter(prefix="/notes", tags=["notes"]) @router.post("", response_model=NoteOut, status_code=status.HTTP_201_CREATED) def create_note(payload: NoteCreate, db: Session = Depends(get_db)): note = Note(title=payload.title, body=payload.body) db.add(note) db.commit() db.refresh(note) return note @router.get("", response_model=list[NoteOut]) def list_notes(db: Session = Depends(get_db), limit: int = 50): return db.query(Note).order_by(Note.id.desc()).limit(limit).all()
Small improvements you can add later: pagination with offset, filtering, and better ordering with timestamps.
Wire everything in main.py
Create app/main.py:
from fastapi import FastAPI from app.core.config import settings from app.api.routes.notes import router as notes_router app = FastAPI(title=settings.app_name) app.include_router(notes_router) @app.get("/health") def health(): return {"status": "ok", "env": settings.env}
Run it locally
Start the server from the project root:
uvicorn app.main:app --reload
Open the interactive docs:
/docs(Swagger UI)/redoc(ReDoc)
Try creating a note:
curl -X POST "http://127.0.0.1:8000/notes" \ -H "Content-Type: application/json" \ -d '{"title":"First note","body":"Hello from FastAPI"}'
List notes:
curl "http://127.0.0.1:8000/notes?limit=10"
Common junior mistakes (and how this structure prevents them)
-
Mixing models, schemas, and routes in one file: splitting them reduces circular imports and keeps files readable.
-
Hardcoding secrets: settings via env vars keeps credentials out of Git and makes deployment simpler.
-
No migrations: Alembic gives you a history of schema changes and safe upgrades/downgrades.
-
Leaking DB sessions: the
get_dbdependency ensures sessions are closed even on errors.
Next steps you can add without rewriting everything
- Add service layer functions (e.g.,
app/services/notes.py) if routes grow complex - Add proper pagination (limit/offset or cursor-based)
- Add validation for uniqueness or length constraints at the DB level too
- Introduce testing (FastAPI TestClient + a separate test database)
- Swap SQLite for Postgres by changing
DATABASE_URL(your structure stays the same)
With this setup, you’ve got a practical FastAPI foundation: clean files, repeatable config, real migrations, and routes that stay maintainable as your API grows.
Leave a Reply