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
-
JSONBGIN 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",
),
]
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
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
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]
Without an appropriate index, PostgreSQL must:
- Scan the table
- Find rows matching
status="completed" - Sort the matching rows by
created_at - 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
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",
)
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
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"
)
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"}
)
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"}
)
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:
- Search the GIN index
- Build a bitmap of matching row locations
- Visit the corresponding heap pages
- 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",
}
)
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
Indexed
Bitmap Heap Scan on orders_indexedorder
Recheck Cond: (
metadata @> '{"plan": "enterprise", "region": "EU"}'::jsonb
)
Execution Time: 96.408 ms
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())
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")
an index such as:
models.Index(
fields=["status", "-created_at"],
name="idx_status_created",
)
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"}
)
Top comments (0)