pip install sqlalchemy★Core + ORM. 2.0.52 is the current stable line.pip install "sqlalchemy[asyncio]"2.1greenletno longer installs by default — async needs this extra.pip install "sqlalchemy[postgresql]"Driver extras:postgresql,mysql,oracle,mssql,aiosqlite…pip install alembic★Schema migrations — a separate package, same authors.import sqlalchemy; sqlalchemy.__version__Confirm which series you are actually on.
engine = create_engine(url)★Lazy — no connection is opened until first use."sqlite+pysqlite:///file.db"sqlite://alone = in-memory, per-connection."postgresql+psycopg://u:p@host/db"2.1Barepostgresql://now defaults to psycopg 3, not psycopg2."mysql+pymysql://u:p@host/db"MySQL / MariaDB. Alsomysqldb,asyncmy."oracle+oracledb://…" · "mssql+pyodbc://…"2.1Oracle default is nowpython-oracledb; newmssql+mssqlpython.URL.create("postgresql", username=…, password=…)★Builds the URL safely — no manual escaping of@ : /.create_engine(url, echo=True)★Log every statement to stdout.echo="debug"adds result rows.engine.dispose()Close the pool — call afterfork(), never mid-request.
with engine.connect() as conn:"Commit as you go" — rolls back on exit unless you commit.with engine.begin() as conn:★"Begin once" — commits on clean exit, rolls back on exception.conn.execute(stmt, params)★Returns aCursorResult. Pass a list of dicts for executemany.conn.execute(text("SELECT * FROM t WHERE id=:i"), {"i": 1})Raw SQL with bound params — never f-string user input.conn.commit() · conn.rollback()Explicit control insideengine.connect().conn.begin_nested()SAVEPOINT — a rollback point inside the outer transaction.conn.execution_options(stream_results=True)Server-side cursor for very large result sets.conn.exec_driver_sql("…")Straight to the DBAPI — no SQLAlchemy compilation at all.
md = MetaData()★The registry of every Table you define. ORM:Base.metadata.MetaData(schema="analytics")Default schema for all tables — and, in 2.1, for named types too.t = Table("user", md, Column("id", Integer, primary_key=True))★Core table definition; the ORM builds one of these for you.Column("name", String(50), nullable=False)nullabledefaults to True except on primary keys.t.c.name · t.c["name"] · t.columns.keys()★.cis the column collection you use in Core expressions.t.primary_key · t.foreign_keys · md.sorted_tablesIntrospect;sorted_tablesis FK-dependency order.class user_cols(TypedColumns): …2.1Typed column collections sot.c.idinfers asColumn[int].
Integer · BigInteger · SmallIntegerPythonint.String(50) · Text · Unicode★Length is required by MySQL for indexed VARCHARs.Numeric(10, 2) · Float · DoubleNumeric→Decimal; use it for money.Boolean · Date · DateTime(timezone=True) · Interval★Settimezone=Trueor you get naive datetimes back.Enum(MyPyEnum, name="kind")Native ENUM where supported, else VARCHAR + CHECK.JSON · ARRAY · UuidUuidis the portable one;UUIDis Postgres-native.from sqlalchemy.dialects.postgresql import JSONB, INET, HSTOREDialect types unlock native operators and containment.class MyType(TypeDecorator): impl = StringCustom bind/result coercion around an existing type.registry(type_annotation_map={str: Text})Map Python annotations → SQL types once, globally.
ForeignKey("user_account.id", ondelete="CASCADE")★Goes on the many side. String form is resolved lazily.UniqueConstraint("a", "b") · CheckConstraint("qty > 0")Composite / expression constraints live in__table_args__.Index("ix_user_name", User.name, unique=True)★Ormapped_column(index=True)for the simple case.__table_args__ = (UniqueConstraint("a","b"), {"schema": "s"})A tuple; the dict of options must come last.mapped_column(default=0)Python-side default, computed at INSERT time by SQLAlchemy.mapped_column(server_default=func.now())★DDL-side DEFAULT — the database fills it in.mapped_column(onupdate=func.now())Touched on every UPDATE — the classicupdated_at.Computed("x * x", persisted=True)2.12.1 defaults to VIRTUAL on PG 18+; passpersisted=Truefor STORED.Identity() · Sequence("user_id_seq")Server-generated keys, modern and legacy spellings.
md.create_all(engine)★CREATE IF NOT EXISTS semantics; never ALTERs an existing table.md.drop_all(engine) · Table.create(engine)Drops in reverse dependency order.md.create_all(engine, checkfirst=CheckFirst.TABLES)2.1Fine-grained control over which existence checks are emitted.md.reflect(bind=engine)★Load an existing database's schema into MetaData.Table("user", md, autoload_with=engine)Reflect just one table.insp = inspect(engine); insp.get_table_names()★Alsoget_columns,get_indexes,get_foreign_keys,has_table.Base = automap_base(); Base.prepare(engine)Generate mapped classes from a live schema.CreateView(select(t), "v_users", metadata=md)2.1First-class CREATE VIEW; alsoCreateTableAsand.into().print(CreateTable(t).compile(engine))See the DDL without running it.
select(User)★All mapped columns; rows come back asUserobjects.select(User.id, User.name)★Named columns; rows areRowtuples, not objects..where(…).order_by(…).limit(10).offset(20)★Every method returns a new statement — reassign it..order_by(User.name.desc(), User.id.asc())Add.nulls_last()where the backend supports it..group_by(User.city).having(func.count() > 5)HAVING filters groups; WHERE filters rows..distinct()Postgres-onlydistinct_on()lives in the dialect module..with_only_columns(User.id)Replace the column list, keeping the FROM/WHERE.select(User).filter_by(name="ada")2.12.1 searches all FROM entities; ambiguity now raises.print(stmt) · stmt.compile(engine)★See the SQL. Addliteral_bindsto inline the values.tstring(t"SELECT * FROM u WHERE id={uid}")2.1PEP 750 t-strings: literals bind, SQL objects embed. Py 3.14+.
.where(User.age >= 18, User.active)★Multiple args are ANDed together.and_(a, b) · or_(a, b) · not_(a)★Needed for OR — Python'sand/ordo not overload.User.name.in_(["a", "b"]) · .not_in(…)★Accepts a subquery too.User.bio.is_(None) · .is_not(None)★Use these, not== None, for readable NULL tests.User.name.like("a%") · .ilike(…) · .startswith(…)Alsocontains,endswith,regexp_match.User.age.between(18, 65)Inclusive on both ends.User.posts.any(Post.title == "x")★EXISTS on a collection;.has()for many-to-one.bindparam("x") · literal(5) · text("a = :x")Explicit parameters when the operator isn't enough.cast(col, String) · type_coerce(col, JSON)SQL CAST vs. a Python-side type hint with no CAST emitted.json_col.contains("v")deprecated2.1: string-LIKE on JSON warns — usetype_coerce(…, String)or JSONB.
select(User).join(User.posts)★ON clause inferred from the relationship..join(Post, Post.user_id == User.id)Explicit ON when there's no relationship or it's ambiguous..outerjoin(Post) · .join(…, isouter=True)★LEFT OUTER JOIN.full=Truefor FULL OUTER..join_from(User, Post)State both sides when the left side isn't obvious.p = aliased(Post); .join(p, …)★Required to join the same table twice.sq = select(…).subquery(); select(sq.c.id)Anonymous derived table; address columns through.c.select(…).scalar_subquery()A one-column subquery usable inside an expression.cte = select(…).cte("c", recursive=True)WITH … ; grow it withcte.union_all(…).union_all(s1, s2) · intersect(…) · except_(…)Compound selects; wrap in.subquery()to filter further.select(…).lateral() · .correlate(t)LATERAL joins and explicit correlation control.
func.count() · func.count(User.id)★func.<anything>renders as a SQL function call.func.sum · func.avg · func.min · func.maxReturn types are inferred from the argument.func.now() · func.current_date()★Server-side clock — ideal as aserver_default.func.coalesce(a, b) · func.lower(x) · func.concat(…)Anything the backend knows,funccan spell.case((cond, "yes"), else_="no")SQL CASE as a Python expression.func.row_number().over(partition_by=…, order_by=…)★Window functions on any aggregate or ranking function..over(range_=FrameClause(…))2.1RANGE frames now accept dates/intervals, not just integers.func.count().filter(User.active)FILTER (WHERE …) where supported.select(func.json_each(col).table_valued("value"))Table-valued and set-returning functions.
insert(User).values(name="ada")★Or omit.values()and pass params toexecute().conn.execute(insert(User), [{…}, {…}])★Batched executemany — far faster than a loop.insert(User).returning(User.id)★RETURNING is used automatically for ORM inserts where supported.insert(User).from_select(["name"], select(…))INSERT … SELECT, no round trip through Python.update(User).where(…).values(active=False)★Set-based UPDATE — skips the ORM's per-object machinery.update(User).values(hits=User.hits + 1)Atomic in-place update using the column itself.delete(User).where(User.active.is_(False))Result's.rowcounttells you how many rows matched.delete(u).using(u.outerjoin(a, …))2.1Explicit USING for multi-table DELETE on PG / MySQL.postgresql.insert(t).on_conflict_do_update(…)★UPSERT. MySQL:on_duplicate_key_update; SQLite has its own.session.execute(update(User)…)careBulk DML bypasses in-memory objects — setsynchronize_session.
class Base(DeclarativeBase): pass★The 2.0 base. Replacesdeclarative_base().__tablename__ = "user_account"★Required unless you attach a__table__yourself.id: Mapped[int] = mapped_column(primary_key=True)★Every mapped class needs at least one primary key column.name: Mapped[str]★Annotation alone is enough: String, NOT NULL.bio: Mapped[str | None]★| Noneis what makes the column NULLable.mapped_column("db_name", String(50), unique=True)First positional arg overrides the DB column name.mapped_column(deferred=True)Skip a heavy column until it's touched.reg = registry(); @reg.mapped_as_dataclassDecorator style — map a class without inheriting a base.class Base(MappedAsDataclass, DeclarativeBase)Real dataclasses: generated__init__,__repr__,__eq__.User.__table__ · inspect(User).mapperDrop from ORM down to the Core Table / Mapper.
posts: Mapped[list["Post"]] = relationship(back_populates="user")★One-to-many. The FK lives onPost, not here.user: Mapped["User"] = relationship(back_populates="posts")★Many-to-one, the other half of the pair.relationship(back_populates=lambda: Post.user)2.1Callable / direct attribute refs — lintable, no magic strings.relationship(secondary=assoc_table)★Many-to-many through a plain association Table.relationship("Child", cascade="all, delete-orphan")★Delete children with the parent. One-to-many side only.relationship(uselist=False)Force one-to-one on the "one" side.relationship(primaryjoin=…, foreign_keys=[…])Disambiguate when two FKs point at the same table.relationship(order_by=Post.created)Sort the collection every time it loads.relationship(lazy="selectin")★Set the default load strategy at mapping time.WriteOnlyMapped["Post"] · DynamicMapped["Post"]Huge collections you never want fully loaded.relationship(backref="user")legacyWorks, butback_populateson both sides is explicit and typed.
class TimestampMixin: created: Mapped[datetime]★Plain mixin classes compose into any mapped class.@declared_attr def __tablename__(cls): …Compute table names or columns per subclass.__mapper_args__ = {"polymorphic_on": …}Single- and joined-table inheritance.__abstract__ = TrueA base class that is never mapped to a table itself.@hybrid_property def full(self): …★Works in Python and compiles into SQL in a WHERE clause.@coords.inplace.bulk_dml2.1Hybrids can now expand into ORM bulk INSERT/UPDATE dicts.composite(mapped_column("x"), mapped_column("y"))Two columns behind one value object. 2.1 addsreturn_none_on.column_property(select(…).scalar_subquery())A read-only derived column computed by the database.@validates("email") def check(self, key, v): …Python-side validation on attribute set.association_proxy("tags", "name")Flatten a many-to-many into a list of scalars.
with Session(engine) as s:★One session per request / task. Not thread-safe.SessionLocal = sessionmaker(engine, expire_on_commit=False)★A configured factory — the usual app-level pattern.with Session(engine) as s, s.begin():★Begin-once: commit on clean exit, rollback on exception.s.add(obj) · s.add_all([…])★Marks pending — no SQL yet.s.delete(obj)Marks for DELETE at the next flush, cascades included.s.flush()Emit the pending SQL, stay inside the transaction.s.commit() · s.rollback()★commit= flush + COMMIT. After a failed flush you must rollback.s.refresh(obj) · s.expire(obj)Re-SELECT now, vs. mark stale and reload on next access.s.begin_nested()SAVEPOINT — partial rollback inside a bigger transaction.Session(engine, execution_options={…})2.1Options now apply to flushes and eager loaders too.scoped_session(SessionLocal)Thread-local registry; prefer explicit passing in new code.
s.scalars(select(User)).all()★The everyday call: a list ofUserobjects.s.execute(select(User.id, User.name)).all()★A list ofRowtuples when you select columns.s.get(User, 1)★PK lookup; checks the identity map first, so it may emit no SQL.s.get(User, {"a": 1, "b": 2}) · s.get_one(…)Composite keys;get_oneraises instead of returning None.s.scalar(select(func.count()).select_from(User))★Counting rows — neverlen(s.scalars(…).all()).s.scalars(stmt).unique().all()★Required whenjoinedloadpulls a collection.stmt.options(…) · stmt.execution_options(…)Loader options vs. engine-level options.s.execute(stmt, execution_options={"yield_per": 1000})Stream large ORM results in batches.select(User).where(User.id.in_(select(…)))ORM entities compose freely with Core constructs.s.query(User).filter_by(name="x").first()legacyThe 1.xQueryAPI still works but is no longer documented first.
result.all()★List ofRow. Iterating a Result yields Rows too.result.scalars()★Unwrap the first column — the objects, not 1-tuples..one() · .one_or_none() · .first()★one()raises on 0 or >1;first()just takes the head..scalar_one() · .scalar_one_or_none()★Scalars + strictness in one call.result.mappings().all()Dict-like rows — handy for JSON serialization.row.name · row[0] · a, b = rowRowis a named tuple: index, attribute or unpack.result.rowcount · result.inserted_primary_keyDML feedback on aCursorResult.result.partitions(500) · result.freeze()Chunked iteration; freeze to re-read a Result.stmt: Select[int, str]2.1PEP 646 drops theTuple[]wrapper — needs mypy 1.7+.
.options(selectinload(User.posts))★Best default for collections. One extra SELECT … WHERE id IN (…)..options(joinedload(Post.user))★Best for many-to-one: LEFT JOIN in the same statement..options(subqueryload(User.posts))Older eager form;selectinloadis usually faster..options(lazyload(User.posts))The default: a SELECT fires the moment you touch the attribute..options(raiseload(User.posts))★Turn accidental lazy loads into a loud error. Great in tests.relationship(lazy="raise_on_sql")Allow it if it's already loaded, raise if it would hit the DB..options(selectinload(User.posts).selectinload(Post.tags))Chain to walk more than one level deep..join(User.posts).options(contains_eager(User.posts))★Reuse a JOIN you wrote to populate the collection..options(load_only(User.id, User.name))Column-level: alsodefer(),undefer().with_loader_criteria(Post, Post.active)Inject a WHERE into every eager load of that entity.
inspect(obj).transient / pending / persistent / detached★The four states every mapped instance moves through.s.new · s.dirty · s.deleted · s.identity_mapEverything the unit of work is currently tracking.obj in s · object_session(obj)Which session, if any, owns this instance.s.expunge(obj)Detach without deleting — the object keeps its loaded values.s.merge(obj)Copy a detached object's state back into this session.s.expire_all() · s.expunge_all()Blanket versions of the above.get_history(obj, "name")Added / unchanged / deleted values for one attribute.obj.attr # after the session closeddetachedEager-load before leaving scope, or setexpire_on_commit=False.
engine = create_async_engine("postgresql+asyncpg://…")★Needs an async driver:asyncpg,aiosqlite,asyncmy,psycopg.Sess = async_sessionmaker(engine, expire_on_commit=False)★Almost always setexpire_on_commit=Falseunder async.async with Sess() as s: await s.execute(stmt)★Sameselect()objects — only the I/O calls are awaited.(await s.scalars(stmt)).all()★Await the call, then use the Result synchronously.await conn.run_sync(Base.metadata.create_all)run_syncbridges any sync-only API into the loop.class Base(AsyncAttrs, DeclarativeBase)★Thenawait obj.awaitable_attrs.postsfor a lazy load..options(selectinload(User.posts))★Eager-load up front — the real fix, notawaitable_attrs.async_scoped_session(Sess, scopefunc=current_task)Task-scoped registry when you can't pass the session down.obj.posts # plain access under asyncMissingGreenletA lazy load has no greenlet to suspend into. Eager-load it.
create_engine(url, pool_pre_ping=True)★Cheap liveness check — cures "server closed the connection".pool_size=5, max_overflow=10, pool_timeout=30★Ceiling ispool_size + max_overflowper process.pool_recycle=1800Recycle before MySQL'swait_timeoutkills the socket.poolclass=NullPoolNo pooling — for serverless / short-lived processes.engine.pool.status()Checked-out vs. checked-in counts, live.insertmanyvaluesBatches INSERTs with RETURNING automatically — on by default.func.uuidv7(monotonic=True)2.1Server-side monotonic keys now work as batch-INSERT sentinels.create_engine(url, query_cache_size=1200)Compiled-statement cache; watch for churn from generated SQL..options(raiseload("*"))★The fastest way to find every N+1 in a code path.
create_engine(url, echo=True, echo_pool="debug")★Fastest possible answer to "what SQL did that emit?".logging.getLogger("sqlalchemy.engine").setLevel("INFO")Route the same output through your app's logging config.@event.listens_for(engine, "before_cursor_execute")★Time queries, tag them, or log slow statements.@event.listens_for(Session, "before_flush")Alsoafter_flush,after_commit,do_orm_execute.@event.listens_for(User, "before_insert")Mapper-level hooks per class.@event.listens_for(Base, "resolve_type_annotation")2.1NewRegistryEvents— program your own annotation rules.stmt.compile(engine, compile_kwargs={"literal_binds": True})★SQL with the values inlined — for reading, not for running.exc.IntegrityError · NoResultFound · MultipleResultsFoundAll DBAPI errors arrive wrapped asDBAPIErrorsubclasses.
alembic init migrations★Then pointtarget_metadata = Base.metadatainenv.py.alembic revision --autogenerate -m "add users"★Diffs models against the live DB. Always read the output.alembic upgrade head · alembic downgrade -1★head= newest revision on this branch.alembic current · alembic history --verboseWhere the database is vs. where the scripts go.alembic stamp headMark as migrated without running anything — for existing DBs.alembic merge -m "merge" rev1 rev2Reconcile two branches that both claimhead.alembic upgrade head --sqlOffline mode: print the SQL for a DBA to review.md.create_all() # in productioncareCreates missing tables but never migrates existing ones.
pip install "sqlalchemy[asyncio]"★greenletleft the default install. The #1 upgrade surprise."postgresql://" → psycopg 3★Pinpostgresql+psycopg2://if you still need the old driver.tstring(t"… {value}")PEP 750 template strings compile to bound parameters.CreateView · CreateTableAs · SelectBase.into()Views and CTAS are finally first-class DDL.Select[int, str]PEP 646 typing —Row._tworkarounds can go.autoflush is now unconditional★Even a rawtext()execute flushes pending changes first.filter_by() searches all FROM entitiesDuplicated names raiseAmbiguousColumnError— usefilter().MappedAsDataclass defaults use DONT_SETPassing onlyrelated_id=no longer NULLs it out.composite() returns an object, not NoneOpt back in withreturn_none_on=, or annotate| None.Enum / DOMAIN belong to MetaDataThey inherit its schema;inherit_schemais deprecated.Select.ext() + SyntaxExtensionAdd QUALIFY, INTO OUTFILE, etc. without patching the compiler.float literals CAST to DOUBLEWas FLOAT; matters for third-party dialects.
session.query(User)legacy→select(User)+session.execute()/session.scalars().query.get(1)legacy→session.get(User, 1).Base = declarative_base()legacy→class Base(DeclarativeBase): pass.id = Column(Integer, primary_key=True)legacy→id: Mapped[int] = mapped_column(primary_key=True).select([col1, col2])removed→select(col1, col2)— no list.engine.execute("SELECT 1")removedConnectionless execution is gone →with engine.connect().Session(autocommit=True)removed→ explicitbegin()/commit().sessionmaker(class_=AsyncSession)dated→async_sessionmaker(engine).ClauseElement.params()deprecated2.1 → the fasterExecutableStatement.params()on statements.