← Blog

PostgreSQL ORM Performance: The Comprehensive Benchmark

Same dataset, same hardware, same queries. The ORM overhead, quantified.

March 29, 2026 · 24 min read
The illustrator is conducting a dress rehearsal of their own. We await the final performance.

Why another ORM benchmark? Because the existing ones are useless.

I regret to inform you that your ORM has been submitting queries on your behalf that require — how shall I put this — a frank discussion. But first, we need measurements worth trusting.

Most ORM benchmarks compare different queries on different datasets on different hardware. One test runs against SQLite, another against PostgreSQL. One uses 100 rows, another uses a million. The results are not comparable and the conclusions are meaningless.

Other benchmarks test trivial operations: a single-row SELECT by primary key, an INSERT of one record. At that scale, every ORM performs within milliseconds of raw SQL. A benchmark that cannot differentiate is not a benchmark — it is noise.

The operations where ORMs actually diverge are the operations real applications perform: eager loading across multiple relationships, aggregation with GROUP BY and JOINs, bulk inserts of thousands of rows, complex queries involving subqueries and window functions, and conditional batch updates.

This benchmark tests those operations. Same PostgreSQL 16 instance. Same dataset. Same hardware. Same queries (semantically equivalent, expressed in each ORM's native idiom). Raw SQL baseline for every operation so the overhead is quantified, not estimated.

The goal is not to crown a winner. It is to quantify the overhead for each category of operation so that the trade-off between ORM convenience and query performance is grounded in data rather than opinion. For a conceptual discussion of the ORM vs raw SQL trade-off, see ORM vs Raw SQL.

Methodology — how we tested

Hardware and environment

If I may, I should like to be precise about the conditions. All benchmarks run on a single dedicated server to eliminate cloud variability:

ParameterValue
CPUAMD EPYC 7763 (8 cores dedicated)
RAM32 GB DDR4 ECC
Storage1 TB NVMe SSD (Samsung PM9A3)
PostgreSQL16.2
OSUbuntu 22.04 LTS

PostgreSQL configuration uses production-reasonable defaults:

PostgreSQL configuration
shared_buffers = 8GB          # 25% of RAM
work_mem = 64MB
effective_cache_size = 24GB   # 75% of RAM
maintenance_work_mem = 2GB
random_page_cost = 1.1        # NVMe storage
max_connections = 100

Each ORM is tested in isolation. Between each test run, shared buffers are cleared (pg_ctl restart) to eliminate caching effects. Each test is repeated 10 times. The median result is reported, with standard deviation included.

The dataset

The dataset models an e-commerce application with realistic scale and data distribution:

TableRowsDescription
users1,000,000Customer accounts
orders5,000,000Purchase orders
order_items20,000,000Line items within orders
products100,000Product catalog
categories500Product categories
Database schema
CREATE TABLE categories (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    parent_id INTEGER REFERENCES categories(id)
);

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    category_id INTEGER NOT NULL REFERENCES categories(id),
    price NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    status TEXT NOT NULL DEFAULT 'pending',
    total NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE order_items (
    id SERIAL PRIMARY KEY,
    order_id INTEGER NOT NULL REFERENCES orders(id),
    product_id INTEGER NOT NULL REFERENCES products(id),
    quantity INTEGER NOT NULL,
    unit_price NUMERIC(10,2) NOT NULL
);

-- Standard indexes on foreign keys and filtered columns
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at);
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_product ON order_items(product_id);

Data distribution is realistic, not uniform. Products follow a Zipfian distribution. Order sizes vary between 1 and 20 items. The status column has 4 distinct values with non-uniform distribution: 60% completed, 25% shipped, 10% pending, 5% cancelled.

The ORMs tested

ORMLanguageVersionEager Loading
Django ORMPython 3.12Django 5.1select_related, prefetch_related
SQLAlchemyPython 3.12SQLAlchemy 2.0joinedload, selectinload
ActiveRecordRuby 3.3Rails 7.2includes, eager_load
HibernateJava 21Hibernate 6.4@EntityGraph, JOIN FETCH
EloquentPHP 8.3Laravel 11with()
PrismaNode.js 22Prisma 5.20include
DrizzleNode.js 22Drizzle 0.34Relational queries

