Quick Reference · Python SQL toolkit & object–relational mapper

sqlalchemy 2.0 · 2.1

Two layers, one pipe. Core builds SQL as composable Python objects and hands them to an Engine; the ORM adds a Session that maps rows to objects and remembers what you changed. Learn where a call sits in that stack and the API stops being a list to memorize.

engine & connection schema & types SQL expressions ORM mapping session & loading async & ecosystem gotcha / legacy most common

Verified 2026-08-26 against SQLAlchemy 2.0.52 (stable) / 2.1.0b3 (beta) & cross-checked against: docs.sqlalchemy.org/en/20 · docs.sqlalchemy.org/en/21 (What’s New in 2.1) · sqlalchemy.org/download · ORM Quick Start · Unified Tutorial · Relationship Loading Techniques · asyncio extension · alembic.sqlalchemy.org · PyPI

The stack & the loop — where every call you write actually lands
A · THE STACK — PYTHON SIDE DATABASE SIDE ORM Session · mapped classes Core select() · Table · MetaData builds on Engine create_engine(url) Connection Pool reuses live sockets DBAPI driver psycopg · aiosqlite Database Postgres, MySQL… .execute() .execute() connect compile SQL rows come back as a Result → .scalars() / .all() / .mappings() B · THE SESSION — UNIT OF WORK 1 · change session.add(obj) / obj.x = 1 2 · flush INSERT / UPDATE / DELETE 3 · commit flush + COMMIT 4 · expire next access re-SELECTs autoflush expire_on_commit=True session.rollback() — and the session is clean again nothing hits the DB until step 2
quickstart — the whole 2.0-style round trip in 16 lines
# pip install "sqlalchemy[asyncio]"   # 2.1: greenlet only ships with this extra
from sqlalchemy import create_engine, select, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

class Base(DeclarativeBase): pass

class User(Base):
    __tablename__ = "user_account"
    id:   Mapped[int] = mapped_column(primary_key=True)     # NOT NULL, PK
    name: Mapped[str]                                       # NOT NULL
    bio:  Mapped[str | None]                                # | None -> NULLable
    posts: Mapped[list["Post"]] = relationship(back_populates="user")

class Post(Base):
    __tablename__ = "post"
    id:      Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))
    user:    Mapped["User"] = relationship(back_populates="posts")

engine = create_engine("sqlite+pysqlite:///app.db", echo=True)
Base.metadata.create_all(engine)

with Session(engine) as s, s.begin():                        # begin-once: commits on exit
    s.add(User(name="ada", posts=[Post(), Post()]))
    ada = s.scalars(select(User).where(User.name == "ada")).one()
    print(ada.posts)                                        # lazy-loads: 1 extra SELECT
01Install & Verifypip extras
02Engine & Database URLsone per process
03Connections & TransactionsCore level
04MetaData, Table, ColumnCore schema
05Column Typesportable → dialect
06Constraints, Indexes, DefaultsDDL detail
07Create, Drop & Reflectschema in & out
08select() — the builderCore and ORM alike
09WHERE & Operatorscolumns overload Python
10Joins, Subqueries, CTEsshaping the FROM
11Functions & Aggregatesthe func namespace
12INSERT / UPDATE / DELETECore DML
13Declarative Mappingclasses → tables
14relationship()Python-side links
15Mapping Extrasmixins · inheritance
16The Sessionshort-lived, not shared
17ORM Querying2.0 style
18Reading ResultsResult navigation
19Loading Strategiesthe N+1 cure
20Object States & Identitywhat the Session knows
21Asynciosame API, awaited
22Pooling & Performancethe boring wins
23Events & Debugginghooks and echoes
24Alembic Migrationsseparate package
25New in 2.12.1.0b3 · beta
26Legacy → Modernwhat old tutorials teach

Four pictures that prevent most SQLAlchemy bugs

Object lifecycle, query counts, where the foreign key really lives, and what shape a Result hands back.

1 · the four object states

An instance is only persistent while a Session owns it and a row exists. Everything that surprises you about ORM objects follows from this line.

transient just User(…) pending no SQL yet persistent row + identity map detached no session s.add() flush close / expunge deleted row is gone s.delete() + flush s.merge() brings it back Touching an attribute on a detached object that was expired at commit → DetachedInstanceError.

2 · N+1, and the two ways out

Same data, three strategies, wildly different query counts. Each block below is one round trip to the database for 5 users.

