SQLAlchemy

FastAPI Rebuild › Chapter 2

Database Models with SQLAlchemy

Map your existing MySQL tables to Python classes — and write your first real queries

Chapter 2 of 10 SQLAlchemy 2.0 ORM MySQL / PyMySQL python-dotenv
Chapter Milestone

By the end of this chapter you will have ORM models for all four database tables (subjects, subtopics, pages, page_content), a working database connection loaded from your .env file, a reusable session dependency for route handlers, and a test script that proves you can read your live data.

1 What SQLAlchemy does — the ORM concept

An ORM (Object-Relational Mapper) lets you work with database rows as if they were Python objects. Instead of writing SELECT * FROM subjects WHERE slug = 'japan', you write db.query(Subject).filter(Subject.slug == "japan").first() — and get back a Subject object whose attributes map directly to column values.

If you know Java / Spring Boot: SQLAlchemy is the Python equivalent of JPA + Hibernate. The DeclarativeBase class is @MappedSuperclass, mapped_column() is @Column, relationship() is @OneToMany / @ManyToOne, and SessionLocal() is the JPA EntityManager.

Java JPA vs SQLAlchemy — side by side

Java (JPA / Hibernate)Python (SQLAlchemy 2.0)
@Entityclass Subject(Base):
@Table(name="subjects")__tablename__ = "subjects"
@Id @GeneratedValuemapped_column(Integer, primary_key=True)
@Column(name="slug", unique=true)mapped_column(String(100), unique=True)
@OneToMany(mappedBy="subject")relationship("Subtopic", back_populates="subject")
@ManyToOne @JoinColumnmapped_column(ForeignKey("subjects.id"))
EntityManager.find(Subject, 1)db.get(Subject, 1)
entityManager.createQuery(...)db.execute(select(Subject))
@PersistenceContext EntityManager emDepends(get_db) in route function

2 Your existing database schema

Your MySQL database has four tables arranged in a strict hierarchy. We will create one Python class for each table. The arrows show foreign key relationships — each child table has an _id column pointing back to its parent.

subjects
idINT PK
nameVARCHAR(100)
slugVARCHAR(100)
descriptionTEXT
iconVARCHAR(50)
display_orderINT
subtopics
idINT PK
subject_idFK
nameVARCHAR(100)
slugVARCHAR(100)
descriptionTEXT
display_orderINT
pages
idINT PK
subtopic_idFK
nameVARCHAR(100)
slugVARCHAR(100)
display_orderINT
page_content
idINT PK
page_idFK
content_typeVARCHAR(50)
contentLONGTEXT
display_orderINT
Your columns may differ slightly. Check your actual table structure with DESCRIBE subjects; in MySQL and adjust the model definitions below to match. The important thing is that the __tablename__ values and primary/foreign keys match exactly — SQLAlchemy reads from the real tables, it does not create them.

3 Database connection — database.py

This file creates the database engine (the connection pool) and the session factory. It reads credentials from your .env file — nothing is hard-coded.

app/database.py python
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from dotenv import load_dotenv
import os

# Load the .env file so os.getenv() can read DB credentials
load_dotenv()

# ── Build the connection URL ─────────────────────────────────────────────
# Format: dialect+driver://user:password@host:port/database
# pymysql is the driver that talks to MySQL — it replaces JDBC in Java world
DATABASE_URL = (
    "mysql+pymysql://"
    + os.getenv("DB_USER")
    + ":"
    + os.getenv("DB_PASSWORD")
    + "@"
    + os.getenv("DB_HOST", "localhost")
    + ":"
    + os.getenv("DB_PORT", "3306")
    + "/"
    + os.getenv("DB_NAME")
)

# ── Create the engine ────────────────────────────────────────────────────
# The engine manages the connection pool — think of it as the DataSource
# bean in Spring Boot.
#
# echo=True prints every SQL query to the console (very useful while
# developing). Set echo=False before going to production.
engine = create_engine(
    DATABASE_URL,
    echo=True,
    pool_pre_ping=True,   # test connections before using them
    pool_recycle=3600,    # recycle connections after 1 hour
)

# ── Session factory ──────────────────────────────────────────────────────
# SessionLocal() creates a new database session on demand.
# autocommit=False means we control when to commit.
# autoflush=False means changes are not flushed to DB until we tell them to.
SessionLocal = sessionmaker(
    autocommit=False,
    autoflush=False,
    bind=engine,
)