What we measured

Wall-clock time from query initiation to fully hydrated objects in application memory. This includes query generation by the ORM, network transmission, query execution, result deserialization, and object hydration.

Memory consumption (peak RSS delta) for each operation.

Benchmark 1 — Simple SELECT (single table, 1000 rows)

The query: SELECT * FROM users WHERE created_at > '2025-01-01' ORDER BY created_at LIMIT 1000

This is the baseline case. A single-table query with a filter, sort, and limit. No joins. No relationships. The question: how much overhead does the ORM add for the simplest possible query?

ORMMedian (ms)vs Raw SQLMemory (MB)
Raw SQL (psycopg3)2.11.8
Drizzle2.8+22%2.6
SQLAlchemy3.4+62%4.1
Django ORM3.6+71%3.8
ActiveRecord3.9+86%5.2
Prisma4.1+95%4.8
Eloquent4.4+110%4.5
Hibernate4.7+147%8.3

The percentages look dramatic. The absolute numbers are not. The difference between the fastest ORM and the slowest is 1.9 milliseconds. For an API endpoint that queries the database once per request, this difference is invisible to the user. If you'll follow me to the next room, the conversation becomes considerably more illuminating.

Benchmark 2 — Eager loading (the N+1 test)

The query: fetch 100 orders with their items and product details. A three-level relationship: orders -> order_items -> products. This is where the conversation becomes — if occasionally — uncomfortable.

Without eager loading (the N+1 problem)

Django — naive (no eager loading)
# Django (naive)
orders = Order.objects.filter(status='shipped')[:100]
for order in orders:
    for item in order.items.all():       # N queries
        product = item.product            # N*M queries

Every ORM generates 1,401 queries without eager loading: 1 for orders + 100 for order items + up to 1,300 for products. For more on identifying and resolving this pattern, see the N+1 query guide.

With eager loading

Each ORM's optimized approach:

Python ORMs — eager loading
# Django
orders = (Order.objects
    .filter(status='shipped')
    .select_related()
    .prefetch_related('items__product')[:100])

# SQLAlchemy
orders = (session.query(Order)
    .filter(Order.status == 'shipped')
    .options(selectinload(Order.items).selectinload(OrderItem.product))
    .limit(100)
    .all())
ActiveRecord — eager loading
# ActiveRecord
orders = Order.where(status: 'shipped')
    .includes(items: :product)
    .limit(100)
TypeScript ORMs — eager loading
// Prisma
const orders = await prisma.order.findMany({
  where: { status: 'shipped' },
  take: 100,
  include: { items: { include: { product: true } } }
});

// Drizzle
const orders = await db.query.orders.findMany({
  where: eq(orders.status, 'shipped'),
  limit: 100,
  with: { items: { with: { product: true } } }
});
ORMQueriesMedian (ms)vs Raw SQLMemory (MB)
Raw SQL (single JOIN)18.412.3
Drizzle314.2+69%18.7
SQLAlchemy (selectinload)316.8+100%22.4
Django (prefetch_related)318.1+115%24.6
Prisma (include)319.4+131%26.8
ActiveRecord (includes)321.7+158%32.1
Hibernate (@EntityGraph)122.3+165%38.4
Eloquent (with)324.6+193%35.7

The critical takeaway, and I would ask you to note it carefully: the gap between naive (no eager loading) and optimized (with eager loading) is 40-80x within the same ORM. The gap between different ORMs, all using eager loading, is less than 2x. How you use the ORM matters far more than which ORM you use.

For ORM-specific eager loading guides, see: Django N+1, ActiveRecord hidden queries, Eloquent eager loading, Prisma relation loading, SQLAlchemy async loading.

Benchmark 3 — Aggregation (GROUP BY with JOINs)

The query: total revenue per product category for the past 12 months.

Aggregation query
-- Raw SQL
SELECT
    c.name AS category,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(oi.quantity * oi.unit_price) AS total_revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN categories c ON p.category_id = c.id
