Backend Development
Hands-onIntermediate 45 min· Data · 7 of 20

ORMs and migrations

Use SQLAlchemy to work with database rows as Python objects, and Alembic to change your database schema safely over time. You build the Brew & Bean models, create migrations, query, and connect it to FastAPI.

What you will build: Python models for the Brew & Bean tables with SQLAlchemy 2, a migration history managed by Alembic, a seed script, a demo that creates, reads, updates and deletes orders, and a FastAPI endpoint that reads the menu from the database.

Prerequisites: *SQL and PostgreSQL for backend developers* (you should understand tables, foreign keys and JOIN) and *Build an API with Python and FastAPI*. Python 3.10+.

To keep setup to zero we start with a SQLite file. Moving to the Postgres from the previous lesson is a one-line change, shown at the end.

1. What is an ORM?

An ORM (object-relational mapper) lets you work with rows as objects in your language. Instead of writing INSERT INTO orders ... strings, you create an Order object and save it. The ORM generates the SQL, sends it and turns results back into objects. SQLAlchemy is the standard ORM in Python. Prisma, Drizzle, TypeORM and Sequelize are popular in the Node.js world, and Hibernate in Java.

Trade-offs

Plain SQLORM
Full control, nothing hiddenLess repetitive code, results are objects
You write every query and map every rowRelationships and tracking changes are handled for you
Easy to introduce SQL injection if carelessParameters are handled automatically
Switching database may need rewritesThe same code often runs on SQLite and Postgres

An ORM does not replace SQL knowledge. It generates SQL, and a slow ORM query is fixed by understanding the SQL behind it. A good habit is to print the generated SQL while learning.

2. A schema changes: why migrations?

Your first version of the tables will not be your last. Next month you add pickup_time. On your laptop you could delete the database and start again, but in production you cannot throw away customers' orders. A migration is a small versioned script that moves the schema from one state to the next (upgrade), and usually back (downgrade). Migration files are committed to Git with the code, so every developer and every server can bring its database to exactly the same state. Alembic is the migration tool for SQLAlchemy.

3. Set up the project

Terminal
mkdir brew-orm
cd brew-orm
python -m venv .venv
# Windows PowerShell: .venv\Scripts\Activate.ps1
# macOS / Linux: source .venv/bin/activate
pip install sqlalchemy alembic

With uv: uv init --bare, uv add sqlalchemy alembic, and put uv run in front of the commands below.

database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# SQLite file for now. For Postgres use e.g.
# "postgresql+psycopg://postgres:devpass@localhost:5432/brewbean"
DATABASE_URL = "sqlite:///brewbean.db"

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)

The engine manages connections to the database. A session is one conversation with it: you add and change objects, then commit() saves them in a single transaction.

4. Define the models

models.py
from datetime import datetime, timezone

from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


def utcnow() -> datetime:
    return datetime.now(timezone.utc)


class MenuItem(Base):
    __tablename__ = "menu_items"
    __table_args__ = (CheckConstraint("price > 0", name="ck_menu_items_price_positive"),)

    id: Mapped[str] = mapped_column(String, primary_key=True)
    name: Mapped[str] = mapped_column(String, nullable=False)
    category: Mapped[str] = mapped_column(String, nullable=False)
    price: Mapped[int] = mapped_column(Integer, nullable=False)


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    customer_name: Mapped[str] = mapped_column(String, nullable=False)
    phone: Mapped[str] = mapped_column(String, nullable=False)
    status: Mapped[str] = mapped_column(String, nullable=False, default="received")
    pickup_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)

    lines: Mapped[list["OrderLine"]] = relationship(
        back_populates="order", cascade="all, delete-orphan"
    )

    @property
    def total(self) -> int:
        return sum(line.qty * line.unit_price for line in self.lines)


