Skip to content
SQLAlchemy vs Django ORM: Which Python ORM Should You Use?

Click to use (opens in a new tab)

SQLAlchemy vs Django ORM: Which Python ORM Should You Use?

September 4, 2026 by Chat2DBChat2DB Team

Python has two serious ORMs and they disagree about almost everything. The Django ORM is an Active Record implementation that ships inside a web framework and assumes you are using the rest of that framework. SQLAlchemy is a standalone Data Mapper with a Unit of Work, a separate SQL expression language underneath it, and no opinion about what kind of application you are building.

Neither is "better". They make different trade-offs, and the right choice depends far more on what surrounds the ORM than on the ORM itself. This article puts the two side by side — same schema, same queries, the SQL each one actually sends to PostgreSQL — and ends with a decision table.

Two different philosophies

Django ORM follows the Active Record pattern. A model class is the table; an instance is a row; instance.save() writes it. Every model gets an objects manager that returns lazy QuerySets. There is no session object to manage: by default Django runs in autocommit mode, so each query is its own transaction unless you wrap it in transaction.atomic(). The ORM is tightly coupled to Django's settings, app registry, migrations, admin, auth and forms.

SQLAlchemy follows the Data Mapper pattern. Mapped classes are plain Python objects; a Session tracks them, records what changed, and flushes the changes as SQL when you commit. That tracking is the Unit of Work. Underneath the ORM sits Core, a SQL expression language you can use without mapping any classes at all. Since 2.0, ORM and Core share one query API built around select(), so the boundary between "ORM query" and "hand-built SQL" is a gradient rather than a wall.

The practical consequence: Django gives you less to think about and less to control. SQLAlchemy gives you more of both.

Defining models side by side

Django:

from django.db import models
 
class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
 
class Post(models.Model):
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="posts")
    title = models.CharField(max_length=200)
    status = models.CharField(max_length=20, default="draft")
    created_at = models.DateTimeField(auto_now_add=True)

SQLAlchemy 2.0, with typed Mapped[] annotations:

from datetime import datetime
from sqlalchemy import ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
 
class Base(DeclarativeBase):
    pass
 
class Author(Base):
    __tablename__ = "author"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    email: Mapped[str] = mapped_column(String(255), unique=True)
    posts: Mapped[list["Post"]] = relationship(back_populates="author")
 
class Post(Base):
    __tablename__ = "post"
    id: Mapped[int] = mapped_column(primary_key=True)
    author_id: Mapped[int] = mapped_column(ForeignKey("author.id", ondelete="CASCADE"))
    title: Mapped[str] = mapped_column(String(200))
    status: Mapped[str] = mapped_column(String(20), default="draft")
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    author: Mapped[Author] = relationship(back_populates="posts")

Django adds the id primary key for you, derives the table name from the app label (blog_post), and declares the reverse relation with related_name. SQLAlchemy makes you write the primary key, the table name and both sides of the relationship. Django's auto_now_add is Python-side (the ORM fills in the timestamp); the SQLAlchemy version above uses server_default so PostgreSQL fills it in. Both approaches are available in both libraries — the defaults just point in different directions.

The same queries, side by side

Filter, order, limit

Django:

posts = Post.objects.filter(status="published").order_by("-created_at")[:10]
SELECT "blog_post"."id", "blog_post"."author_id", "blog_post"."title",
       "blog_post"."status", "blog_post"."created_at"
FROM "blog_post"
WHERE "blog_post"."status" = 'published'
ORDER BY "blog_post"."created_at" DESC
LIMIT 10

SQLAlchemy:

from sqlalchemy import select
 
stmt = (
    select(Post)
    .where(Post.status == "published")
    .order_by(Post.created_at.desc())
    .limit(10)
)
posts = session.scalars(stmt).all()
SELECT post.id, post.author_id, post.title, post.status, post.created_at
FROM post
WHERE post.status = %(status_1)s
ORDER BY post.created_at DESC
LIMIT %(param_1)s

Django's lookups are string keys (status__in, created_at__gte), which are concise but invisible to type checkers. SQLAlchemy's are Python operators on column attributes, which autocomplete and type-check but read more verbosely. SQLAlchemy also always parameterises, so what you see in echo=True logs is the prepared form; Django's str(qs.query) interpolates values for display.

Joins and relationships

Both ORMs lazy-load relationships by default, and both give you two eager-loading strategies: a JOIN or a second query with IN.