# ── Base class for ORM models ────────────────────────────────────────────
# All model classes will inherit from Base.
# DeclarativeBase is the SQLAlchemy 2.0 replacement for declarative_base().
class Base(DeclarativeBase):
    pass
pool_pre_ping=True is important for MySQL. MySQL drops idle connections after a timeout (usually 8 hours). pool_pre_ping sends a lightweight SELECT 1 check before using a pooled connection, and refreshes it if it has gone stale. Without this you will get mysterious "MySQL server has gone away" errors after leaving the app idle.

4 ORM models — models.py

Each class maps to one database table. The Mapped[T] type annotation tells SQLAlchemy (and your IDE) what Python type each column holds — so you get auto-complete and type checking for free.

app/models.py python
from __future__ import annotations
from typing import List, Optional
from sqlalchemy import String, Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base


# ────────────────────────────────────────────────────────────────────────
# Subject — top level of the hierarchy (e.g. "Japan", "French", "Python")
# ────────────────────────────────────────────────────────────────────────
class Subject(Base):
    __tablename__ = "subjects"

    id:            Mapped[int]           = mapped_column(Integer, primary_key=True)
    name:          Mapped[str]           = mapped_column(String(100))
    slug:          Mapped[str]           = mapped_column(String(100), unique=True, index=True)
    description:   Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    icon:          Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
    display_order: Mapped[int]           = mapped_column(Integer, default=0)

    # One Subject has many Subtopics
    subtopics: Mapped[List["Subtopic"]] = relationship(
        "Subtopic",
        back_populates="subject",
        order_by="Subtopic.display_order",
        cascade="all, delete-orphan",
    )

    def __repr__(self) -> str:
        return f"<Subject id={self.id} slug='{self.slug}'>"


# ────────────────────────────────────────────────────────────────────────
# Subtopic — second level (e.g. Subject "Japan" → Subtopic "Kanji")
# ────────────────────────────────────────────────────────────────────────
class Subtopic(Base):
    __tablename__ = "subtopics"

    id:            Mapped[int]           = mapped_column(Integer, primary_key=True)
    subject_id:    Mapped[int]           = mapped_column(ForeignKey("subjects.id"), index=True)
    name:          Mapped[str]           = mapped_column(String(100))
    slug:          Mapped[str]           = mapped_column(String(100), index=True)
    description:   Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    display_order: Mapped[int]           = mapped_column(Integer, default=0)

    # Many Subtopics belong to one Subject
    subject: Mapped["Subject"] = relationship("Subject", back_populates="subtopics")

    # One Subtopic has many Pages
    pages: Mapped[List["Page"]] = relationship(
        "Page",
        back_populates="subtopic",
        order_by="Page.display_order",
        cascade="all, delete-orphan",
    )

    def __repr__(self) -> str:
        return f"<Subtopic id={self.id} slug='{self.slug}'>"


# ────────────────────────────────────────────────────────────────────────
# Page — third level (e.g. Subtopic "Kanji" → Page "Kanji Tiles")
# ────────────────────────────────────────────────────────────────────────
class Page(Base):
    __tablename__ = "pages"

    id:            Mapped[int]           = mapped_column(Integer, primary_key=True)
    subtopic_id:   Mapped[int]           = mapped_column(ForeignKey("subtopics.id"), index=True)
    name:          Mapped[str]           = mapped_column(String(100))
    slug:          Mapped[str]           = mapped_column(String(100), index=True)
    display_order: Mapped[int]           = mapped_column(Integer, default=0)

    # Many Pages belong to one Subtopic
    subtopic: Mapped["Subtopic"] = relationship("Subtopic", back_populates="pages")

    # One Page has many PageContent rows (body sections)
    content_sections: Mapped[List["PageContent"]] = relationship(
        "PageContent",
        back_populates="page",
        order_by="PageContent.display_order",
        cascade="all, delete-orphan",
    )

    def __repr__(self) -> str:
        return f"<Page id={self.id} slug='{self.slug}'>"