class OrderLine(Base):
    __tablename__ = "order_lines"

    id: Mapped[int] = mapped_column(primary_key=True)
    order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"))
    item_id: Mapped[str] = mapped_column(ForeignKey("menu_items.id"))
    qty: Mapped[int] = mapped_column(Integer, nullable=False)
    unit_price: Mapped[int] = mapped_column(Integer, nullable=False)

    order: Mapped["Order"] = relationship(back_populates="lines")
    item: Mapped["MenuItem"] = relationship()

Each class is a table. Mapped[str] gives the Python type. relationship() lets you move between objects (order.lines, line.item) without writing JOINs. total is a computed property, not a column.

Reading the models

CodeMeaning
primary_key=TrueThe primary key; an integer one auto-increments
ForeignKey("orders.id", ondelete="CASCADE")A foreign key; deleting the order deletes its lines in the database
relationship(back_populates=...)Two-way link between Order.lines and OrderLine.order
cascade="all, delete-orphan"Saving, deleting or removing an order's lines through the ORM follows the parent
nullable=TrueThe column may be empty (NULL)
default="received"Python-side default value used when you create the object

5. Create the schema with a migration

We do not call Base.metadata.create_all() for real projects, because it cannot change an existing table. Use Alembic from day one.

  1. 1Initialise Alembic. It creates alembic.ini and a migrations folder.
  2. 2Tell Alembic about your models and database URL by editing migrations/env.py (patch below).
  3. 3Ask Alembic to compare your models with the database and write a migration (autogenerate).
  4. 4Read the generated file in migrations/versions/. Autogenerate is a draft, so always review it.
  5. 5Apply it.
Terminal
alembic init migrations
migrations/env.py (edit)
# in migrations/env.py, replace the line 'target_metadata = None' with:
from database import DATABASE_URL
from models import Base

config.set_main_option("sqlalchemy.url", DATABASE_URL)
target_metadata = Base.metadata

Keep the rest of the generated file. target_metadata is what autogenerate compares the database against.

Terminal
alembic revision --autogenerate -m "create menu, orders and order lines"
alembic upgrade head
What you should see (the revision id will differ; log lines vary by version)
INFO  [alembic.autogenerate.compare.tables] Detected added table 'menu_items'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'orders'
INFO  [alembic.autogenerate.compare.tables] Detected added table 'order_lines'
Generating .../migrations/versions/7e97a7244a4c_create_menu_orders_and_order_lines.py ... done
INFO  [alembic.runtime.migration] Running upgrade  -> 7e97a7244a4c, create menu, orders and order lines

A new file brewbean.db appeared: that is the SQLite database. Open the generated migration file and read upgrade(): it is a list of op.create_table(...) calls. Alembic also created an alembic_version table that remembers which revision the database is on.

6. Change the schema with a second migration

Add a pickup_time column to the Order model, exactly as the API needs it. In models.py add this line to Order, after the status line:

models.py (add inside class Order)
    pickup_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

It is nullable on purpose: existing rows have no pickup time, and adding a NOT NULL column without a default to a table that already has rows would fail.

Terminal
alembic revision --autogenerate -m "add pickup_time to orders"
alembic upgrade head
alembic current
What you should see
INFO  [alembic.autogenerate.compare.tables] Detected added column 'orders.pickup_time'
INFO  [alembic.runtime.migration] Running upgrade 7e97a7244a4c -> 3968a7e91350, add pickup_time to orders
3968a7e91350 (head)
Terminal
alembic downgrade -1
alembic upgrade head

downgrade -1 undoes the last migration and upgrade head reapplies it. Try this on your local database to see that migrations work both ways.

Migration habits

  1. Always read the autogenerated migration before applying it (renames look like drop-plus-add and could lose data).
  2. Commit the migration files to Git together with the model change.
  3. Never edit a migration that has already been applied elsewhere; add a new one instead.
  4. Test upgrade on a copy of production data before running it on production.
  5. Make risky changes (dropping a column, adding NOT NULL) in several small steps.

7. Seed and use the data