Django:

# JOIN — one query, for ForeignKey / OneToOne
Post.objects.select_related("author")
 
# Separate query — for reverse FK and ManyToMany
Author.objects.prefetch_related("posts")
-- select_related
SELECT "blog_post".*, "blog_author"."id", "blog_author"."name", "blog_author"."email"
FROM "blog_post"
INNER JOIN "blog_author" ON ("blog_post"."author_id" = "blog_author"."id")
 
-- prefetch_related, second query
SELECT ... FROM "blog_post" WHERE "blog_post"."author_id" IN (1, 2, 3, ...)

SQLAlchemy:

from sqlalchemy.orm import joinedload, selectinload
 
session.scalars(select(Post).options(joinedload(Post.author)))
session.scalars(select(Author).options(selectinload(Author.posts)))
-- joinedload
SELECT post.id, post.author_id, ..., author_1.id, author_1.name, author_1.email
FROM post LEFT OUTER JOIN author AS author_1 ON author_1.id = post.author_id
 
-- selectinload, second query
SELECT post.author_id, post.id, ... FROM post
WHERE post.author_id IN (%(primary_keys_1)s, %(primary_keys_2)s, ...)

The mapping is nearly one to one: select_related corresponds to joinedload, prefetch_related to selectinload. Django picks INNER or LEFT OUTER JOIN based on whether the field is nullable; SQLAlchemy's joinedload always emits LEFT OUTER JOIN unless you ask for innerjoin=True. SQLAlchemy additionally lets you set the strategy on the relationship itself (lazy="selectin") so every query gets it without repeating the option.

Aggregation

Django:

from django.db.models import Count
 
Author.objects.annotate(post_count=Count("posts"))
SELECT "blog_author"."id", "blog_author"."name", "blog_author"."email",
       COUNT("blog_post"."id") AS "post_count"
FROM "blog_author"
LEFT OUTER JOIN "blog_post" ON ("blog_author"."id" = "blog_post"."author_id")
GROUP BY "blog_author"."id"

SQLAlchemy:

stmt = (
    select(Author.name, func.count(Post.id).label("post_count"))
    .join(Author.posts, isouter=True)
    .group_by(Author.id)
)
rows = session.execute(stmt).all()
SELECT author.name, count(post.id) AS post_count
FROM author LEFT OUTER JOIN post ON author.id = post.author_id
GROUP BY author.id

annotate is convenient, but Django decides the GROUP BY clause for you, and combining several annotations across different joins silently multiplies counts. SQLAlchemy makes you write the group_by, which is more typing and fewer surprises. Whichever ORM you use, run the emitted SQL through EXPLAIN (ANALYZE, BUFFERS) before trusting it at scale — Chat2DB (opens in a new tab) can paste the statement from your logs and show the plan visually, and the EXPLAIN BUFFERS guide explains what to look for.

Raw SQL escape hatches

Every ORM has queries it cannot express. Django gives you raw(), which maps rows back onto model instances, and a bare cursor for everything else:

posts = Post.objects.raw(
    "SELECT * FROM blog_post WHERE status = %s ORDER BY created_at DESC", ["published"]
)
 
from django.db import connection
with connection.cursor() as cur:
    cur.execute("SELECT count(*) FROM blog_post WHERE author_id = %s", [author_id])
    (n,) = cur.fetchone()

SQLAlchemy uses text() with named parameters, and can map the result onto ORM objects with from_statement:

from sqlalchemy import text
 
rows = session.execute(
    text("SELECT id, title FROM post WHERE status = :status"), {"status": "published"}
).all()
 
posts = session.scalars(
    select(Post).from_statement(text("SELECT * FROM post WHERE status = :status")),
    {"status": "published"},
).all()

The bigger difference is that SQLAlchemy rarely forces you all the way down to strings. Core lets you build a window function, a CTE or a lateral join with Python objects that still compose with the rest of the query. Django has Window, Subquery and Func expressions, and they cover a lot, but the composition is clumsier and the ceiling is lower.

Transactions

Django, autocommit by default, opt in to a transaction:

from django.db import transaction
 
with transaction.atomic():
    author = Author.objects.create(name="Ada", email="ada@example.com")
    Post.objects.create(author=author, title="Hello")
# nested atomic() blocks become SAVEPOINTs

Setting ATOMIC_REQUESTS = True wraps every view in a transaction, which is the common production configuration.

