DEV Community

Amujo Elijah
Amujo Elijah

Posted on Originally published at elijahilnero.hashnode.dev

Django ORM & PostgreSQL Indexing: Benchmarking B-Tree, Composite, and GIN Indexes

When building data-intensive applications with Django, the Object-Relational Mapper (ORM) abstracts raw SQL queries seamlessly. However, as tables grow into tens or hundreds of thousands of rows, naive ORM queries can quietly turn into severe application bottlenecks.

To understand how PostgreSQL handles different index types under realistic loads, I built a benchmark suite comparing execution plans on a dataset of 50,000 records per table (100,000 total rows).

Using Django 6.1.1, PostgreSQL 18, and EXPLAIN ANALYZE, this deep dive explores:

  • Single-column B-Tree indexes
  • Composite indexes
  • JSONB GIN indexes
  • PostgreSQL execution plans
  • Query selectivity
  • A few counter-intuitive cases where adding an index can actually make a query slower

1. Environment & Database Schema

The benchmark compares two identical tables:

  • UnindexedOrder — no database indexes beyond the defaults
  • IndexedOrder — equipped with B-Tree, composite, and GIN indexes
# orders/models.py

from django.db import models
from django.contrib.postgres.indexes import GinIndex


class UnindexedOrder(models.Model):
    customer_email = models.EmailField()
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField(auto_now_add=True)
    metadata = models.JSONField(default=dict)


class IndexedOrder(models.Model):
    customer_email = models.EmailField()
    status = models.CharField(max_length=20)
    created_at = models.DateTimeField(auto_now_add=True)
    metadata = models.JSONField(default=dict)

    class Meta:
        indexes = [
            # Standard B-Tree index for single-column equality
            models.Index(
                fields=["customer_email"],
                name="idx_customer_email",
            ),

            # Composite index matching filter + ordering direction
            models.Index(
                fields=["status", "-created_at"],
                name="idx_status_created",
            ),

            # GIN index for JSONB metadata lookups
            GinIndex(
                fields=["metadata"],
                name="idx_order_metadata_gin",
            ),
        ]
Enter fullscreen mode Exit fullscreen mode

Both models were populated with identical datasets containing 50,000 records each using bulk_create() to mimic real transactional order history.


2. Benchmark Performance Breakdown

Test Scenario Query Filter Unindexed Plan Indexed Plan Unindexed Time Indexed Time Performance Shift
B-Tree Index Single-column lookup Seq Scan Bitmap Index Scan 81.73 ms 2.69 ms ~30× faster
Composite Index Filter + Sort Seq Scan + Sort Index Scan 53.00 ms 0.76 ms ~70× faster
GIN (Low Selectivity) JSONB match (33.5% of dataset) Seq Scan Bitmap Index Scan 79.36 ms 109.37 ms ~37% slower
GIN (High Selectivity) JSONB match (11.1% of dataset) Seq Scan Bitmap Index Scan 132.93 ms 96.41 ms ~27% faster

Important: These results are specific to this benchmark environment and dataset. PostgreSQL's planner may choose a different execution strategy depending on table size, hardware, cache state, data distribution, statistics, and query shape.


3. Execution Plan Analysis & Key Findings

Benchmark 1: Single-Column B-Tree Index (~30× Speedup)

Querying by email address on an unindexed table forces PostgreSQL to perform a Sequential Scan (Seq Scan).

Unindexed

Seq Scan on orders_unindexedorder
  (cost=0.00..1470.00 rows=8 width=98)
  Filter: ((customer_email)::text = 'user_42@gmail.com'::text)
  Rows Removed by Filter: 49993
  Buffers: shared hit=845

Planning Time: 7.347 ms
Execution Time: 81.731 ms
Enter fullscreen mode Exit fullscreen mode

The database had to examine all 50,000 rows across 845 shared buffers just to return 7 matching records.