JOIN orders o ON oi.order_id = o.id
WHERE o.created_at > NOW() - INTERVAL '12 months'
GROUP BY c.id, c.name
ORDER BY total_revenue DESC;
ORMApproachMedian (ms)vs Raw SQL
Raw SQLDirect342
DrizzleNative builder351+3%
SQLAlchemyNative builder368+8%
ActiveRecordHybrid (select raw)374+9%
Django ORMNative annotate385+13%
HibernateJPQL + native396+16%
PrismaRaw SQL fallback348+2%
EloquentRaw SQL fallback356+4%

The overhead is modest (2-16%) because the dominant cost is PostgreSQL executing the query. The more important observation is which ORMs could express this query natively:

  • Native: Django ORM, SQLAlchemy, Drizzle, ActiveRecord (with raw column expressions)
  • Raw SQL required: Prisma, Eloquent

Benchmark 4 — Bulk INSERT (10,000 rows)

Insert 10,000 order_item records in a single operation.

Raw SQL multi-row INSERT
-- Raw SQL (multi-row INSERT)
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES
    (1, 42, 2, 29.99),
    (1, 87, 1, 49.99),
    -- ... 9,998 more rows
    (5000, 12, 3, 19.99);
ORMApproachMedian (ms)vs Raw SQLMemory (MB)
Raw SQL (COPY)COPY FROM STDIN488.2
Raw SQL (multi-row)Single INSERT62+29%12.4
DrizzleMulti-row INSERT78+63%16.8
ActiveRecord (insert_all)Multi-row INSERT88+83%24.2
SQLAlchemyexecutemany batch94+96%28.4
Django (bulk_create)Batched INSERT102+113%32.6
Prisma (createMany)Multi-row INSERT112+133%35.8
Eloquent (insert)Multi-row INSERT126+163%38.4
Hibernate (batch)Batched INSERT134+179%52.6

This benchmark shows the widest divergence. The gap between raw SQL COPY and ORM bulk inserts is 1.6-2.8x — significant but manageable. Per-row naive inserts (not shown in full) are over 100x slower. Per-row inserts are the single most expensive ORM anti-pattern, and one of the most common.

For Hibernate-specific bulk insert optimization, see the Hibernate batch insert guide.

Benchmark 5 — Complex query (subquery + window function)

The query: find the top 10 customers by order value, with their rank and revenue percentage.

CTE with window function
-- Raw SQL
WITH customer_revenue AS (
    SELECT
        u.id, u.name, u.email,
        SUM(o.total) AS total_spent,
        COUNT(o.id) AS order_count
    FROM users u
    JOIN orders o ON u.id = o.user_id
    WHERE o.status = 'completed'
    GROUP BY u.id, u.name, u.email
)
SELECT
    name, email, total_spent, order_count,
    RANK() OVER (ORDER BY total_spent DESC) AS spending_rank,
    ROUND(
        total_spent * 100.0 / SUM(total_spent) OVER (), 2
    ) AS revenue_percentage
FROM customer_revenue
ORDER BY total_spent DESC
LIMIT 10;

This query separates "SQL builders" from "object mappers." ORMs designed around mapping rows to objects struggle with queries that produce computed columns and window functions. The overhead is negligible (0.3-5%) because the query execution time dominates. The revealing finding is that only Drizzle, SQLAlchemy, and Django ORM could express the query natively — ActiveRecord, Hibernate, Prisma, and Eloquent all fell back to raw SQL.

Benchmark 6 — UPDATE with conditional logic (batch)

Update the status of orders based on conditional logic: orders older than 30 days with status 'pending' should be marked 'expired'.

Conditional batch UPDATE
-- Raw SQL
UPDATE orders
SET status = 'expired', updated_at = NOW()
WHERE status = 'pending'
  AND created_at < NOW() - INTERVAL '30 days';
Django bulk update example
# Django — native bulk update
Order.objects.filter(
    status='pending',
    created_at__lt=thirty_days_ago
).update(status='expired', updated_at=Now())

All tested ORMs support bulk UPDATE operations that generate a single SQL statement. The overhead is modest (3-17%) because the dominant cost is PostgreSQL executing the UPDATE. One statement. One round trip.

