Project 9: Database ORM Integration
Project 9: Database ORM Integration
Build a data layer using SQLModel (Pydantic + SQLAlchemy) that shares models between API validation and database operations, with migrations and relationship handling.
Learning Objectives
By completing this project, you will:
- Understand SQLModelโs unified architecture - How it combines Pydantic and SQLAlchemy into a single model
- Master table=True vs table=False patterns - When to create database tables vs validation-only models
- Handle relationships correctly - Foreign keys, one-to-many, many-to-many with proper loading strategies
- Implement the repository pattern - Clean separation between domain logic and data access
- Manage database migrations with Alembic - Schema evolution without data loss
- Use async database operations - Non-blocking database access for high-performance APIs
Deep Theoretical Foundation
Why SQLModel? The Unification Story
Before SQLModel, developers faced a frustrating duplication problem:
WITHOUT SQLModel (The Pain):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ # Pydantic model for API validation โ
โ class UserCreate(BaseModel): โ
โ email: str โ
โ name: str โ
โ password: str โ
โ โ
โ # SQLAlchemy model for database โ
โ class User(Base): โ
โ __tablename__ = "users" โ
โ id = Column(Integer, primary_key=True) โ
โ email = Column(String, unique=True) โโโ Duplicated! โ
โ name = Column(String) โโโ Duplicated! โ
โ hashed_password = Column(String) โ
โ โ
โ # You need ANOTHER model for responses โ
โ class UserResponse(BaseModel): โ
โ id: int โ
โ email: str โโโ Duplicated again! โ
โ name: str โโโ Duplicated again! โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
WITH SQLModel (The Solution):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ class UserBase(SQLModel): โ
โ email: EmailStr # Shared fields โ
โ name: str โ
โ โ
โ class User(UserBase, table=True): # Database table โ
โ id: Optional[int] = Field(primary_key=True) โ
โ hashed_password: str โ
โ โ
โ class UserCreate(UserBase): # API input โ
โ password: str โ
โ โ
โ class UserRead(UserBase): # API output โ
โ id: int โ
โ โ
โ # Same field definitions, different purposes! โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ

SQLModel was created by Sebastiรกn Ramรญrez (creator of FastAPI) to solve this exact problem. It inherits from both Pydanticโs BaseModel and SQLAlchemyโs declarative base.
Understanding table=True vs table=False
The table parameter is the key that determines whether a SQLModel class creates a database table:
from sqlmodel import SQLModel, Field
from typing import Optional
# table=False (default): Pure Pydantic validation model
class UserBase(SQLModel):
"""No database table created. Used for validation only."""
email: str
name: str
# This class is essentially a Pydantic BaseModel
# table=True: Creates a database table
class User(UserBase, table=True):
"""Database table 'user' is created with these columns."""
id: Optional[int] = Field(default=None, primary_key=True)
hashed_password: str
# This class maps to a real database table
What happens internally:
When table=False (default):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ class UserBase(SQLModel): โ
โ โ
โ Pydantic Features: โ
โ โ Type validation โ
โ โ JSON serialization โ
โ โ Schema generation โ
โ โ
โ SQLAlchemy Features: โ
โ โ No __tablename__ โ
โ โ No Column() mappings โ
โ โ No relationship() support โ
โ โ Not registered with MetaData โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
When table=True:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ class User(SQLModel, table=True): โ
โ โ
โ Pydantic Features: โ
โ โ Type validation โ
โ โ JSON serialization โ
โ โ Schema generation โ
โ โ
โ SQLAlchemy Features: โ
โ โ __tablename__ = "user" (auto-generated) โ
โ โ id โ Column(Integer, primary_key=True) โ
โ โ email โ Column(String) โ
โ โ Registered with MetaData โ
โ โ Can use Relationship() โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Model Inheritance Pattern
The recommended pattern for SQLModel projects:
โโโโโโโโโโโโโโโโโโโโโโโ
โ UserBase โ
โ (table=False) โ
โ โ
โ - email: str โ
โ - name: str โ
โ - is_active: bool โ
โโโโโโโโโโโโฌโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโ
โ โ โ
โผ โผ โผ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโ
โ UserCreate โ โ User โ โ UserRead โ
โ (table=False) โ โ (table=True) โ โ (table=False) โ
โ โ โ โ โ โ
โ Inherits base + โ โ Inherits base + โ โ Inherits base + โ
โ + password: str โ โ + id: int โ โ + id: int โ
โ โ โ + hashed_pwd โ โ + created_at โ
โ โ โ + created_at โ โ โ
โ Used for: โ โ + orders: list โ โ Used for: โ
โ POST /users โ โ โ โ GET /users/{id} โ
โโโโโโโโโโโโโโโโโโโ โ Database table โ โโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโ

Relationship Handling and Foreign Keys
SQLModel supports SQLAlchemy-style relationships:
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List
class Team(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
# One-to-many: A team has many heroes
heroes: List["Hero"] = Relationship(back_populates="team")
class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
# Foreign key column
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
# Relationship back to Team
team: Optional[Team] = Relationship(back_populates="heroes")
Relationship types:
ONE-TO-MANY (User has many Orders):
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ User โ 1 N โ Order โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ id (PK) โ โ id (PK) โ
โ name โ โ user_id (FK)โ
โ โ โ total โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
class User(SQLModel, table=True):
id: Optional[int] = Field(primary_key=True)
orders: List["Order"] = Relationship(back_populates="user")
class Order(SQLModel, table=True):
id: Optional[int] = Field(primary_key=True)
user_id: int = Field(foreign_key="user.id")
user: Optional[User] = Relationship(back_populates="orders")
MANY-TO-MANY (Users have many Roles, Roles have many Users):
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ User โ N M โ UserRole โ M N โ Role โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโบโโโโโโโโโโโโโโโ
โ id (PK) โ โ user_id (FK)โ โ id (PK) โ
โ name โ โ role_id (FK)โ โ name โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
class UserRoleLink(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id", primary_key=True)
role_id: int = Field(foreign_key="role.id", primary_key=True)
class User(SQLModel, table=True):
id: Optional[int] = Field(primary_key=True)
roles: List["Role"] = Relationship(
back_populates="users",
link_model=UserRoleLink
)
class Role(SQLModel, table=True):
id: Optional[int] = Field(primary_key=True)
users: List["User"] = Relationship(
back_populates="roles",
link_model=UserRoleLink
)
The Repository Pattern
The repository pattern abstracts database operations behind a clean interface:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ API Layer โ
โ โ
โ @app.post("/users") โ
โ async def create_user(user: UserCreate): โ
โ return user_repo.create(user) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Repository Layer โ
โ โ
โ class UserRepository: โ
โ def create(self, data: UserCreate) -> User: ... โ
โ def get_by_id(self, id: int) -> Optional[User]: ... โ
โ def list(self, skip: int, limit: int) -> List[User]: ... โ
โ def update(self, id: int, data: UserUpdate) -> User: ... โ
โ def delete(self, id: int) -> bool: ... โ
โ โ
โ Benefits: โ
โ - Testable (can mock the repository) โ
โ - Swappable (can change database without changing API) โ
โ - Clean (business logic doesn't know about SQL) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Database Layer โ
โ โ
โ SQLModel Session โ
โ - session.add(user) โ
โ - session.commit() โ
โ - session.exec(select(User).where(...)) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Database Migrations with Alembic
Alembic manages schema changes over time:
Initial State: After Migration:
โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ
โ users โ โ users โ
โโโโโโโโโโโโโโโโโโโโโโโ โโโบ โโโโโโโโโโโโโโโโโโโโโโโ
โ id: INTEGER (PK) โ โ id: INTEGER (PK) โ
โ email: VARCHAR โ โ email: VARCHAR โ
โ name: VARCHAR โ โ name: VARCHAR โ
โ โ โ created_at: DATETIMEโ โโโ NEW!
โ โ โ is_active: BOOLEAN โ โโโ NEW!
โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ
Migration file (versions/001_add_user_fields.py):
def upgrade():
op.add_column('users', sa.Column('created_at', sa.DateTime()))
op.add_column('users', sa.Column('is_active', sa.Boolean(), default=True))
def downgrade():
op.drop_column('users', 'created_at')
op.drop_column('users', 'is_active')
Alembic workflow:
# Initialize Alembic
alembic init alembic
# Generate migration from model changes
alembic revision --autogenerate -m "Add user fields"
# Apply migrations
alembic upgrade head
# Rollback
alembic downgrade -1
# View migration history
alembic history
Async Database Operations
For high-performance APIs, use async database operations:
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
# Async engine
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
engine = create_async_engine(DATABASE_URL, echo=True)
# Async session factory
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
# Usage in FastAPI
async def get_session() -> AsyncSession:
async with async_session() as session:
yield session
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
session: AsyncSession = Depends(get_session)
):
result = await session.exec(
select(User).where(User.id == user_id)
)
user = result.first()
if not user:
raise HTTPException(404)
return user
Sync vs Async comparison:
SYNCHRONOUS (Blocking):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Request 1 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Request 2 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Request 3 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Each request blocks until database responds โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ASYNCHRONOUS (Non-blocking):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Request 1 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Request 2 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Request 3 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ Requests interleave during I/O waits โ
โ Same time, more throughput! โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
from_attributes for ORM Compatibility
When returning ORM objects from FastAPI, you need from_attributes:
from pydantic import ConfigDict
from sqlmodel import SQLModel, Field
from typing import Optional
class UserRead(SQLModel):
"""Response model that can read from ORM objects."""
id: int
email: str
name: str
# This allows Pydantic to read from SQLAlchemy objects
model_config = ConfigDict(from_attributes=True)
# Now this works:
@app.get("/users/{user_id}", response_model=UserRead)
async def get_user(user_id: int, session: Session = Depends(get_session)):
user = session.get(User, user_id) # Returns SQLModel table instance
return user # Automatically converted to UserRead
How from_attributes works:
Without from_attributes=True:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ User(table=True) instance โ
โ โโโ id = 1 โ
โ โโโ email = "john@example.com" โ
โ โโโ name = "John" โ
โ โ
โ โผ Try to create UserRead โ
โ โ
โ ERROR: Input should be a valid dictionary โ
โ (Pydantic expects dict, not ORM object) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
With from_attributes=True:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ User(table=True) instance โ
โ โโโ id = 1 โ
โ โโโ email = "john@example.com" โ
โ โโโ name = "John" โ
โ โ
โ โผ Pydantic reads attributes โ
โ โ
โ UserRead( โ
โ id=getattr(user, 'id'), โ
โ email=getattr(user, 'email'), โ
โ name=getattr(user, 'name') โ
โ ) โ
โ โ
โ SUCCESS: UserRead(id=1, email="john@example.com", ...) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Project Specification
Functional Requirements
Build a complete e-commerce data layer that demonstrates:
- User management with authentication fields
- Product catalog with categories and inventory
- Order processing with line items and status tracking
- Full CRUD through a repository pattern
- Database migrations with Alembic
- Both sync and async database operations
Data Model
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โ โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โ
โ โ Category โ 1 N โ Product โ โ User โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โ
โ โ id (PK) โ โ id (PK) โ โ id (PK) โ โ
โ โ name โ โ name โ โ email (unique)โ โ
โ โ description โ โ description โ โ name โ โ
โ โ created_at โ โ price โ โ hashed_pwd โ โ
โ โ โ โ stock_quantity โ โ is_active โ โ
โ โ โ โ category_id(FK)โ โ created_at โ โ
โ โ โ โ created_at โ โ โ โ
โ โโโโโโโโโโโโโโโโโ โโโโโโโโโฌโโโโโโโโโ โโโโโโโโโฌโโโโโโโโ โ
โ โ โ โ
โ โ N โ 1 โ
โ โ โ โ
โ โผ โผ โ
โ โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โ
โ โ OrderItem โ N 1 โ Order โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโบโโโโโโโโโโโโโโโโโ โ
โ โ id (PK) โ โ id (PK) โ โ
โ โ order_id (FK) โ โ user_id (FK) โ โ
โ โ product_id(FK) โ โ status โ โ
โ โ quantity โ โ total_amount โ โ
โ โ unit_price โ โ created_at โ โ
โ โ โ โ updated_at โ โ
โ โโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
API Endpoints
Categories:
POST /categories Create category
GET /categories List categories
GET /categories/{id} Get category with products
PATCH /categories/{id} Update category
DELETE /categories/{id} Delete category
Products:
POST /products Create product
GET /products List products (with filters)
GET /products/{id} Get product details
PATCH /products/{id} Update product
DELETE /products/{id} Delete product
POST /products/{id}/stock Adjust stock
Users:
POST /users Register user
GET /users/{id} Get user profile
PATCH /users/{id} Update user
DELETE /users/{id} Deactivate user
Orders:
POST /orders Create order
GET /orders List orders (with filters)
GET /orders/{id} Get order with items
PATCH /orders/{id}/status Update order status
DELETE /orders/{id} Cancel order
Solution Architecture
Project Structure
ecommerce-sqlmodel/
โโโ app/
โ โโโ __init__.py
โ โโโ main.py # FastAPI application
โ โโโ config.py # Settings with pydantic-settings
โ โ
โ โโโ models/ # SQLModel models
โ โ โโโ __init__.py
โ โ โโโ base.py # Base classes and common fields
โ โ โโโ user.py # User models (table + schemas)
โ โ โโโ category.py # Category models
โ โ โโโ product.py # Product models
โ โ โโโ order.py # Order and OrderItem models
โ โ
โ โโโ repositories/ # Data access layer
โ โ โโโ __init__.py
โ โ โโโ base.py # Generic repository base
โ โ โโโ user_repository.py
โ โ โโโ category_repository.py
โ โ โโโ product_repository.py
โ โ โโโ order_repository.py
โ โ
โ โโโ routes/ # API routes
โ โ โโโ __init__.py
โ โ โโโ users.py
โ โ โโโ categories.py
โ โ โโโ products.py
โ โ โโโ orders.py
โ โ
โ โโโ services/ # Business logic
โ โ โโโ __init__.py
โ โ โโโ user_service.py
โ โ โโโ product_service.py
โ โ โโโ order_service.py
โ โ
โ โโโ db/ # Database configuration
โ โโโ __init__.py
โ โโโ engine.py # Engine and session
โ โโโ init_db.py # Database initialization
โ
โโโ alembic/ # Migrations
โ โโโ versions/
โ โโโ env.py
โ โโโ alembic.ini
โ
โโโ tests/
โ โโโ __init__.py
โ โโโ conftest.py # Fixtures
โ โโโ test_models.py
โ โโโ test_repositories.py
โ โโโ test_api.py
โ
โโโ pyproject.toml
โโโ README.md
Component Diagram
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ main.py โ
โ - FastAPI app โ
โ - Exception handlers โ
โ - Lifespan events (DB init) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ routes/users.py โ โ routes/products.py โ โ routes/orders.py โ
โ - Input validation โ โ - Input validation โ โ - Input validation โ
โ - HTTP handling โ โ - Query params โ โ - Status transitions โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโ โโโโโโโโโโโโโฌโโโโโโโโโโโโ โโโโโโโโโโโโโฌโโโโโโโโโโโโ
โ โ โ
โผ โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
โ services/user_svc.py โ โ services/product_svc โ โ services/order_svc โ
โ - Business rules โ โ - Stock management โ โ - Order creation โ
โ - Password hashing โ โ - Price calculation โ โ - Total calculation โ
โโโโโโโโโโโโโฌโโโโโโโโโโโโ โโโโโโโโโโโโโฌโโโโโโโโโโโโ โโโโโโโโโโโโโฌโโโโโโโโโโโโ
โ โ โ
โผ โผ โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ repositories/ โ
โ - UserRepository - ProductRepository - OrderRepository โ
โ - Generic CRUD - Stock queries - Order with items โ
โ - SQL abstraction - Category filters - Status updates โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ db/engine.py โ
โ - SQLModel engine - Session factory - Async support โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโ
โ PostgreSQL โ
โ Database โ
โโโโโโโโโโโโโโโโโโโ
Data Flow for Order Creation
POST /orders
{
"user_id": 1,
"items": [
{"product_id": 101, "quantity": 2},
{"product_id": 102, "quantity": 1}
]
}
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Route: create_order(order_create: OrderCreate) โ
โ - Validate input with Pydantic โ
โ - Call OrderService โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OrderService.create_order() โ
โ 1. Verify user exists โ
โ 2. Verify products exist and have stock โ
โ 3. Calculate total from product prices ร quantities โ
โ 4. Create Order and OrderItems in transaction โ
โ 5. Deduct stock from products โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OrderRepository.create_with_items(order, items) โ
โ session.begin() โ
โ session.add(order) โ
โ session.add_all(order_items) โ
โ session.commit() โ
โ session.refresh(order) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Response: OrderRead โ
โ { โ
โ "id": 1, โ
โ "user_id": 1, โ
โ "status": "pending", โ
โ "total_amount": 149.97, โ
โ "items": [ โ
โ {"product_id": 101, "quantity": 2, "unit_price": 49.99}, โ
โ {"product_id": 102, "quantity": 1, "unit_price": 49.99} โ
โ ] โ
โ } โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Phased Implementation Guide
Phase 1: Project Setup and Base Models (1-2 hours)
Goal: Set up the project structure with database connection.
Step 1: Create project and install dependencies:
mkdir ecommerce-sqlmodel && cd ecommerce-sqlmodel
python -m venv venv
source venv/bin/activate
pip install fastapi uvicorn sqlmodel alembic asyncpg psycopg2-binary pydantic-settings python-dotenv
Step 2: Create database configuration:
# app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql://user:pass@localhost/ecommerce"
database_echo: bool = False
class Config:
env_file = ".env"
settings = Settings()
Step 3: Create database engine:
# app/db/engine.py
from sqlmodel import SQLModel, create_engine, Session
from app.config import settings
engine = create_engine(settings.database_url, echo=settings.database_echo)
def get_session():
with Session(engine) as session:
yield session
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
Step 4: Create base model:
# app/models/base.py
from sqlmodel import SQLModel, Field
from datetime import datetime
from typing import Optional
class TimestampMixin(SQLModel):
"""Mixin for created_at and updated_at fields."""
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: Optional[datetime] = Field(default=None)
Checkpoint: Can connect to database and create tables.
Phase 2: User Models and Repository (2 hours)
Goal: Implement complete user management with the repository pattern.
Step 1: Create user models:
# app/models/user.py
from sqlmodel import SQLModel, Field, Relationship
from pydantic import EmailStr, ConfigDict
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
if TYPE_CHECKING:
from .order import Order
# Base fields shared across models
class UserBase(SQLModel):
email: EmailStr = Field(unique=True, index=True)
name: str = Field(min_length=1, max_length=100)
is_active: bool = Field(default=True)
# Database table
class User(UserBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
hashed_password: str
created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
orders: List["Order"] = Relationship(back_populates="user")
# API Input: Create
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=100)
# API Input: Update
class UserUpdate(SQLModel):
email: Optional[EmailStr] = None
name: Optional[str] = Field(None, min_length=1, max_length=100)
password: Optional[str] = Field(None, min_length=8, max_length=100)
is_active: Optional[bool] = None
# API Output
class UserRead(UserBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
Step 2: Create user repository:
# app/repositories/user_repository.py
from sqlmodel import Session, select
from typing import Optional, List
from app.models.user import User, UserCreate, UserUpdate
import hashlib
def hash_password(password: str) -> str:
"""Simple hash for demo. Use bcrypt in production!"""
return hashlib.sha256(password.encode()).hexdigest()
class UserRepository:
def __init__(self, session: Session):
self.session = session
def create(self, user_create: UserCreate) -> User:
user = User(
email=user_create.email,
name=user_create.name,
hashed_password=hash_password(user_create.password)
)
self.session.add(user)
self.session.commit()
self.session.refresh(user)
return user
def get_by_id(self, user_id: int) -> Optional[User]:
return self.session.get(User, user_id)
def get_by_email(self, email: str) -> Optional[User]:
statement = select(User).where(User.email == email)
return self.session.exec(statement).first()
def list(self, skip: int = 0, limit: int = 100) -> List[User]:
statement = select(User).offset(skip).limit(limit)
return self.session.exec(statement).all()
def update(self, user_id: int, user_update: UserUpdate) -> Optional[User]:
user = self.get_by_id(user_id)
if not user:
return None
update_data = user_update.model_dump(exclude_unset=True)
if "password" in update_data:
update_data["hashed_password"] = hash_password(update_data.pop("password"))
for key, value in update_data.items():
setattr(user, key, value)
self.session.add(user)
self.session.commit()
self.session.refresh(user)
return user
def delete(self, user_id: int) -> bool:
user = self.get_by_id(user_id)
if not user:
return False
self.session.delete(user)
self.session.commit()
return True
Checkpoint: Can create, read, update, delete users through repository.
Phase 3: Product and Category Models (2 hours)
Goal: Implement product catalog with category relationships.
Step 1: Create category model:
# app/models/category.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
from pydantic import ConfigDict
if TYPE_CHECKING:
from .product import Product
class CategoryBase(SQLModel):
name: str = Field(min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
class Category(CategoryBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
products: List["Product"] = Relationship(back_populates="category")
class CategoryCreate(CategoryBase):
pass
class CategoryRead(CategoryBase):
id: int
created_at: datetime
product_count: int = 0
model_config = ConfigDict(from_attributes=True)
class CategoryReadWithProducts(CategoryRead):
products: List["ProductRead"] = []
Step 2: Create product model:
# app/models/product.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, TYPE_CHECKING
from datetime import datetime
from decimal import Decimal
from pydantic import ConfigDict
if TYPE_CHECKING:
from .category import Category
class ProductBase(SQLModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=2000)
price: Decimal = Field(ge=0, decimal_places=2)
stock_quantity: int = Field(ge=0, default=0)
class Product(ProductBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: Optional[datetime] = None
category: Optional["Category"] = Relationship(back_populates="products")
class ProductCreate(ProductBase):
category_id: Optional[int] = None
class ProductUpdate(SQLModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
price: Optional[Decimal] = Field(None, ge=0)
stock_quantity: Optional[int] = Field(None, ge=0)
category_id: Optional[int] = None
class ProductRead(ProductBase):
id: int
category_id: Optional[int]
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProductReadWithCategory(ProductRead):
category: Optional["CategoryRead"] = None
Checkpoint: Can manage products and categories with relationships.
Phase 4: Order Models with Transactions (2-3 hours)
Goal: Implement order processing with proper transaction handling.
Step 1: Create order models:
# app/models/order.py
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, List, TYPE_CHECKING
from datetime import datetime
from decimal import Decimal
from enum import Enum
from pydantic import ConfigDict
if TYPE_CHECKING:
from .user import User
from .product import Product
class OrderStatus(str, Enum):
pending = "pending"
confirmed = "confirmed"
processing = "processing"
shipped = "shipped"
delivered = "delivered"
cancelled = "cancelled"
class OrderItemBase(SQLModel):
quantity: int = Field(ge=1)
unit_price: Decimal = Field(ge=0, decimal_places=2)
class OrderItem(OrderItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="order.id")
product_id: int = Field(foreign_key="product.id")
order: Optional["Order"] = Relationship(back_populates="items")
product: Optional["Product"] = Relationship()
class OrderBase(SQLModel):
status: OrderStatus = Field(default=OrderStatus.pending)
total_amount: Decimal = Field(ge=0, decimal_places=2, default=0)
class Order(OrderBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: Optional[datetime] = None
user: Optional["User"] = Relationship(back_populates="orders")
items: List["OrderItem"] = Relationship(back_populates="order")
# API Input
class OrderItemCreate(SQLModel):
product_id: int
quantity: int = Field(ge=1)
class OrderCreate(SQLModel):
user_id: int
items: List[OrderItemCreate]
# API Output
class OrderItemRead(OrderItemBase):
id: int
product_id: int
model_config = ConfigDict(from_attributes=True)
class OrderRead(OrderBase):
id: int
user_id: int
created_at: datetime
items: List[OrderItemRead] = []
model_config = ConfigDict(from_attributes=True)
Step 2: Create order service with transaction:
# app/services/order_service.py
from sqlmodel import Session, select
from typing import Optional
from decimal import Decimal
from app.models.order import Order, OrderItem, OrderCreate, OrderStatus
from app.models.product import Product
from app.models.user import User
class InsufficientStockError(Exception):
def __init__(self, product_id: int, requested: int, available: int):
self.product_id = product_id
self.requested = requested
self.available = available
super().__init__(f"Product {product_id}: requested {requested}, available {available}")
class OrderService:
def __init__(self, session: Session):
self.session = session
def create_order(self, order_create: OrderCreate) -> Order:
# Verify user exists
user = self.session.get(User, order_create.user_id)
if not user:
raise ValueError(f"User {order_create.user_id} not found")
# Verify products and calculate total
total = Decimal("0.00")
order_items = []
for item in order_create.items:
product = self.session.get(Product, item.product_id)
if not product:
raise ValueError(f"Product {item.product_id} not found")
if product.stock_quantity < item.quantity:
raise InsufficientStockError(
item.product_id,
item.quantity,
product.stock_quantity
)
item_total = product.price * item.quantity
total += item_total
order_items.append({
"product": product,
"quantity": item.quantity,
"unit_price": product.price
})
# Create order
order = Order(
user_id=order_create.user_id,
total_amount=total,
status=OrderStatus.pending
)
self.session.add(order)
self.session.flush() # Get order.id without committing
# Create order items and deduct stock
for item_data in order_items:
order_item = OrderItem(
order_id=order.id,
product_id=item_data["product"].id,
quantity=item_data["quantity"],
unit_price=item_data["unit_price"]
)
self.session.add(order_item)
# Deduct stock
item_data["product"].stock_quantity -= item_data["quantity"]
self.session.add(item_data["product"])
self.session.commit()
self.session.refresh(order)
return order
def update_status(self, order_id: int, new_status: OrderStatus) -> Optional[Order]:
order = self.session.get(Order, order_id)
if not order:
return None
# Status transition validation
valid_transitions = {
OrderStatus.pending: [OrderStatus.confirmed, OrderStatus.cancelled],
OrderStatus.confirmed: [OrderStatus.processing, OrderStatus.cancelled],
OrderStatus.processing: [OrderStatus.shipped, OrderStatus.cancelled],
OrderStatus.shipped: [OrderStatus.delivered],
OrderStatus.delivered: [],
OrderStatus.cancelled: []
}
if new_status not in valid_transitions.get(order.status, []):
raise ValueError(f"Cannot transition from {order.status} to {new_status}")
order.status = new_status
self.session.add(order)
self.session.commit()
self.session.refresh(order)
return order
Checkpoint: Can create orders with stock validation and transactions.
Phase 5: Alembic Migrations (1-2 hours)
Goal: Set up and use Alembic for schema migrations.
Step 1: Initialize Alembic:
alembic init alembic
Step 2: Configure Alembic to use SQLModel:
# alembic/env.py
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from sqlmodel import SQLModel
# Import all models so they're registered with SQLModel.metadata
from app.models.user import User
from app.models.category import Category
from app.models.product import Product
from app.models.order import Order, OrderItem
# This is the Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Use SQLModel metadata
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
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()
Step 3: Generate initial migration:
alembic revision --autogenerate -m "Initial schema"
alembic upgrade head
Checkpoint: Can generate and apply database migrations.
Phase 6: FastAPI Integration and Testing (2-3 hours)
Goal: Wire everything together with FastAPI routes and tests.
Step 1: Create the main application:
# app/main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlmodel import Session
from contextlib import asynccontextmanager
from app.db.engine import create_db_and_tables, get_session
from app.routes import users, products, categories, orders
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="E-Commerce API",
description="SQLModel-powered e-commerce backend",
version="1.0.0",
lifespan=lifespan
)
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(categories.router, prefix="/categories", tags=["categories"])
app.include_router(products.router, prefix="/products", tags=["products"])
app.include_router(orders.router, prefix="/orders", tags=["orders"])
@app.get("/health")
async def health():
return {"status": "healthy"}
Step 2: Create routes:
# app/routes/users.py
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from typing import List
from app.db.engine import get_session
from app.models.user import UserCreate, UserRead, UserUpdate
from app.repositories.user_repository import UserRepository
router = APIRouter()
@router.post("", response_model=UserRead, status_code=201)
def create_user(
user: UserCreate,
session: Session = Depends(get_session)
):
repo = UserRepository(session)
if repo.get_by_email(user.email):
raise HTTPException(409, "Email already registered")
return repo.create(user)
@router.get("", response_model=List[UserRead])
def list_users(
skip: int = 0,
limit: int = 100,
session: Session = Depends(get_session)
):
repo = UserRepository(session)
return repo.list(skip, limit)
@router.get("/{user_id}", response_model=UserRead)
def get_user(user_id: int, session: Session = Depends(get_session)):
repo = UserRepository(session)
user = repo.get_by_id(user_id)
if not user:
raise HTTPException(404, "User not found")
return user
@router.patch("/{user_id}", response_model=UserRead)
def update_user(
user_id: int,
user_update: UserUpdate,
session: Session = Depends(get_session)
):
repo = UserRepository(session)
user = repo.update(user_id, user_update)
if not user:
raise HTTPException(404, "User not found")
return user
@router.delete("/{user_id}", status_code=204)
def delete_user(user_id: int, session: Session = Depends(get_session)):
repo = UserRepository(session)
if not repo.delete(user_id):
raise HTTPException(404, "User not found")
Checkpoint: Full API working with all CRUD operations.
Testing Strategy
Unit Tests for Models
# tests/test_models.py
import pytest
from pydantic import ValidationError
from app.models.user import UserCreate, UserUpdate, UserRead
from app.models.order import OrderStatus
def test_user_create_valid():
user = UserCreate(
email="test@example.com",
name="Test User",
password="securepassword123"
)
assert user.email == "test@example.com"
assert user.name == "Test User"
def test_user_create_invalid_email():
with pytest.raises(ValidationError) as exc:
UserCreate(
email="not-an-email",
name="Test",
password="password123"
)
errors = exc.value.errors()
assert any(e["loc"] == ("email",) for e in errors)
def test_user_create_short_password():
with pytest.raises(ValidationError) as exc:
UserCreate(
email="test@example.com",
name="Test",
password="short"
)
errors = exc.value.errors()
assert any(e["loc"] == ("password",) for e in errors)
def test_user_update_partial():
update = UserUpdate(name="New Name")
assert update.name == "New Name"
assert update.email is None
assert update.password is None
def test_order_status_enum():
assert OrderStatus.pending.value == "pending"
assert OrderStatus.cancelled.value == "cancelled"
Repository Tests
# tests/test_repositories.py
import pytest
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from app.models.user import UserCreate, UserUpdate
from app.repositories.user_repository import UserRepository
@pytest.fixture
def session():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
def test_create_user(session):
repo = UserRepository(session)
user = repo.create(UserCreate(
email="test@example.com",
name="Test User",
password="password123"
))
assert user.id is not None
assert user.email == "test@example.com"
assert user.hashed_password != "password123"
def test_get_user_by_email(session):
repo = UserRepository(session)
repo.create(UserCreate(
email="find@example.com",
name="Find Me",
password="password123"
))
user = repo.get_by_email("find@example.com")
assert user is not None
assert user.name == "Find Me"
def test_update_user(session):
repo = UserRepository(session)
user = repo.create(UserCreate(
email="update@example.com",
name="Original",
password="password123"
))
updated = repo.update(user.id, UserUpdate(name="Updated"))
assert updated.name == "Updated"
assert updated.email == "update@example.com"
def test_delete_user(session):
repo = UserRepository(session)
user = repo.create(UserCreate(
email="delete@example.com",
name="Delete Me",
password="password123"
))
assert repo.delete(user.id) is True
assert repo.get_by_id(user.id) is None
Integration Tests
# tests/test_api.py
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from app.main import app
from app.db.engine import get_session
@pytest.fixture
def client():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
def get_test_session():
with Session(engine) as session:
yield session
app.dependency_overrides[get_session] = get_test_session
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
def test_create_user_api(client):
response = client.post("/users", json={
"email": "api@example.com",
"name": "API User",
"password": "password123"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "api@example.com"
assert "password" not in data
assert "id" in data
def test_create_user_duplicate_email(client):
client.post("/users", json={
"email": "dupe@example.com",
"name": "First",
"password": "password123"
})
response = client.post("/users", json={
"email": "dupe@example.com",
"name": "Second",
"password": "password123"
})
assert response.status_code == 409
def test_create_order_insufficient_stock(client):
# Create user
user_resp = client.post("/users", json={
"email": "buyer@example.com",
"name": "Buyer",
"password": "password123"
})
user_id = user_resp.json()["id"]
# Create product with limited stock
cat_resp = client.post("/categories", json={"name": "Test"})
prod_resp = client.post("/products", json={
"name": "Limited Product",
"price": 10.00,
"stock_quantity": 5,
"category_id": cat_resp.json()["id"]
})
product_id = prod_resp.json()["id"]
# Try to order more than available
response = client.post("/orders", json={
"user_id": user_id,
"items": [{"product_id": product_id, "quantity": 10}]
})
assert response.status_code == 400
assert "insufficient" in response.json()["detail"].lower()
Common Pitfalls and Debugging
Pitfall 1: Forgetting table=True
Problem: Model doesnโt create database table.
# Wrong: Missing table=True
class User(SQLModel):
id: int = Field(primary_key=True)
name: str
# Correct
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
Symptom: sqlalchemy.exc.NoSuchTableError: user
Pitfall 2: Optional Primary Key
Problem: Primary key must be Optional for auto-generation.
# Wrong: Can't insert without id
class User(SQLModel, table=True):
id: int = Field(primary_key=True) # Required!
# Correct: Optional with default None
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
Pitfall 3: Circular Import with Relationships
Problem: Models reference each other causing import errors.
# Wrong: Direct import causes circular dependency
from app.models.order import Order
class User(SQLModel, table=True):
orders: List[Order] # Error!
# Correct: Use TYPE_CHECKING and string annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.order import Order
class User(SQLModel, table=True):
orders: List["Order"] = Relationship(back_populates="user")
Pitfall 4: Missing from_attributes
Problem: Canโt return ORM objects as response models.
# Wrong: Missing from_attributes
class UserRead(SQLModel):
id: int
name: str
@app.get("/users/{id}", response_model=UserRead)
def get_user(id: int, session: Session = Depends(get_session)):
return session.get(User, id) # Error!
# Correct: Add from_attributes
class UserRead(SQLModel):
id: int
name: str
model_config = ConfigDict(from_attributes=True)
Pitfall 5: N+1 Query Problem
Problem: Loading relationships causes too many queries.
# Wrong: N+1 queries when accessing order.items for each order
orders = session.exec(select(Order)).all()
for order in orders:
print(order.items) # Each access triggers a query!
# Correct: Use selectinload or joinedload
from sqlalchemy.orm import selectinload
statement = select(Order).options(selectinload(Order.items))
orders = session.exec(statement).all()
# All items loaded in 2 queries total
Pitfall 6: Session Not Refreshed
Problem: Object doesnโt have updated values after commit.
# Wrong: user.id is still None after commit
user = User(name="Test")
session.add(user)
session.commit()
print(user.id) # None!
# Correct: Refresh to get database-generated values
session.add(user)
session.commit()
session.refresh(user)
print(user.id) # 1
Pitfall 7: Alembic Not Detecting Changes
Problem: Autogenerate doesnโt see model changes.
Solution: Ensure all models are imported in alembic/env.py:
# alembic/env.py
# Import ALL models here
from app.models.user import User
from app.models.order import Order, OrderItem
from app.models.product import Product
from app.models.category import Category
target_metadata = SQLModel.metadata
Extensions and Challenges
Extension 1: Async Database Operations
Convert to async for better performance:
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/db"
engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(engine, class_=AsyncSession)
async def get_async_session() -> AsyncSession:
async with async_session() as session:
yield session
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
session: AsyncSession = Depends(get_async_session)
):
result = await session.exec(select(User).where(User.id == user_id))
return result.first()
Extension 2: Soft Deletes
Implement soft delete pattern:
class SoftDeleteMixin(SQLModel):
deleted_at: Optional[datetime] = None
is_deleted: bool = Field(default=False)
class User(UserBase, SoftDeleteMixin, table=True):
...
# Repository method
def soft_delete(self, user_id: int) -> bool:
user = self.get_by_id(user_id)
if not user:
return False
user.is_deleted = True
user.deleted_at = datetime.utcnow()
self.session.add(user)
self.session.commit()
return True
# Query excludes soft-deleted
def list_active(self):
statement = select(User).where(User.is_deleted == False)
return self.session.exec(statement).all()
Extension 3: Query Builder Pattern
Build complex queries fluently:
class ProductQueryBuilder:
def __init__(self, session: Session):
self.session = session
self.statement = select(Product)
def in_category(self, category_id: int) -> "ProductQueryBuilder":
self.statement = self.statement.where(
Product.category_id == category_id
)
return self
def in_stock(self) -> "ProductQueryBuilder":
self.statement = self.statement.where(
Product.stock_quantity > 0
)
return self
def price_range(self, min_price: Decimal, max_price: Decimal):
self.statement = self.statement.where(
Product.price.between(min_price, max_price)
)
return self
def execute(self) -> List[Product]:
return self.session.exec(self.statement).all()
# Usage
products = (
ProductQueryBuilder(session)
.in_category(1)
.in_stock()
.price_range(10, 100)
.execute()
)
Extension 4: Event Sourcing for Orders
Track all order state changes:
class OrderEvent(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="order.id")
event_type: str # "created", "confirmed", "shipped", etc.
old_status: Optional[str]
new_status: str
metadata: dict = Field(default={}, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=datetime.utcnow)
created_by: Optional[int] = None
def update_order_status(order_id: int, new_status: OrderStatus, user_id: int):
order = session.get(Order, order_id)
old_status = order.status
# Create event
event = OrderEvent(
order_id=order_id,
event_type="status_change",
old_status=old_status,
new_status=new_status,
created_by=user_id
)
session.add(event)
# Update order
order.status = new_status
session.add(order)
session.commit()
Extension 5: Full-Text Search
Add search capability with PostgreSQL:
from sqlalchemy import func
class ProductRepository:
def search(self, query: str) -> List[Product]:
# PostgreSQL full-text search
statement = select(Product).where(
func.to_tsvector('english', Product.name + ' ' + Product.description)
.match(query)
)
return self.session.exec(statement).all()
Real-World Connections
Where This Pattern Appears
- FastAPI + SQLModel - Official recommended stack
- Django ORM - Similar model-first approach
- ActiveRecord (Rails) - Models as tables pattern
- Entity Framework (.NET) - Code-first migrations
Industry Examples
- Stripe - Repository pattern for payment models
- GitHub - Event sourcing for repository events
- Shopify - Order and inventory management
- Netflix - Async database operations for streaming
Production Considerations
- Connection Pooling - Use SQLAlchemy pool settings
- Read Replicas - Route reads to replicas
- Migrations in CI/CD - Run migrations before deployment
- Backup Strategy - Regular database backups
- Monitoring - Query performance and connection metrics
Self-Assessment Checklist
Core Understanding
- Can I explain the difference between table=True and table=False?
- Can I describe how SQLModel combines Pydantic and SQLAlchemy?
- Can I explain why we separate input, output, and database models?
- Can I describe the repository pattern and its benefits?
Implementation Skills
- Can I create SQLModel tables with relationships?
- Can I implement a repository with full CRUD operations?
- Can I handle transactions for multi-step operations?
- Can I set up and run Alembic migrations?
Advanced Skills
- Can I implement async database operations?
- Can I prevent N+1 query problems?
- Can I handle circular imports between models?
- Can I write comprehensive tests for repositories?
Mastery Indicators
- Order creation properly validates and deducts stock
- Migrations generate correctly from model changes
- Tests cover both success and error cases
- API returns properly serialized responses
Resources
Documentation
Books
- โArchitecture Patterns with Pythonโ by Harry Percival & Bob Gregory
- โCosmic Pythonโ (free online) - Domain-driven design with Python
Related Projects
Videos
- Sebastiรกn Ramรญrezโs SQLModel talks
- ArjanCodes - SQLModel tutorials