With a standard B-Tree index on customer_email, PostgreSQL can use the index to locate candidate rows much more efficiently.

Indexed

Bitmap Heap Scan on orders_indexedorder
  (cost=4.35..34.12 rows=8 width=98)
  Recheck Cond: ((customer_email)::text = 'user_42@gmail.com'::text)
  Buffers: shared hit=9

  -> Bitmap Index Scan on idx_customer_email
     (cost=0.00..4.35 rows=8 width=0)

Execution Time: 2.693 ms
Enter fullscreen mode Exit fullscreen mode

Shared buffer hits dropped from 845 to 9 blocks.

That's a dramatic reduction in the amount of table data PostgreSQL needed to touch. Conceptually, instead of walking through the entire table, PostgreSQL can use the B-Tree structure to locate matching values efficiently.


4. Benchmark 2: Composite Index & Eliminating In-Memory Sorts (~70× Speedup)

A common query pattern in web application dashboards involves filtering by status while sorting by creation timestamp:

IndexedOrder.objects.filter(
    status="completed"
).order_by("-created_at")[:10]
Enter fullscreen mode Exit fullscreen mode

Without an appropriate index, PostgreSQL must:

  1. Scan the table
  2. Find rows matching status="completed"
  3. Sort the matching rows by created_at
  4. Return the first 10 rows

Unindexed

Limit
  (cost=1738.18..1738.20 rows=10 width=98)
  Buffers: shared hit=848

  -> Sort
     (cost=1738.18..1769.20 rows=12410 width=98)
     Sort Key: created_at DESC
     Sort Method: top-N heapsort
     Memory: 28kB

     -> Seq Scan on orders_unindexedorder

Execution Time: 53.004 ms
Enter fullscreen mode Exit fullscreen mode

The important part here is Sort Method: top-N heapsort. PostgreSQL has to perform a sort because the table itself isn't organized in the order required by the query.

Indexed

The composite index is defined as:

models.Index(
    fields=["status", "-created_at"],
    name="idx_status_created",
)
Enter fullscreen mode Exit fullscreen mode

The resulting plan is:

Limit
  (cost=0.41..3.53 rows=10 width=98)
  Buffers: shared hit=5

  -> Index Scan using idx_status_created on orders_indexedorder

Execution Time: 0.759 ms
Enter fullscreen mode Exit fullscreen mode

The composite index is ordered by status then created_at DESC. This matches the query's filtering and ordering requirements.

As a result, PostgreSQL can walk the relevant section of the index, retrieve the first 10 matching rows, and stop. There is no separate sort step. Buffer access also dropped from 848 to 5 shared hits.


5. Benchmark 3: GIN Indexes, Django Syntax Traps & the Selectivity Paradox

JSONB indexes behave differently from ordinary B-Tree indexes, and this is where things get particularly interesting.

5.1 The Django ORM Syntax Trap

Suppose we want to query the metadata JSON field:

IndexedOrder.objects.filter(
    metadata__plan="enterprise"
)
Enter fullscreen mode Exit fullscreen mode

This looks perfectly reasonable from a Django perspective. However, the generated SQL uses JSON key extraction rather than the JSONB containment operator that a standard GIN index is designed to accelerate.

For a default PostgreSQL jsonb_ops GIN index, containment queries are a much better match:

IndexedOrder.objects.filter(
    metadata__contains={"plan": "enterprise"}
)
Enter fullscreen mode Exit fullscreen mode

This generates a PostgreSQL JSONB containment condition using @>, which can make use of the GIN index.

Key point: Creating a GIN index doesn't automatically mean every JSON lookup will use it. The operator used by the query matters.


6. The Selectivity Paradox

Here's where the benchmark produced a counter-intuitive result.

For this query:

IndexedOrder.objects.filter(
    metadata__contains={"plan": "enterprise"}
)
Enter fullscreen mode Exit fullscreen mode

The condition matched 16,752 out of 50,000 rows (~33.5% of the table).