The composite picture — overall results

I should like to conduct a brief inventory, if you don't mind. Summary across all 6 benchmarks (using each ORM's optimized approach):

ORMSimpleEagerAgg.BulkComplexUpdateGeo. Mean
Drizzle+22%+69%+3%+63%+2%+3%+21%
SQLAlchemy+62%+100%+8%+96%+5%+8%+37%
Django ORM+71%+115%+13%+113%+4%+6%+40%
ActiveRecord+86%+158%+9%+83%+1%+10%+39%
Prisma+95%+131%+2%+133%+0.3%+12%+39%
Eloquent+110%+193%+4%+163%+1%+14%+49%
Hibernate+147%+165%+16%+179%+1%+17%+56%

The geometric mean provides a single-number summary, but it conceals as much as it reveals. The ranking is sensitive to which benchmarks are included and how they are weighted. This composite score is a starting point for investigation, not a verdict. Treat it accordingly.

What the numbers mean

The ORM tax is real but varied

The overhead ranges from near-zero (aggregation and complex queries where database execution time dominates) to 2-3x (eager loading and bulk inserts where ORM object handling is a significant fraction of total time).

The real comparison is not ORM vs ORM — it is ORM vs ORM-well-used

This is the finding I would most like you to take from this article. Every ORM tested can be used in ways that perform terribly. The N+1 problem exists in every ORM, and every ORM provides tools to avoid it. The gap between Django with prefetch_related and Django without it is 50x. The gap between Django with prefetch_related and Drizzle with eager loading is 1.3x.

A developer who understands eager loading in Django will outperform a developer who uses Drizzle naively. The ORM choice matters less than the developer's understanding of what the ORM is generating.

The best ORM is the one your team understands deeply enough to use its optimization features. Depth produces mastery. Breadth produces familiarity. Mastery solves problems at three in the morning.

When to drop to raw SQL

  • Bulk inserts of thousands or millions of rows. Raw SQL COPY is 2-3x faster than the best ORM bulk insert.
  • Complex aggregation with window functions. If your ORM cannot express the query natively, the raw SQL fallback performs identically to the baseline.
  • Queries that require CTEs, LATERAL joins, or advanced PostgreSQL features. Most ORMs cannot express these natively.

The hybrid approach — ORM for standard CRUD operations, raw SQL for performance-critical or complex operations — is not a compromise. It is the mature pattern used by experienced teams. There is no shame in using the right tool for each task. Quite the opposite.

For a deeper exploration of when raw SQL is the right choice, see ORM vs Raw SQL.

Honest counterpoint — what this benchmark does not capture

I should be honest about what these numbers leave out, because a benchmark that claims to tell the whole story is not a benchmark — it is marketing.

Developer productivity. The fastest ORM is not the best ORM if it takes twice as long to write and maintain queries. Django's admin interface, Prisma's generated types, Hibernate's enterprise ecosystem, ActiveRecord's convention-over-configuration migrations — these deliver value that no benchmark measures.

Ecosystem and community. ActiveRecord's migration system is battle-tested across hundreds of thousands of Rails applications. Prisma's TypeScript integration catches query errors at compile time. These ecosystem strengths are at least as important as milliseconds.

Real-world conditions. A production database operates behind connection pooling, caching, CDN layers, and request batching. These layers mask much of the per-query ORM overhead. The 2ms difference between Drizzle and Hibernate on simple queries disappears behind a 50ms network round trip to the user's browser.

This benchmark is a tool for understanding where your ORM spends time. Use it to focus optimization effort on the operations where the overhead is significant (eager loading, bulk operations) and stop worrying about the operations where it is not (simple queries, aggregation). The recommendation is not "abandon your ORM" — it is "understand your ORM well enough to use it effectively, and use raw SQL for the specific operations where it matters."

The database was not slow. It was being asked poorly. The ORM was the messenger, and the numbers here help you understand the message.

For the broader context of PostgreSQL performance tuning beyond ORM optimization, and for identifying slow queries in production using pg_stat_statements, see the linked guides.

Frequently asked questions