# ────────────────────────────────────────────────────────────────────────
# PageContent — the actual HTML/text body of a page
# A page can have multiple content sections (e.g. intro + vocab table)
# ────────────────────────────────────────────────────────────────────────
class PageContent(Base):
    __tablename__ = "page_content"

    id:            Mapped[int]           = mapped_column(Integer, primary_key=True)
    page_id:       Mapped[int]           = mapped_column(ForeignKey("pages.id"), index=True)
    content_type:  Mapped[str]           = mapped_column(String(50), default="html")
    content:       Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    display_order: Mapped[int]           = mapped_column(Integer, default=0)

    # Many PageContent rows belong to one Page
    page: Mapped["Page"] = relationship("Page", back_populates="content_sections")

    def __repr__(self) -> str:
        return f"<PageContent id={self.id} type='{self.content_type}'>"
Why from __future__ import annotations? Without it, Python evaluates type hints like Mapped["Subtopic"] immediately at class definition time — which fails because Subtopic hasn't been defined yet when Subject is being read. This import defers all annotation evaluation to runtime, avoiding the circular reference problem.

Understanding relationships

The relationship() call creates a Python-level link between objects — it does not add a column. When you load a Subject and access subject.subtopics, SQLAlchemy issues a second SELECT automatically. This is called lazy loading and is the default. In Chapter 4 we will use joinedload() to fetch everything in a single query when rendering a page.

back_populates keeps both sides of the relationship in sync. If you add a subtopic to subject.subtopics, the subtopic's .subject attribute is updated automatically in the same Python session.

5 The session dependency — get_db()

FastAPI's dependency injection system (using Depends()) will be your main mechanism for getting a database session into route handlers. This pattern ensures that each request gets its own session, and the session is always closed — even if an exception is raised — because it uses Python's yield (a generator-based context manager).

Add this function to app/database.py, below the Base class:

app/database.py — add at the bottom python
from typing import Generator
from sqlalchemy.orm import Session

# ── Database session dependency ──────────────────────────────────────────
# This is a FastAPI dependency. Route functions declare it like this:
#
#   @app.get("/example")
#   async def example(db: Session = Depends(get_db)):
#       subjects = db.query(Subject).all()
#       ...
#
# FastAPI calls get_db(), injects the session, runs the route, then the
# finally block closes the session. Every request gets a fresh session;
# no sessions are leaked. Compare to Spring's @Transactional annotation.
def get_db() -> Generator[Session, None, None]:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
yield vs return: using yield db makes this a generator function. FastAPI recognises generator dependencies and runs everything after the yield as clean-up code, regardless of whether the route succeeded or threw an exception. This is equivalent to Java's try-with-resources / AutoCloseable.

6 Test script — verify the connection and read live data

Before wiring the session into routes, write a quick standalone script to confirm everything connects and the models work against your real database. This script runs outside FastAPI — it just uses SQLAlchemy directly.

test_db.py  (project root — run once, then delete) python
"""
Quick database connectivity and model test.
Run from the project root with the venv active:
  python test_db.py
"""
from app.database import SessionLocal, engine
from app.models import Subject, Subtopic, Page, PageContent
from sqlalchemy import select, text

def main():
    # ── 1. Raw connection test ─────────────────────────────────────
    print("\n=== Testing raw connection ===")
    with engine.connect() as conn:
        result = conn.execute(text("SELECT 1 + 1 AS answer"))
        row = result.fetchone()
        print(f"Connection OK — SELECT 1+1 = {row.answer}")

    db = SessionLocal()
    try:
        # ── 2. List all subjects ───────────────────────────────────────
        print("\n=== All subjects ===")
        subjects = (
            db.execute(select(Subject).order_by(Subject.display_order))
            .scalars()
            .all()
        )
        for s in subjects:
            print(f"  [{s.id:3}] {s.slug:20} {s.name}")

        # ── 3. Drill into one subject ──────────────────────────────────
        # Change "japan" to a slug that actually exists in your DB
        SLUG = "japan"
        print(f"\n=== Subtopics of '{SLUG}' ===")
        subject = db.execute(
            select(Subject).where(Subject.slug == SLUG)
        ).scalar_one_or_none()

        if subject:
            for st in subject.subtopics:
                print(f"  [{st.id:3}] {st.slug:25} {st.name}")
                for pg in st.pages:
                    print(f"         → {pg.slug:22} {pg.name}")
        else:
            print(f"  (no subject with slug '{SLUG}' found)")

        # ── 4. Fetch a specific page's content ────────────────────────
        # Change to a page slug that exists in your DB
        PAGE_SLUG = "kanji-tiles"
        print(f"\n=== Content sections for page '{PAGE_SLUG}' ===")
        page = db.execute(
            select(Page).where(Page.slug == PAGE_SLUG)
        ).scalar_one_or_none()

        if page:
            print(f"  Found page: {page.name} (id={page.id})")
            print(f"  Content sections: {len(page.content_sections)}")
            for c in page.content_sections:
                # Show just the first 80 characters of each content block
                preview = (c.content or "")[:80].replace("\n", " ")
                print(f"  [type={c.content_type}] {preview}...")
        else:
            print(f"  (no page with slug '{PAGE_SLUG}' found)")

        print("\nAll tests passed.")

    finally:
        db.close()