seed.py
from database import SessionLocal
from models import MenuItem

MENU = [
    ("espresso", "Espresso", "coffee", 120),
    ("cappuccino", "Cappuccino", "coffee", 180),
    ("cold-brew", "Cold Brew", "coffee", 220),
    ("filter-coffee", "Filter Coffee", "coffee", 80),
    ("masala-chai", "Masala Chai", "tea", 90),
    ("croissant", "Butter Croissant", "bakery", 150),
    ("banana-bread", "Banana Bread", "bakery", 130),
    ("veg-sandwich", "Veg Grilled Sandwich", "food", 160),
]

with SessionLocal() as session:
    if session.query(MenuItem).count() == 0:
        session.add_all(
            MenuItem(id=i, name=n, category=c, price=p) for i, n, c, p in MENU
        )
        session.commit()
        print("Seeded", len(MENU), "menu items")
    else:
        print("Menu already seeded")

Run with python seed.py. It only inserts when the menu is empty, so running it twice is safe.

demo.py
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload

from database import SessionLocal
from models import MenuItem, Order, OrderLine

with SessionLocal() as session:
    # Create: an order with two lines, saved in one transaction
    prices = {m.id: m.price for m in session.scalars(select(MenuItem))}
    order = Order(customer_name="Asha", phone="9876543210")
    for item_id, qty in [("cappuccino", 1), ("croissant", 2)]:
        order.lines.append(OrderLine(item_id=item_id, qty=qty, unit_price=prices[item_id]))
    session.add(order)
    session.commit()
    print("Created order", order.id, "total", order.total)

    # Read: filter, sort
    coffees = session.scalars(
        select(MenuItem).where(MenuItem.category == "coffee").order_by(MenuItem.price.desc())
    ).all()
    print([c.name for c in coffees])

    # Read one by primary key, with its lines loaded in one extra query
    found = session.scalars(
        select(Order).where(Order.id == order.id).options(selectinload(Order.lines))
    ).one()
    print(found.customer_name, [(l.item_id, l.qty) for l in found.lines])

    # Update
    found.status = "preparing"
    session.commit()

    # Aggregate: units sold per category
    rows = session.execute(
        select(MenuItem.category, func.sum(OrderLine.qty))
        .join(OrderLine, OrderLine.item_id == MenuItem.id)
        .group_by(MenuItem.category)
    ).all()
    print(rows)

    # Delete
    session.delete(found)
    session.commit()
    print("Orders left:", session.scalar(select(func.count()).select_from(Order)))

Create, read, update, aggregate and delete, all through objects. Notice that order.lines.append(...) plus one commit() saves the order and its lines in one transaction, and the price on each line comes from the database.

Terminal
python seed.py
python demo.py
What you should see (the group-by rows may come in another order)
Seeded 8 menu items
Created order 1 total 480
['Cold Brew', 'Cappuccino', 'Espresso', 'Filter Coffee']
Asha [('cappuccino', 1), ('croissant', 2)]
[('bakery', 2), ('coffee', 1)]
Orders left: 0

8. The N+1 query problem

The most common ORM performance trap. Imagine listing 50 orders and printing each one's lines. If order.lines is loaded lazily, SQLAlchemy runs 1 query for the orders and then 1 more query for each order: 51 queries. The fix is to load the relationship up front with selectinload, as in demo.py: it uses 2 queries in total however many orders there are.

Eager loading
from sqlalchemy import select
from sqlalchemy.orm import selectinload

stmt = select(Order).options(selectinload(Order.lines)).limit(50)

To see the SQL being run while you learn, create the engine with create_engine(DATABASE_URL, echo=True). Remove echo before deploying.

9. Use it from FastAPI

Each request should use its own session and close it at the end. FastAPI's dependency system does this neatly with a function that yields the session.

main.py
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from database import SessionLocal
from models import MenuItem