QUERIES EMITTED FOR: 5 users, each with their posts lazy (default) users posts posts posts posts posts = 6 1 + N — one extra SELECT per parent, fired on attribute access selectinload users posts WHERE user_id IN (…) = 2 Best default for collections — leaves the original query untouched. joinedload users LEFT OUTER JOIN posts = 1 Best for many-to-one. On a collection it duplicates parent rows — so you must call .unique() on the Result.

3 · where the foreign key lives

ForeignKey is the database constraint and sits on the many side. relationship() is pure Python and sits on both. They are not the same thing.

DATABASE — ONE COLUMN CARRIES THE LINK user_account id PK name post id PK user_id FK title ForeignKey PYTHON — TWO ATTRIBUTES, POINTED AT EACH OTHER User.posts = relationship(back_populates="user") → list[Post] Post.user = relationship(back_populates="posts") → User

4 · what comes back from execute()

Whether you get objects or tuples is decided by what you put inside select() — not by which method you call afterwards.

select(User) select(User.id, User.name) s.scalars(stmt) s.execute(stmt) .all() .all() [User, User, …] u.name works [Row(1, 'ada'), …] row.name or row[0] Exactly one row expected? .scalar_one() / .one() — they raise instead of silently truncating. Want dicts for JSON? .mappings().all().

Appendix — moving 1.4 → 2.0 → 2.1

What to adopt at each step, and what stops working. 2.0 was the API break; 2.1 is mostly behavioural, but two of its changes bite on day one.

1.4the bridge release

✚ adopt

  • SQLALCHEMY_WARN_20=1 — surfaces every 2.0-incompatible call as a warning
  • select() and session.execute() already work here — migrate before upgrading
  • future=True on engine and Session opts into 2.0 behaviour early
  • New unified Result / Row objects across Core and ORM

⚠ changed

  • Query is now a thin wrapper over select() — behaviour, not just style
  • Autocommit mode deprecated; transactions become explicit
2.0typing & the new API

✚ adopt

  • DeclarativeBase + Mapped[] + mapped_column() — PEP 484 all the way down, no mypy plugin
  • select() everywhere; session.scalars() / session.get()
  • Native asyncio: create_async_engine, AsyncSession, AsyncAttrs
  • MappedAsDataclass for real dataclass mappings
  • "insertmanyvalues" batching — large INSERTs get much faster for free

⚠ removed

  • engine.execute() and all connectionless execution
  • Session(autocommit=True)
  • select([cols]) list form
  • Implicit string coercion — wrap raw SQL in text()
2.1beta · 2.1.0b3

✚ adopt

  • tstring() for PEP 750 t-strings (Python 3.14+)
  • CreateView, CreateTableAs, Delete.using(), Select.ext()
  • Select[int, str] — PEP 646 typing, no more Tuple[]
  • TypedColumns so Table.c is statically typed
  • back_populates=lambda: B.a instead of magic strings
  • Session-level execution_options that reach flushes too

⚠ watch out

  • greenlet is not installed unless you use sqlalchemy[asyncio]
  • postgresql:// now means psycopg 3 — pin +psycopg2 to keep the old driver
  • Autoflush fires on every execute, including raw text()
  • filter_by() can now raise AmbiguousColumnError
  • Dataclass default no longer lands in __dict__ (DONT_SET)
  • composite() returns an object where 2.0 returned None
  • Computed() is VIRTUAL by default on PostgreSQL 18+
  • Requires Python 3.10+; inherit_schema deprecated

Worth memorizing

flush ≠ commitflush emits SQL; commit ends the transaction
scalars() vs execute()objects vs Row tuples — decided by what's inside select()
Mapped[str]NOT NULL  ·  Mapped[str | None] is what makes it nullable
FK on the many siderelationship() is Python-only; ForeignKey is the constraint
back_populates twicename each side from the other, or the halves drift apart
N+1lazy is the default — reach for selectinload on collections
joinedload + collectionyou must call .unique() on the Result
expire_on_commit=Truethe default; every attribute re-SELECTs after commit()
Engine global, Session notone Engine per process, one Session per request/task
Session isn't thread-safeand it isn't task-safe either
failed flush → rollbackthe Session stays unusable until you do
pool_pre_ping=Truethe one-line fix for "server closed the connection"
delete-orphangoes on the one-to-many side only
text() for raw SQLbind params with :name — never f-string user input
create_all ≠ migrateit creates missing tables; it never ALTERs — use Alembic
MissingGreenleta lazy load under async — eager-load or use awaitable_attrs
2.1: sqlalchemy[asyncio]greenlet no longer ships in the default install
2.1: postgresql://now resolves to psycopg 3, not psycopg2