The benchmark produced:

Plan Execution Time
Sequential Scan 79.36 ms
GIN Index 109.37 ms

The indexed query was approximately 37% slower. So why would adding an index make the query slower?

6.1 Indexes Aren't Always Faster

An index has overhead. For a GIN-backed query, PostgreSQL may need to:

  1. Search the GIN index
  2. Build a bitmap of matching row locations
  3. Visit the corresponding heap pages
  4. Recheck the condition against the actual rows

When a query matches a large percentage of the table, this additional work can become more expensive than simply reading the table sequentially.

In this case, PostgreSQL determined that a sequential scan was cheaper for the unindexed query.

This is an important database principle:

An index is a tool for reducing unnecessary work—not a guarantee that less work will always happen.


7. Reversing the Curve with Higher Selectivity

Now consider a more selective query:

IndexedOrder.objects.filter(
    metadata__contains={
        "plan": "enterprise",
        "region": "EU",
    }
)
Enter fullscreen mode Exit fullscreen mode

The additional condition reduced the matching dataset from 16,752 rows down to 5,557 rows (~11.1% of the table).

The execution times changed significantly.

Unindexed

Seq Scan on orders_unindexedorder
  Filter: (
    metadata @> '{"plan": "enterprise", "region": "EU"}'::jsonb
  )

Execution Time: 132.933 ms
Enter fullscreen mode Exit fullscreen mode

Indexed

Bitmap Heap Scan on orders_indexedorder
  Recheck Cond: (
    metadata @> '{"plan": "enterprise", "region": "EU"}'::jsonb
  )

Execution Time: 96.408 ms
Enter fullscreen mode Exit fullscreen mode

Now the GIN index wins:

Plan Execution Time
Sequential Scan 132.93 ms
GIN Index 96.41 ms

That's approximately a 27% improvement.


8. Why Did Selectivity Change the Result?

The important variable is the percentage of rows that match the query.

Low Selectivity

50,000 total rows -> 16,752 matches -> 33.5%

The query is asking for a large portion of the table. In this situation, PostgreSQL may decide that scanning the table sequentially is cheaper than GIN Index -> Bitmap -> Heap pages -> Recheck rows.

Higher Selectivity

50,000 total rows -> 5,557 matches -> 11.1%

Now the index can eliminate much more unnecessary table access. The cost of using the index is outweighed by the amount of data PostgreSQL avoids scanning.

This is why selectivity matters when designing and evaluating indexes.


9. Practical Takeaways for Django Developers

9.1 Always Verify with .explain()

Never assume an index is being used simply because you've created one. Django allows you to inspect the generated execution plan:

queryset = IndexedOrder.objects.filter(
    customer_email="user_42@gmail.com"
)

print(queryset.explain())
Enter fullscreen mode Exit fullscreen mode

For deeper analysis, you can inspect PostgreSQL's execution plan using EXPLAIN ANALYZE where appropriate. Look for operations such as Seq Scan, Index Scan, Bitmap Index Scan, Bitmap Heap Scan, and Sort.


9.2 Column Order Matters in Composite Indexes

For queries like:

Order.objects.filter(
    status="completed"
).order_by("-created_at")
Enter fullscreen mode Exit fullscreen mode

an index such as:

models.Index(
    fields=["status", "-created_at"],
    name="idx_status_created",
)
Enter fullscreen mode Exit fullscreen mode

can allow PostgreSQL to combine filtering and ordering into a single index traversal.

The order of fields in a composite index matters. Don't blindly create ["created_at", "status"] when your dominant query pattern is WHERE status = ... ORDER BY created_at DESC.


9.3 Use Appropriate Operators for JSONB GIN Indexes

If you're using a standard PostgreSQL GIN index on a JSONB column, containment queries are an important use case:


python
Order.objects.filter(
    metadata__contains={"plan": "enterprise"}
)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)