Python has two answers to the persistence question. SQLAlchemy is the toolkit — Core for SQL expressions, ORM on top, use either or both. Django ORM is the framework — pre-wired, batteries-included, inseparable from the Django request cycle. Picking one usually picks itself based on the framework.
← Back to Database SideModel.objects.filter(...). Tied to settings.py and Django's lifecycle.makemigrations/migrate for Django.The modern API — typed, async-capable, unified across Core and ORM. The default for FastAPI, Flask, scripts, data pipelines.
SQLAlchemy's migration tool. Autogenerates from model diffs; humans review and edit before merging.
Ships with Django. Integrated with the admin, forms, signals, and the auth system. You don't pick it; Django picks it for you.
Async-first (Tortoise) and minimalist (Peewee) alternatives. Niche but well-loved.
SQLAlchemy + Pydantic in one. Popular with FastAPI for unifying database models and request schemas.
The drivers underneath async SQLAlchemy on Postgres — raw-SQL speed when you don't need an ORM.
The whole framework — admin, generic views, forms, auth — assumes Django models. Swapping in SQLAlchemy means losing all of that. The Django ORM is less expressive than SQLAlchemy, but the integration is worth more than the expressiveness for typical web apps.
It's the de-facto standard outside Django. Async support, typed mappings, and the Core layer for when you need raw SQL power without leaving Python. SQLModel is a friendly façade if you want less boilerplate.
ETL, analytics, scripts that move millions of rows — the ORM's identity map and change tracking are pure overhead. Use SQLAlchemy Core to compose SQL safely, or drop to asyncpg/psycopg directly.
orders.filter(...).filter(...) doesn't execute until iterated. The trap is a queryset evaluated twice in the same view — two SQL trips. Cache with list(), or use prefetch_related / select_related to fold related lookups into one query.
Django: forget select_related on a foreign key, and every order.customer in a loop fires a query. SQLAlchemy: lazy-loaded relationships do the same. Both have eager-loading options (joinedload, selectinload); both punish you when you forget.
Two branches each generate a migration with the same parent — when both merge, Alembic and Django both refuse to apply. Resolve with alembic merge or makemigrations --merge. Common enough on busy teams that it's worth knowing the muscle memory.
Long-running async tasks holding sessions exhaust the pool. Use async with contexts so sessions close on exit, and size the pool deliberately — not all defaults match what async workers need.
The 2.0 API changed how queries are written (select(User).where(...) instead of session.query(User).filter(...)). Tutorials and Stack Overflow answers mix both freely. New code should be 2.0-style; old code may need migrating.
Manager/QuerySet classes for reusable filters (.published(), .for_user(u)) — keeps views thin.F() expressions for atomic updates instead of read-modify-write.insert().values(...) + execute) when bulk-loading; the ORM path is too slow for big batches.raw(), extra(), text(). Use it when the ORM costs more than it saves.