keepcalculator

06 Numbers and Powers

Django PostgreSQL Indexes: Which Type Fits Your Queries?

Card No. 06·65840 · Updated · Standard formula, shown below

The best PostgreSQL index type for a Django query depends on the query’s operators and data patterns: use B-tree for exact lookups and range filters, GiST for full-text search and geometric queries, GIN for array and JSONB containment, and BRIN for huge tables with naturally sorted data. This article explains each index type, how to create it in Django, and how to match it to your queries.

Background: Why Index Choice Matters

Indexes are the database’s shortcut system. Without one, PostgreSQL must scan every row (a sequential scan) to find matching records, which becomes slow as tables grow. A well-chosen index lets the database jump directly to relevant rows, reducing query time from seconds to milliseconds. Django does not automatically create indexes for every field; it only indexes primary keys, foreign keys, and fields with db_index=True. For everything else, you decide.

PostgreSQL offers several index types because no single structure works well for all operations. B-tree excels at equality and range comparisons, but it is inefficient for searching inside arrays or JSON. GIN is built for containment checks, but it is slower to update. Choosing the wrong type can make queries slower than having no index at all, because the database spends time maintaining an index it cannot use.

How Indexes Work in PostgreSQL

An index is a separate data structure that stores a sorted or hashed copy of the indexed column values, along with pointers to the actual rows. When a query filters on that column, PostgreSQL checks if an index can satisfy the condition. If yes, it uses the index to find matching row locations, then fetches those rows from the main table. The planner decides this based on statistics about the table size and data distribution.

In Django, you define indexes in your model’s Meta class using the indexes option. You can specify the type with the Index class’s name and condition parameters, but for type-specific indexes like GIN or BRIN, you need to use GinIndex, GistIndex, BrinIndex, or BTreeIndex. Django translates these into the correct CREATE INDEX statement when you run makemigrations and migrate.

For example, to add a GIN index on a JSONField, you would write:

from django.contrib.postgres.indexes import GinIndex

class Product(models.Model):
    attributes = models.JSONField()
    class Meta:
        indexes = [GinIndex(fields=['attributes'])]

B-Tree: The Default Workhorse

B-tree is PostgreSQL’s default index type and the best choice for most queries. It supports equality (=), range comparisons (<, >, BETWEEN), and sorting. It also handles LIKE patterns that are anchored at the start (e.g., LIKE 'abc%') and IS NULL checks. If you are unsure which index to use, start with B-tree.

In Django, a plain models.Index(fields=['name']) creates a B-tree index. You can also set db_index=True on a field for a single-column index. B-tree indexes are efficient for columns with many distinct values, like IDs, emails, or timestamps. They are less useful for columns with few distinct values (like a boolean) because the index does not narrow the search enough.

For composite indexes, order matters. Place columns that are used in equality filters first, then range filters. For example, an index on (user_id, created_at) helps queries that filter by user and sort by date, but an index on (created_at, user_id) would not be as effective for the same query.

GIN: For Arrays, JSONB, and Full-Text

GIN (Generalized Inverted Index) is designed for columns that contain multiple values per row, such as arrays, JSONB, and tsvector for full-text search. It stores a mapping from each possible value to the rows that contain it, enabling fast containment and intersection queries.

Common use cases in Django:

  • ArrayField: Queries using __contains, __overlap, or __len benefit from a GIN index. For example, tags__contains=['django'].
  • JSONField: Queries like data__has_key='price' or data__price__gt=10 can use a GIN index on the field. Note that for nested keys, you may need a GinIndex(fields=['data'], name='...') with the jsonb_path_ops operator class for better performance.
  • Full-text search: If you use Django’s SearchVector and SearchQuery, create a GIN index on the SearchVector field to speed up __search lookups.

GIN indexes are larger and slower to update than B-tree, so they are best for columns that are read often but written rarely. For high-write tables, consider whether the query speed gain outweighs the update overhead.

GiST: For Geometry and Ranges

GiST (Generalized Search Tree) is a balanced tree structure that supports a wide range of data types, including geometric shapes (points, polygons), ranges (tsrange, int4range), and even full-text search (though GIN is usually better). It is the index of choice for queries that involve overlap, containment, or distance calculations.

In Django, you might use GiST for:

  • GeoDjango: Spatial queries like distance_lte or bbcontains on PointField or PolygonField require a GiST index. Django’s GistIndex works with these fields.
  • Range fields: If you have a DateRangeField or IntegerRangeField, queries using __overlap or __contains can use a GiST index.
  • Exclusion constraints: GiST is required for exclusion constraints, which are useful for preventing overlapping bookings or reservations.