app = FastAPI()


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@app.get("/menu")
def get_menu(category: str | None = None, db: Session = Depends(get_db)):
    query = select(MenuItem).order_by(MenuItem.name)
    if category:
        query = query.where(MenuItem.category == category)
    items = db.scalars(query).all()
    return [
        {"id": m.id, "name": m.name, "category": m.category, "price": m.price}
        for m in items
    ]

Run with fastapi dev main.py and open /menu. The route no longer holds a Python list: it reads the table. Pydantic models for responses are covered in the validation lesson.

10. Switch to PostgreSQL

With the Postgres container from the SQL lesson running, install a driver and change one line in database.py:

Terminal
pip install "psycopg[binary]"
database.py (change)
DATABASE_URL = "postgresql+psycopg://postgres:devpass@localhost:5432/brewbean"

Use a fresh database (or DROP the earlier tables from the SQL lesson) so Alembic can create the tables, then run alembic upgrade head and python seed.py. In real projects, read the URL from an environment variable instead of writing it in the file, so the password never lands in Git.

Secrets

Never commit passwords or database URLs. Read them from environment variables (os.environ["DATABASE_URL"]) and keep a .env file out of Git with .gitignore.

Other languages

In Node.js, Prisma is a common choice: you describe models in a schema.prisma file and run npx prisma migrate dev to create and apply migrations; Drizzle and TypeORM are alternatives. The ideas are the same: models describe tables, migrations record changes, and relationships should be loaded deliberately to avoid N+1 queries.

How this comes up in interviews

Typical questions: What is an ORM and what are its drawbacks? What is the N+1 problem and how do you fix it? Why use migrations instead of editing tables by hand? What is a session or unit of work? When would you write raw SQL instead? A good answer mentions that ORMs speed up simple work, but complex reports and performance tuning often need SQL.

Prompt · Review my model and migration
You are a senior backend engineer. Review my SQLAlchemy 2 models and the Alembic migration generated from them.

Models:
---
[paste models.py]
---

Migration:
---
[paste the migration file]
---

Tell me: (1) does the migration match the models, (2) is anything dangerous for a table that already has data, (3) missing indexes or constraints, (4) possible N+1 queries. Give corrected code for each problem.

What to expect: Check that suggestions are specific (for example, adding an index on a foreign key) and that anything about data loss is explained before you apply it.

Try it: Add an index and a column

Add Index("ix_orders_status", "status") to Order (import Index from sqlalchemy and add it to __table_args__), and a new nullable column notes (a string). Generate one migration for both, read it, apply it and then downgrade and upgrade again.

Try it: Place an order function

Write place_order(session, customer_name, phone, items) where items is a list of (item_id, qty) tuples. It must reject unknown item ids with a ValueError, copy each item's current price into unit_price, save the order and its lines in one commit and return the order.

Try it: Spot the N+1

Write code that prints every order with its customer name and total. First run it with echo=True on the engine and count the queries with a few orders in the database, then add selectinload(Order.lines) and count again.

Key takeaways

  1. An ORM maps tables to classes and rows to objects; SQLAlchemy is the standard in Python.
  2. A session is a unit of work: change objects, then commit() saves them in one transaction.
  3. Migrations are versioned scripts that change the schema safely; Alembic generates them from your models and keeps a history in Git.
  4. Always review an autogenerated migration, and never edit one that is already applied elsewhere.
  5. Avoid N+1 queries by loading relationships eagerly (selectinload) and check the SQL with echo=True.
  6. Use one session per request in a web app, and keep database URLs and passwords out of code.
  7. The ORM generates SQL; you still need SQL knowledge for reports and performance.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why use Alembic migrations instead of deleting and recreating the database when the schema changes?

2. You add a NOT NULL column with no default to a table that already has rows. What is the likely problem?

3. What is the N+1 query problem?

4. In demo.py, why is commit() called once after appending lines to the order?

5. Why should autogenerated migrations always be reviewed?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free