πΊοΈ ORM Basics with SQLAlchemy
An Object-Relational Mapper lets you work with database rows as ordinary Python objects β no hand-written SQL for everyday work. This lesson introduces SQLAlchemy 2.0, the most capable ORM in the Python world: you'll define models, understand the Engine and Session, do CRUD with the modern select() API, and wire up relationships against PostgreSQL.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain what an ORM is and the trade-offs versus raw SQL
- Define declarative models with the modern
Mapped/mapped_columnsyntax - Describe the roles of the Engine and the Session
- Perform CRUD with
session.add,select(), andsession.get() - Model one-to-many and many-to-many relationships and query across them
- Use PostgreSQL-specific columns (
JSONB, arrays) through SQLAlchemy
Estimated Time: 55β75 minutes β’ Difficulty: Intermediate
Hands-on: Model a blog with users, posts, and tags, then query "all posts by a given author, newest first."
In This Lesson
What Is an ORM?
Object-Relational Mapping (ORM) bridges two worlds that think differently. Your Python code thinks in objects β classes, attributes, and references between them. Your database thinks in relations β tables, columns, rows, and foreign keys. An ORM translates automatically between the two, so you can write user.posts instead of a JOIN.
π‘ An analogy β the universal translator: Your app speaks "Object-Oriented"; your database speaks "Relational". Without a translator you'd constantly convert objects to SQL and SQL results back to objects, by hand. The ORM is the universal translator sitting between them: you keep speaking your native language, and it handles every conversion behind the scenes.
π Why teams reach for an ORM
Productivity: far less boilerplate for routine reads and writes.
Safety: parameterized queries by default β SQL injection is handled for you.
Maintainability: your schema lives as readable Python classes.
Portability: the same models can target PostgreSQL, MySQL, or SQLite.
β οΈ The honest trade-offs
- An ORM can hide the cost of a query β the classic N+1 problem (one query per related object) sneaks up on beginners.
- Very complex analytical SQL is sometimes clearer written by hand.
- You still need to understand SQL β the ORM writes it, but you debug it.
SQLAlchemy handles this gracefully: you can always drop down to raw SQL when the ORM isn't the right tool.
SQLAlchemy's Two Layers
SQLAlchemy is really two libraries stacked together, and you can use either or both.
models Β· Session Β· relationships] APP --> CORE[SQLAlchemy Core
SQL expressions Β· schema] ORM --> CORE CORE --> ENGINE[Engine + connection pool] ENGINE --> DB[(PostgreSQL)]
- Core β a SQL toolkit: the Engine, connection pooling, and a Pythonic SQL expression language. You can use Core alone for a lightweight, explicit approach.
- ORM β built on Core: maps classes to tables, tracks changes in a Session, and manages relationships.
This lesson focuses on the ORM, but note that the modern select() construct you'll use comes straight from Core β the two layers now share one unified querying style.
The Engine & Connection Setup
Install SQLAlchemy alongside the PostgreSQL driver:
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install "sqlalchemy[postgresql]" psycopg
The Engine is the entry point to the database. Create one per application β it owns the connection pool. Read credentials from the environment rather than hardcoding them:
import os
from sqlalchemy import create_engine
# Dialect+driver://user:password@host:port/database
url = os.environ.get(
"DATABASE_URL",
"postgresql+psycopg://appuser:dev_password@localhost:5432/blog",
)
engine = create_engine(
url,
pool_size=5, # connections kept open
max_overflow=10, # extra connections allowed under load
pool_pre_ping=True, # check a connection is alive before using it
echo=False, # set True in development to see the SQL it emits
)
π‘ Read the URL
postgresql+psycopg://... means "PostgreSQL, via the psycopg3 driver." For the older driver you'd write postgresql+psycopg2://.... The rest is user:password@host:port/dbname.
Defining Models
In SQLAlchemy 2.0, models subclass a DeclarativeBase and declare columns with typed Mapped[...] annotations plus mapped_column(...). The type hints double as real Python types and the column definition β one source of truth.
from datetime import datetime, timezone
from sqlalchemy import String, ForeignKey, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""All models inherit from this."""
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(128))
is_active: Mapped[bool] = mapped_column(default=True)
# server_default=func.now() lets the database fill the timestamp
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
# One user has many posts (the relationship, defined once)
posts: Mapped[list["Post"]] = relationship(back_populates="author")
def __repr__(self) -> str:
return f"<User {self.username!r}>"
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column() # maps to TEXT
is_published: Mapped[bool] = mapped_column(default=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
# Each post belongs to one user
author: Mapped["User"] = relationship(back_populates="posts")
def __repr__(self) -> str:
return f"<Post {self.title!r}>"
# Create the tables (reads every model that inherits from Base)
Base.metadata.create_all(engine)
π A note on Mapped[...] and nullability
The annotation carries meaning: Mapped[str] becomes NOT NULL, while Mapped[str | None] allows NULL. You rarely need to spell out nullable= anymore β the type hint decides.
Common column types
| Python annotation | SQLAlchemy type | PostgreSQL type |
|---|---|---|
Mapped[int] | Integer | INTEGER |
Mapped[str] | String / Text | VARCHAR / TEXT |
Mapped[float] | Float | DOUBLE PRECISION |
Mapped[Decimal] | Numeric | NUMERIC |
Mapped[bool] | Boolean | BOOLEAN |
Mapped[datetime] | DateTime | TIMESTAMP |
Mapped[dict] + JSONB | JSONB | JSONB |
Mapped[list[str]] + ARRAY | ARRAY | ARRAY |
The Session & CRUD
The Session is your unit of work β a staging area that tracks new, changed, and deleted objects, then flushes them to the database when you commit. Create a session factory once, bound to the engine:
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=engine)
Then open a session per unit of work with a with block. Wrapping it in Session.begin() gives you automatic commit-on-success and rollback-on-error:
Create
# begin() commits at the end of the block, or rolls back on exception
with Session.begin() as session:
user = User(
username="johndoe",
email="john@example.com",
password_hash="not_a_real_hash",
)
session.add(user)
# After flush/commit, the database-generated id is populated
# print works after commit because the object was refreshed
print(user.id)
Read β the modern select() API
SQLAlchemy 2.0 unifies querying around select(). Build a statement, then execute it with session.scalars() (which returns model objects) or session.execute() (which returns rows):
from sqlalchemy import select
with Session() as session:
# Fetch by primary key β the quickest lookup
user = session.get(User, 1)
# One row (or None)
stmt = select(User).where(User.username == "johndoe")
user = session.scalars(stmt).first()
# Many rows with filtering and ordering
stmt = (
select(User)
.where(User.is_active.is_(True))
.where(User.email.like("%@example.com"))
.order_by(User.created_at.desc())
.limit(10)
)
users = session.scalars(stmt).all()
for u in users:
print(u.username, u.email)
Update
Because the Session tracks loaded objects, updating is often just assigning an attribute β SQLAlchemy notices the change and writes it on commit:
with Session.begin() as session:
user = session.scalars(
select(User).where(User.username == "johndoe")
).first()
if user:
user.email = "john.doe@example.com" # tracked automatically
# committed on block exit
Delete
with Session.begin() as session:
user = session.get(User, 1)
if user:
session.delete(user)
β οΈ query.get() is legacy
You'll see older code use session.query(User).get(1) and .filter_by(...). That's the 1.x style. New code should prefer session.get() and select() β it's the future-proof, unified API.
Relationships
Relationships are the ORM's superpower: navigate between related objects in Python without writing JOINs. You already saw one-to-many above (a user has many posts). Here's the full picture.
Many-to-many with an association table
from sqlalchemy import Table, Column, ForeignKey
# The join table linking posts and tags
post_tag = Table(
"post_tag",
Base.metadata,
Column("post_id", ForeignKey("posts.id"), primary_key=True),
Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True)
posts: Mapped[list["Post"]] = relationship(
secondary=post_tag, back_populates="tags"
)
# Add to Post:
# tags: Mapped[list["Tag"]] = relationship(
# secondary=post_tag, back_populates="posts"
# )
Using relationships
with Session.begin() as session:
author = User(username="blogger", email="blog@example.com", password_hash="x")
# Attach posts straight to the collection β cascade saves them
author.posts = [
Post(title="First Post", content="Hello, world."),
Post(title="Second Post", content="More thoughts."),
]
session.add(author) # posts are saved along with the user
Avoiding the N+1 problem with eager loading
Looping over users and touching user.posts fires one extra query per user. Load everything up front with selectinload:
from sqlalchemy.orm import selectinload
with Session() as session:
stmt = select(User).options(selectinload(User.posts))
for user in session.scalars(stmt):
# No extra query here β posts were already loaded
print(f"{user.username}: {len(user.posts)} posts")
PostgreSQL-Specific Columns
The ORM doesn't hide PostgreSQL's best features. Import types from the postgresql dialect to use JSONB and arrays as first-class mapped columns.
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from sqlalchemy import String
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
tags: Mapped[list[str]] = mapped_column(ARRAY(String))
attributes: Mapped[dict] = mapped_column(JSONB, default=dict)
Insert Python lists and dicts directly β SQLAlchemy adapts them:
with Session.begin() as session:
session.add(Product(
name="Ergonomic Keyboard",
tags=["electronics", "office", "ergonomic"],
attributes={"layout": "split", "wireless": True},
))
And query with PostgreSQL's operators through SQLAlchemy:
with Session() as session:
# Array: products carrying a specific tag (uses PostgreSQL's ANY)
stmt = select(Product).where(Product.tags.any("office"))
office = session.scalars(stmt).all()
# JSONB containment: attributes contain {"wireless": true} (the @> operator)
stmt = select(Product).where(Product.attributes.contains({"wireless": True}))
wireless = session.scalars(stmt).all()
# JSONB field access: attributes ->> 'layout' == 'split'
stmt = select(Product).where(Product.attributes["layout"].astext == "split")
split = session.scalars(stmt).all()
β The best of both worlds
You keep the ergonomics of Python objects and reach PostgreSQL's power features. When even that isn't enough, session.execute(text("...raw SQL...")) is always available.
Hands-on Exercise
ποΈ Model a blog and query it
Objective: Build the User and Post models, create some data, and write one query.
Requirements:
- Define
User(id, username, email) andPost(id, title, content, author_id, created_at) with a one-to-many relationship. - Create one user with two posts, saved in a single transaction.
- Write a
select()query that returns all posts by that user, newest first. - Use eager loading so printing each post's author causes no extra queries.
π‘ Hint
Attach posts via user.posts = [...] and session.add(user) β the cascade saves the posts. For the query, filter on Post.author_id (or join through the relationship) and chain .order_by(Post.created_at.desc()). Add .options(selectinload(Post.author)) to preload the author.
β Solution
from datetime import datetime
from sqlalchemy import create_engine, String, ForeignKey, select, func
from sqlalchemy.orm import (
DeclarativeBase, Mapped, mapped_column, relationship,
sessionmaker, selectinload,
)
engine = create_engine("postgresql+psycopg://appuser:dev_password@localhost/blog")
Session = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column()
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
author: Mapped["User"] = relationship(back_populates="posts")
Base.metadata.create_all(engine)
# Step 2: create a user with two posts
with Session.begin() as session:
ana = User(username="ana", email="ana@example.com")
ana.posts = [
Post(title="Hello", content="First!"),
Post(title="Again", content="Second!"),
]
session.add(ana)
# Steps 3 & 4: newest-first posts by ana, with author preloaded
with Session() as session:
stmt = (
select(Post)
.join(Post.author)
.where(User.username == "ana")
.order_by(Post.created_at.desc())
.options(selectinload(Post.author))
)
for post in session.scalars(stmt):
print(f"{post.title} β by {post.author.username}") # no N+1
π― Quick Quiz
Question 1: What is the role of the SQLAlchemy Session?
Question 2: In SQLAlchemy 2.0, which is the recommended way to build a query?
Question 3: You loop over users and read user.posts, firing one query per user. What is this called, and how do you fix it?
Summary & Quiz
π Key Takeaways
- An ORM maps Python objects to database rows so you rarely hand-write SQL β with real trade-offs to stay aware of.
- SQLAlchemy has two layers: Core (Engine, SQL expressions) and the ORM (models, Session, relationships).
- Define models with
DeclarativeBase+ typedMapped/mapped_column; the annotation sets nullability. - The Session is the unit of work; query with the modern
select()API andsession.scalars(). - Relationships replace JOINs in Python β and eager loading defeats the N+1 problem.
- PostgreSQL's
JSONBand array columns are fully available through the dialect types.
π Further Reading
π What's Next?
You've now covered the relational world end to end β the database, the driver, and the ORM. Next, MongoDB Document Structure steps into NoSQL, where data lives as flexible documents instead of rows and columns.
πΊοΈ Great work!
You can model, query, and relate data with modern SQLAlchemy. Let's see how a document database thinks differently.