SQLAlchemy
Database Models with SQLAlchemy
Map your existing MySQL tables to Python classes — and write your first real queries
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.
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) |
|---|---|
| @Entity | class Subject(Base): |
| @Table(name="subjects") | __tablename__ = "subjects" |
| @Id @GeneratedValue | mapped_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 @JoinColumn | mapped_column(ForeignKey("subjects.id")) |
| EntityManager.find(Subject, 1) | db.get(Subject, 1) |
| entityManager.createQuery(...) | db.execute(select(Subject)) |
| @PersistenceContext EntityManager em | Depends(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.
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.
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 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.
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}'>"
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:
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 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.
""" 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()
(.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 message | Most 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 |
ssh -L 3306:localhost:3306 your_user@your_server_ip -NThis 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:
# 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