if __name__ == "__main__":
    main()
Run from the project root with the venv active bash
(.venv) $ python test_db.py

# Expected output (your data will differ):

=== Testing raw connection ===
2024-11-01 ...: SELECT 1 + 1 AS answer          # echo=True prints SQL
Connection OK — SELECT 1+1 = 2

=== All subjects ===
  [  1] japan                Japan
  [  2] french               French
  [  3] hungarian            Hungarian
  [  4] python               Python

=== Subtopics of 'japan' ===
  [  3] japanese-lessons      Japanese Lessons
         → lesson-1            Lesson 1: Greetings
         → lesson-2            Lesson 2: Numbers
  [  4] kanji                Kanji
         → kanji-tiles         Kanji Tiles
         → kanji-links         Kanji Links

=== Content sections for page 'kanji-tiles' ===
  Found page: Kanji Tiles (id=12)
  Content sections: 1
  [type=html] <style> .kg-page { background: #1a1a1a; ...

All tests passed.

Troubleshooting common errors

Error messageMost likely cause and fix
Access denied for user '...'@'localhost' Wrong DB_USER or DB_PASSWORD in .env — double-check spelling and no extra spaces
Unknown database 'osztromok_db' DB_NAME doesn't match your actual database name — check with SHOW DATABASES;
Can't connect to MySQL server on 'localhost' MySQL isn't running locally — you need to SSH tunnel from the server first (Chapter 9 covers this)
Table 'xxx.subjects' doesn't exist __tablename__ value doesn't match your actual table name — check with SHOW TABLES;
sqlalchemy.exc.NoInspectionAvailable models.py was imported before database.py — check import order in test script
If your database is only on the server (not locally): for now, set up an SSH tunnel on your Windows machine before running the test:

ssh -L 3306:localhost:3306 your_user@your_server_ip -N

This forwards local port 3306 to MySQL on the server. Leave that terminal open and run the test in another terminal. Chapter 9 covers this properly for the migration step.

7 Import models in main.py

SQLAlchemy needs to know about all your model classes before they can be used in queries. Add this import to app/main.py so the models are always registered when the application starts up:

app/main.py — add near the top, after the existing imports python
# Import models here so SQLAlchemy registers them with the metadata.
# Even if you don't use them directly in main.py, this ensures they are
# loaded before any code tries to query the database.
from app import models  # noqa: F401

The noqa: F401 comment tells linters not to complain that models is imported but never directly referenced — it is used indirectly by SQLAlchemy's metadata.

✓ Chapter 2 Complete — Milestone reached

  • database.py — engine, SessionLocal, Base, and get_db() all in place
  • models.py — four ORM classes (Subject, Subtopic, Page, PageContent) with full relationships
  • .env credentials loaded securely via python-dotenv
  • test_db.py confirmed a live connection and returned real data from your database
  • main.py updated to import models at startup
  • Ready for Chapter 3 — Jinja2 templates that will render this data as HTML

Quick Reference — Chapter 2

db.execute(select(Subject)).scalars().all() Fetch all rows from the subjects table
db.execute(select(Subject).where(Subject.slug == x)).scalar_one_or_none() Fetch one row by a condition, or None if not found
db.get(Subject, 5) Fetch by primary key (checks session cache first)
subject.subtopics Access related objects via relationship (lazy load)
select(Subject).order_by(Subject.display_order) Add ORDER BY to a query
select(Subject).where(Subject.slug == x).limit(1) Add WHERE and LIMIT clauses
db: Session = Depends(get_db) Inject a database session into a route handler
echo=True on create_engine() Print every SQL statement to console (dev only)