GiST indexes are more flexible than B-tree but can be slower for simple equality lookups. They are also not the default, so you must explicitly create them in Django.

BRIN: For Massive, Sorted Tables

BRIN (Block Range INdex) is a lightweight index that stores summary information about ranges of physical blocks. It is extremely small and fast to create, but it only works well when the indexed column is naturally sorted with the physical order of the table, such as a monotonically increasing timestamp or an auto-incrementing ID.

Use BRIN when:

  • The table has millions of rows (e.g., logs, events, sensor readings).
  • The indexed column correlates strongly with insertion order.
  • Queries typically filter on a range of that column (e.g., created_at > now() - interval '1 day').

In Django, you can create a BRIN index with BrinIndex(fields=['created_at']). BRIN scans only the relevant block ranges, so it can be much faster than a full sequential scan, but it is not as precise as B-tree. For small tables, BRIN offers no benefit and may even slow down queries because the planner still has to check the index.

How to Choose the Right Index

Follow these steps to decide which index type fits a specific query:

  1. Identify the query: Look at your filter(), exclude(), and order_by() calls. Write down the exact operators used.
  2. Match the operator to the index: For =, <, >, BETWEEN, use B-tree. For @>, && (overlap) on arrays/JSON, use GIN. For geometric or range overlap, use GiST. For large range scans on sorted data, use BRIN.
  3. Check selectivity: If the column has very few distinct values (like a status field with 3 possible values), an index may not help. PostgreSQL might still choose a sequential scan because it is faster.
  4. Test with EXPLAIN: Use QuerySet.explain() in Django to see if the index is being used. If not, the planner may consider it unnecessary; try adjusting the query or index.
  5. Monitor maintenance: Indexes add overhead to writes. If your write throughput drops, consider whether the index is worth it.

Common Mistakes and Misconceptions

One frequent mistake is creating an index on a column but not matching the query’s operator. For example, a B-tree index on a JSONField will not help a __contains query; you need a GIN index. Another is indexing every column “just in case,” which bloats the database and slows down inserts without improving read speed.

A common misconception is that LIKE '%text%' can use a B-tree index. It cannot, because the wildcard is at the start. For such patterns, you need a trigram index (using the pg_trgm extension) or full-text search. Django does not have a built-in trigram index class, but you can create one with a raw SQL migration.

Also, remember that indexes do not guarantee query speed. The planner may ignore an index if the table is small or if the condition matches a large percentage of rows. Always check with explain().

Costs and Trade-offs

Every index consumes disk space and slows down INSERT, UPDATE, and DELETE operations because the database must maintain the index in sync with the table. GIN indexes are the heaviest, BRIN the lightest. For write-heavy applications, limit the number of indexes and choose the smallest type that works.

Another cost is query planning time. With many indexes, the planner spends more time choosing the best one. In practice, this is negligible for a handful of indexes but can matter with dozens on a single table.

Finally, consider partial indexes. Django supports Index(condition=Q(...)) to index only a subset of rows, which can dramatically reduce index size and maintenance overhead. For example, an index on status only where status='active' if most queries filter on that.

Frequently Asked Questions

Does Django automatically create indexes for foreign keys?

Yes, Django creates a B-tree index on every ForeignKey field by default. This is necessary for efficient join operations. You do not need to add one manually unless you want a composite index that includes the foreign key.

Can I use a GIN index on a regular TextField for fast icontains queries?

No. GIN indexes on text fields are for full-text search (tsvector), not for substring matching. For icontains, use a trigram index (GinIndex with gin_trgm_ops) after enabling the pg_trgm extension. You can create it via a raw migration.

How do I know if my index is being used?

Use the explain() method on your QuerySet to output the query plan. Look for “Index Scan” or “Bitmap Index Scan” in the output. If you see “Seq Scan,” the index is not being used, and you should investigate why.

Is it better to have one composite index or multiple single-column indexes?

It depends. A composite index on (user, created_at) can speed up queries that filter by both columns, but it does not help queries that filter only by created_at. Multiple single-column indexes allow the planner to combine them, but that is less efficient than a single composite index. Analyze your most common query patterns.

What is the difference between db_index=True and Meta.indexes?

db_index=True on a field creates a single-column B-tree index. The Meta.indexes option allows you to create composite indexes, unique constraints, and type-specific indexes like GIN or GiST. Use Meta.indexes for anything beyond a simple single-column index.

Can I drop an index in Django?

Yes, remove the index definition from your model and run makemigrations and migrate. Django will generate a migration that drops the index. You can also use RunSQL to drop it manually if needed.

Related

Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.

More cards in this drawer

The whole Numbers and Powers drawer →