SQLAlchemy, a transaction is always open once the session does anything (2.0 calls this autobegin), and you end it explicitly:

from sqlalchemy.orm import Session
 
with Session(engine) as session, session.begin():
    author = Author(name="Ada", email="ada@example.com")
    session.add(author)
    session.add(Post(author=author, title="Hello"))
# commit on success, rollback on exception; begin_nested() for SAVEPOINTs

Note what SQLAlchemy did not do: it did not send an INSERT when you called session.add(). The Unit of Work flushes at commit (or before a query that needs the data), orders the inserts so foreign keys resolve, and sends only the columns that changed on updates. Django writes immediately on create()/save(), and save() writes every column unless you pass update_fields.

Migrations

Django's migrations are built in and integrated with the app registry:

python manage.py makemigrations blog
python manage.py sqlmigrate blog 0002   # preview the SQL
python manage.py migrate

SQLAlchemy delegates to Alembic, a separate package from the same author:

alembic init migrations
alembic revision --autogenerate -m "add post table"
alembic upgrade head --sql              # preview the SQL
alembic upgrade head

Both diff the models against the previous state and generate a migration file you should read before applying. The gaps: Alembic's autogenerate does not detect renames (a renamed column becomes a drop and an add — you edit the file by hand), and by default it does not compare server defaults or types unless configured. Django's makemigrations asks interactively whether a change is a rename. Django also has a strong story for data migrations via RunPython; in Alembic you write op.execute() or use the Core API inside the migration. Either way, after migrate or upgrade head, it is worth opening the resulting schema in a client such as app.chat2db.ai (opens in a new tab) to confirm the indexes and constraints you expected actually exist.

Async support

Django has offered a-prefixed async QuerySet methods since 4.1:

post = await Post.objects.aget(pk=1)
n = await Post.objects.filter(status="published").acount()
async for post in Post.objects.filter(status="published"):
    ...
await post.asave()          # Django 5.0+

Be clear about what this is: as of Django 5.x the database backends are still synchronous, so these methods run the query in a worker thread via sync_to_async. You get an async-shaped API and non-blocking views, not native async database I/O.

SQLAlchemy's async support goes down to the driver:

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
 
engine = create_async_engine("postgresql+asyncpg://app:secret@localhost/blog")
 
async with AsyncSession(engine) as session:
    result = await session.execute(
        select(Post).options(selectinload(Post.author)).where(Post.status == "published")
    )
    posts = result.scalars().all()

The catch is lazy loading. In async mode, touching post.author when it was not eager-loaded cannot silently run a query, so SQLAlchemy raises MissingGreenlet. You either eager-load (as above), use the AsyncAttrs mixin and await post.awaitable_attrs.author, or set lazy="raise" on the relationship to catch it in tests. It is a real learning curve, but it is genuine asyncio.

PostgreSQL-specific features

Both ORMs go well beyond portable SQL on PostgreSQL. Django has django.contrib.postgres:

from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import SearchVector, SearchQuery
 
class Post(models.Model):
    tags = ArrayField(models.CharField(max_length=30), default=list)
    meta = models.JSONField(default=dict)   # JSONB on PostgreSQL
 
Post.objects.filter(tags__contains=["python"])
Post.objects.filter(meta__has_key="canonical")
Post.objects.annotate(search=SearchVector("title", "body")).filter(search=SearchQuery("orm"))
 
# Upsert since Django 4.1
Post.objects.bulk_create(
    [Post(slug="a", title="A")],
    update_conflicts=True, unique_fields=["slug"], update_fields=["title"],
)

SQLAlchemy exposes the same through sqlalchemy.dialects.postgresql, including a first-class ON CONFLICT:

from sqlalchemy.dialects.postgresql import ARRAY, JSONB, insert
 
class Post(Base):
    ...
    tags: Mapped[list[str]] = mapped_column(ARRAY(String(30)), default=list)
    meta: Mapped[dict] = mapped_column(JSONB, default=dict)
 
stmt = insert(Post).values(slug="a", title="A")
stmt = stmt.on_conflict_do_update(
    index_elements=[Post.slug],
    set_={"title": stmt.excluded.title},
)
session.execute(stmt)
INSERT INTO post (slug, title) VALUES (%(slug)s, %(title)s)
ON CONFLICT (slug) DO UPDATE SET title = excluded.title

