Why Database?
So far data lives in a Python list — when server restarts, everything is gone. Real applications need permanent storage.
In this stage we connect FastAPI to a real database using SQLAlchemy — the most popular Python ORM.
What is an ORM?
ORM = Object Relational Mapper
Instead of writing raw SQL:
SELECT * FROM users WHERE id = 1;
You write Python:
db.query(User).filter(User.id == 1).first()
ORM translates Python code to SQL automatically. You already know this concept — TypeORM in NestJS is the same idea.
Tools We'll Use
- SQLite — database (comes with Python, zero setup, perfect for learning)
- SQLAlchemy — ORM (translates Python to SQL)
- Alembic — database migrations (we'll keep this simple)
Later you can swap SQLite for PostgreSQL with just one line change — same code works.
Installing Dependencies
pip install sqlalchemy
That's it for SQLite. When you switch to PostgreSQL later:
pip install psycopg2-binary
Project Structure
Before writing code — let's organize properly. This is real project structure:
fastapi-learning/
├── venv/
├── main.py
├── database.py ← database connection setup
├── models.py ← SQLAlchemy table models
├── schemas.py ← Pydantic models (request/response)
├── crud.py ← database operations (create, read, update, delete)
└── requirements.txt
This separation is standard practice. Each file has one clear responsibility — same principle as NestJS modules.
Step 1 — database.py — Database Connection
# database.py
from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker
# SQLite database URL # This creates a file called 'app.db' in your project folder DATABASE_URL = "sqlite:///./app.db"
# For PostgreSQL later — just change this one line: # DATABASE_URL = "postgresql://username:password@localhost/dbname"
# Create engine — the core connection to database engine = create_engine( DATABASE_URL, connect_args={"check_same_thread": False} # needed only for SQLite )
# SessionLocal — each request gets its own database session SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base — all your database models will inherit from this Base = declarative_base()
# Dependency — gives a database session to each route # Automatically closes session when request is done def get_db(): db = SessionLocal() try: yield db finally: db.close()
Think of get_db as middleware that gives each request its own database connection and closes it cleanly when done.
Step 2 — models.py — Database Tables
SQLAlchemy models define your database tables:
# models.py
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text from sqlalchemy.orm import relationship from sqlalchemy.sql import func from database import Base
class User(Base): __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) name = Column(String(50), nullable=False) email = Column(String(100), unique=True, nullable=False, index=True) age = Column(Integer, nullable=False) city = Column(String(50), default="Unknown") is_active = Column(Boolean, default=True) created_at = Column(DateTime, server_default=func.now())
# Relationship — one user has many posts posts = relationship("Post", back_populates="author")
class Post(Base): __tablename__ = "posts"
id = Column(Integer, primary_key=True, index=True) title = Column(String(100), nullable=False) content = Column(Text, nullable=False) published = Column(Boolean, default=False) created_at = Column(DateTime, server_default=func.now())
# Foreign key — connects post to user author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Relationship — each post belongs to one user author = relationship("User", back_populates="posts")
This is very similar to TypeORM entities you write in NestJS. Column types, constraints, relationships — same concepts.
Key column options:
|
Option |
Meaning |
|
primary_key=True |
This is the primary key |
|
nullable=False |
Required
field, cannot be NULL |
|
unique=True |
No duplicate values allowed |
|
index=True |
Creates
database index for faster queries |
|
default= |
Default value in Python |
|
server_default= |
Default
value set by database |
Step 3 — schemas.py — Pydantic Models
These are the same Pydantic models from Stage 3 — request and response shapes:
# schemas.py
from pydantic import BaseModel, Field, field_validator from datetime import datetime
# ── User Schemas ─────────────────────────────────
class UserCreate(BaseModel): name: str = Field(min_length=2, max_length=50) email: str = Field(examples=["gagan@email.com"]) age: int = Field(ge=0, le=150) city: str = Field(default="Unknown")
@field_validator("email") @classmethod def normalize_email(cls, value: str) -> str: return value.lower().strip()
class UserUpdate(BaseModel): name: str | None = Field(default=None, min_length=2) email: str | None = None age: int | None = Field(default=None, ge=0, le=150) city: str | None = None
class UserResponse(BaseModel): id: int name: str email: str age: int city: str is_active: bool created_at: datetime
model_config = {"from_attributes": True} # allows reading SQLAlchemy objects
# ── Post Schemas ─────────────────────────────────
class PostCreate(BaseModel): title: str = Field(min_length=5, max_length=100) content: str = Field(min_length=20) author_id: int
class PostResponse(BaseModel): id: int title: str content: str published: bool author_id: int created_at: datetime
model_config = {"from_attributes": True}
class PostWithAuthor(BaseModel): id: int title: str content: str published: bool created_at: datetime author: UserResponse # nested user response
model_config = {"from_attributes": True}
Step 4 — crud.py — Database Operations
This is where all database queries live. Separating them here keeps routes clean:
# crud.py
from sqlalchemy.orm import Session from sqlalchemy import or_ import models import schemas
# ── User CRUD ─────────────────────────────────────
def get_user(db: Session, user_id: int): return db.query(models.User).filter(models.User.id == user_id).first()
def get_user_by_email(db: Session, email: str): return db.query(models.User).filter(models.User.email == email).first()
def get_users(db: Session, skip: int = 0, limit: int = 10): return db.query(models.User).offset(skip).limit(limit).all()
def search_users(db: Session, name: str | None = None, city: str | None = None): query = db.query(models.User)
if name: query = query.filter(models.User.name.ilike(f"%{name}%")) if city: query = query.filter(models.User.city.ilike(f"%{city}%"))
return query.all()
def create_user(db: Session, user: schemas.UserCreate): db_user = models.User(**user.model_dump()) db.add(db_user) db.commit() db.refresh(db_user) return db_user
def update_user(db: Session, user_id: int, user: schemas.UserUpdate): db_user = get_user(db, user_id) if not db_user: return None
update_data = user.model_dump(exclude_none=True) for field, value in update_data.items(): setattr(db_user, field, value)
db.commit() db.refresh(db_user) return db_user
def delete_user(db: Session, user_id: int): db_user = get_user(db, user_id) if not db_user: return None db.delete(db_user) db.commit() return db_user
# ── Post CRUD ─────────────────────────────────────
def get_post(db: Session, post_id: int): return db.query(models.Post).filter(models.Post.id == post_id).first()
def get_posts( db: Session, skip: int = 0, limit: int = 10, published: bool | None = None, author_id: int | None = None ): query = db.query(models.Post)
if published is not None: query = query.filter(models.Post.published == published) if author_id is not None: query = query.filter(models.Post.author_id == author_id)
return query.offset(skip).limit(limit).all()
def create_post(db: Session, post: schemas.PostCreate): db_post = models.Post(**post.model_dump()) db.add(db_post) db.commit() db.refresh(db_post) return db_post
def publish_post(db: Session, post_id: int): db_post = get_post(db, post_id) if not db_post: return None db_post.published = True db.commit() db.refresh(db_post) return db_post
def delete_post(db: Session, post_id: int): db_post = get_post(db, post_id) if not db_post: return None db.delete(db_post) db.commit() return db_post
Understanding the key operations:
Query pattern:
db.query(Model) # start query on this table
.filter(Model.field == value) # WHERE clause
.offset(skip) # skip N rows (pagination)
.limit(limit) # take only N rows
.all() # fetch all results as list
.first() # fetch first result or None
Create pattern:
db_object = Model(**data) # create model instance
db.add(db_object) # stage for insertion
db.commit() # actually insert to database
db.refresh(db_object) # reload from database (gets auto-generated id, created_at etc.)
return db_object
Update pattern:
setattr(db_object, field, value) # update field value
db.commit() # save changes
db.refresh(db_object) # reload updated data
Step 5 — main.py — Routes
Now routes are clean — they just call crud functions:
# main.py
from fastapi import FastAPI, HTTPException, Depends, status from sqlalchemy.orm import Session import models import schemas import crud from database import engine, get_db
# Create all tables in database automatically models.Base.metadata.create_all(bind=engine)
app = FastAPI(title="Users & Posts API", version="3.0.0")
# ── User Routes ───────────────────────────────────
@app.get("/") def root(): return {"message": "API is running"}
@app.post("/users", response_model=schemas.UserResponse, status_code=201) def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)): # Check duplicate email existing = crud.get_user_by_email(db, user.email) if existing: raise HTTPException(status_code=400, detail="Email already registered")
return crud.create_user(db, user)
@app.get("/users", response_model=list[schemas.UserResponse]) def get_users(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): return crud.get_users(db, skip, limit)
@app.get("/users/search", response_model=list[schemas.UserResponse]) def search_users( name: str | None = None, city: str | None = None, db: Session = Depends(get_db) ): return crud.search_users(db, name, city)
@app.get("/users/{user_id}", response_model=schemas.UserResponse) def get_user(user_id: int, db: Session = Depends(get_db)): user = crud.get_user(db, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") return user
@app.patch("/users/{user_id}", response_model=schemas.UserResponse) def update_user(user_id: int, user: schemas.UserUpdate, db: Session = Depends(get_db)): updated = crud.update_user(db, user_id, user) if not updated: raise HTTPException(status_code=404, detail="User not found") return updated
@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_user(user_id: int, db: Session = Depends(get_db)): deleted = crud.delete_user(db, user_id) if not deleted: raise HTTPException(status_code=404, detail="User not found")
# ── Post Routes ───────────────────────────────────
@app.post("/posts", response_model=schemas.PostResponse, status_code=201) def create_post(post: schemas.PostCreate, db: Session = Depends(get_db)): # Check author exists author = crud.get_user(db, post.author_id) if not author: raise HTTPException(status_code=404, detail="Author not found") return crud.create_post(db, post)
@app.get("/posts", response_model=list[schemas.PostResponse]) def get_posts( skip: int = 0, limit: int = 10, published: bool | None = None, author_id: int | None = None, db: Session = Depends(get_db) ): return crud.get_posts(db, skip, limit, published, author_id)
@app.get("/posts/{post_id}", response_model=schemas.PostWithAuthor) def get_post(post_id: int, db: Session = Depends(get_db)): post = crud.get_post(db, post_id) if not post: raise HTTPException(status_code=404, detail="Post not found") return post
@app.post("/posts/{post_id}/publish", response_model=schemas.PostResponse) def publish_post(post_id: int, db: Session = Depends(get_db)): post = crud.publish_post(db, post_id) if not post: raise HTTPException(status_code=404, detail="Post not found") return post
@app.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_post(post_id: int, db: Session = Depends(get_db)): deleted = crud.delete_post(db, post_id) if not deleted: raise HTTPException(status_code=404, detail="Post not found")
Understanding Depends(get_db)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
Depends(get_db) is FastAPI's Dependency Injection. Every time this route is called:
- FastAPI calls
get_db() get_db()creates a database session- Passes that session as
dbto your function - After function finishes —
get_db()automatically closes the session
You already know this pattern from NestJS — it's constructor injection. Same concept, FastAPI style.
Running and Testing
uvicorn main:app --reload
When you run this the first time — SQLAlchemy automatically creates app.db file and all tables inside it. No manual SQL needed.
Check /docs — test this flow:
1. Create a user:
POST /users
{
"name": "Gagan Singh",
"email": "gagan@email.com",
"age": 22,
"city": "Delhi"
}
2. Create a post for that user:
POST /posts
{
"title": "Learning FastAPI",
"content": "FastAPI is an amazing framework for building APIs with Python",
"author_id": 1
}
3. Get post with author details:
GET /posts/1
Response includes full author object nested inside:
{
"id": 1,
"title": "Learning FastAPI",
"content": "...",
"published": false,
"created_at": "2025-03-03T10:30:00",
"author": {
"id": 1,
"name": "Gagan Singh",
"email": "gagan@email.com",
"age": 22,
"city": "Delhi",
"is_active": true,
"created_at": "2025-03-03T10:29:00"
}
}
4. Publish the post:
POST /posts/1/publish
5. Restart server — data is still there. Unlike the in-memory list — SQLite persists everything.
Switching to PostgreSQL
When you're ready for production — just change one line in database.py:
# SQLite (development)
DATABASE_URL = "sqlite:///./app.db"
# PostgreSQL (production) — same as your current NestJS setup
DATABASE_URL = "postgresql://username:password@localhost/dbname"
And install the driver:
pip install psycopg2-binary
Everything else — models, crud, routes — stays exactly the same.
Viewing Your Database
Since it's SQLite you can view the app.db file using:
- DB Browser for SQLite — free GUI tool — sqlitebrowser.org
- VS Code SQLite extension — search "SQLite Viewer" in extensions
This lets you see your tables and data visually — like pgAdmin for PostgreSQL.
Common SQLAlchemy Queries — Reference
# Get all
db.query(Model).all()
# Get by id
db.query(Model).filter(Model.id == id).first()
# Filter with multiple conditions
db.query(Model).filter(
Model.age > 18,
Model.city == "Delhi"
).all()
# OR condition
from sqlalchemy import or_
db.query(Model).filter(
or_(Model.name == "Gagan", Model.name == "Rahul")
).all()
# Case insensitive search (like ILIKE in PostgreSQL)
db.query(Model).filter(Model.name.ilike("%gagan%")).all()
# Order by
db.query(Model).order_by(Model.created_at.desc()).all()
# Count
db.query(Model).filter(Model.is_active == True).count()
# Pagination
db.query(Model).offset(skip).limit(limit).all()
# Create
obj = Model(field1=val1, field2=val2)
db.add(obj)
db.commit()
db.refresh(obj)
# Update
obj.field = new_value
db.commit()
# Delete
db.delete(obj)
db.commit()
Exercise 🏋️
Extend the current project — add a Products feature:
Add to models.py:
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True)
name = Column(String(50), unique=True, nullable=False)
products = relationship("Product", back_populates="category")
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True)
name = Column(String(100), nullable=False)
price = Column(Float, nullable=False)
stock = Column(Integer, default=0)
category_id = Column(Integer, ForeignKey("categories.id"))
category = relationship("Category", back_populates="products")
Build these routes:
POST /categories— create categoryGET /categories— list all categoriesPOST /products— create product (validate category exists)GET /products— list products with filters:category_id,min_price,max_price,in_stockGET /products/{id}— get product with category details nestedPATCH /products/{id}— update price or stockDELETE /products/{id}— delete product
Add to schemas.py:
CategoryCreate,CategoryResponseProductCreate,ProductResponse,ProductWithCategory