Post.tags.contains(["python"]), Post.meta["canonical"].astext and func.to_tsvector(...) cover the rest. Django's full-text search wrappers are friendlier; SQLAlchemy's upsert is more complete (conditional WHERE on the conflict action, ON CONFLICT DO NOTHING with constraint names). For a deeper walk through the SQLAlchemy side, see the SQLAlchemy with PostgreSQL tutorial; for the Django side, integrating Django with PostgreSQL.

Performance pitfalls

N+1 queries are the classic failure in both. The loop for post in posts: print(post.author.name) is one query plus one per post in either ORM unless you eager-load. Django's django-debug-toolbar and assertNumQueries in tests are the usual detectors. SQLAlchemy has echo=True on the engine, and a sharper tool: select(Post).options(raiseload("*")) makes any lazy load raise instead of silently querying, which turns N+1 into a test failure rather than a production surprise.

Identity map. A SQLAlchemy Session guarantees one Python object per primary key: load the same row twice and you get the same instance, with changes visible everywhere. Django has no identity map — Post.objects.get(pk=1) twice returns two independent objects, and saving a stale one overwrites the fields the other changed. The fix in Django is update_fields, F() expressions or select_for_update(); in SQLAlchemy it is mostly a non-issue within a session.

Large result sets. Django's iterator(chunk_size=...) uses a server-side cursor on PostgreSQL; SQLAlchemy's equivalent is session.execute(stmt, execution_options={"yield_per": 1000}). Both ORMs will happily build a million objects in memory if you do not ask.

Bulk writes. QuerySet.update() and bulk_create() in Django, and session.execute(update(Post).where(...).values(...)) or session.execute(insert(Post), list_of_dicts) in SQLAlchemy, bypass per-row object overhead. Instantiating an ORM object per row is the slow path in both libraries.

Framework fit and the decision table

This is where the choice is usually made, not on query syntax.

ConcernDjango ORMSQLAlchemy
PatternActive RecordData Mapper + Unit of Work
Standalone useAwkward; needs settings.configure()Designed for it
Framework fitDjango, Django REST FrameworkFastAPI, Flask, Litestar, scripts, data pipelines
Admin, auth, forms, serializersBuilt in, all driven by the ORMNot included; assemble yourself
Query APIString lookups, QuerySet chainingselect() with Python operators; Core for raw composition
Type checkingVia django-stubs pluginNative with Mapped[]
MigrationsBuilt in, rename detectionAlembic, hand-edit renames
AsyncAsync API over sync driversNative asyncio with asyncpg or psycopg 3
Multiple databasesRoutersOne engine per bind, explicit
Learning curveLowModerate; Session semantics take time

Scenario recommendations:

  • Django site with admin, auth, DRF. Use the Django ORM. The admin, ModelForm, ModelSerializer and the permission system all assume it, and that integration is most of the reason to pick Django.
  • FastAPI or Flask API. Use SQLAlchemy. SQLModel, which layers Pydantic models on top of SQLAlchemy, is a reasonable bridge if you want request schemas and table models to share a definition — but understand that it is SQLAlchemy underneath and you will eventually reach for the SQLAlchemy API directly.
  • Data pipelines, CLIs, analytics jobs. SQLAlchemy Core. You do not need object mapping to run a SELECT ... GROUP BY, and Core gives you composable SQL with no session to manage.
  • Complex reporting queries inside a Django app. Stay on the Django ORM for the CRUD paths and drop to connection.cursor() or raw() for the reports. Adding SQLAlchemy just for those queries doubles your connection handling and model definitions.
  • Heavy asyncio service with high connection concurrency. SQLAlchemy with asyncpg. Django's async ORM interface helps view code but does not free a thread per query.

Can you use SQLAlchemy inside a Django project? Yes — point an engine at the same DATABASES credentials and query away. But you then forgo the admin, ModelForm, DRF serializers, the auth models and Django's migration tracking for those tables, and you are running two connection pools and two transaction managers against one database. It is done, and it is rarely worth it.

Summary

The Django ORM trades control for integration: a smaller API, immediate writes, no session to think about, and an admin, auth system and form layer built on top of it. SQLAlchemy trades integration for control: an explicit Session and Unit of Work, an identity map, a full SQL expression language under the ORM, native asyncio and a more complete PostgreSQL dialect. If you are building a Django application, use the Django ORM. If you are building anything else in Python, use SQLAlchemy. In both cases, read the SQL they emit — the ORM is a convenience, and the query planner still only sees the SQL.