Company
About Tomosu
Platform
Platform & Agents Indexes How it works Solutions Pricing
Get Started
MCP Server VS Code — Plugin Installation Scan Your Repo — Guide Integrations · GitHub App Integrations · CodeRabbit MCP FAQ
Free Tools
Governance Impact
Resources
Blogs News Download / Free Trial Book a call →
Production Debugging · Database indexes

Missing Database Indexes in a PR: What Reviewers Should Check

Tomosu AI·13 min read·

A missing database index almost never looks like anything in a diff. The pull request adds a filter option, a sort order, or a nightly job. The ORM call reads cleanly, the tests pass against a few hundred rows, and the release goes out. A week later the same query is scanning sixty million rows on every page load.

Quick answer

To catch a missing database index in a pull request, look for new query shapes, not new indexes. For every query the change adds or alters, compare its equality filters, range filters, sort, and join columns against the leading columns of existing indexes on that table, and ask how large the table is in production.

This is a review guide for the moment before merge. If a release is already slow and you are trying to work out why a query behaves differently in production, including plan changes, stale statistics, and skew, start with Why an API Is Slow in Production but Fast Locally. The two overlap on one point: a query plan run against production-sized data answers most index questions in a minute.

Why do missing indexes get through code review?

A missing index is a query that the database can only answer by reading far more rows than it returns, because no index matches the columns the query filters, sorts, or joins on. It gets through review for structural reasons, not careless ones:

WHAT THE REVIEWER SEES VS WHAT THE DATABASE DOES 1 · THE DIFF .filter(   status="open",   due_at__lt=now) 2 · THE SQL WHERE status = 'open'   AND due_at < now() 3 · THE INDEXES pkey (id), (account_id) None starts with status or due_at: scan the table DEV AND CI · 2,000 ROWS Seq Scan reads 2,000 rows About 1 ms. Tests pass. Looks fine PRODUCTION · 60,000,000 ROWS Seq Scan reads every row, every call Seconds per query, I/O and CPU spike Same plan, 30,000× the rows
The plan does not change between environments. The number of rows it has to read does. Illustrative numbers.

So the review question is not “did the author add an index?” It is “what does each new query ask the database to do, and how many rows will that touch in production?” Answering it takes three inputs: the SQL the code produces, the indexes that exist, and a rough size for the table.

Which changes in a PR create a new query shape?

A query shape is the combination of table, filter columns, sort, and joins, independent of the parameter values. Most index regressions come from a small set of innocent-looking changes that create a new shape against an existing large table.

Change in the PRQuery shape it createsIndex question to ask
New filter option on a list endpointWHERE account_id = ? AND status = ?Is there an index starting with both, or does it filter one account’s rows in memory?
New sort optionORDER BY due_at LIMIT 50Can an index return rows already in that order, or must every match be sorted first?
New background job or reportWHERE status = 'open' AND due_at < now() across all accountsExisting indexes may all start with a tenant column the job does not filter on
New relation or eager loadJOIN line_items ON line_items.invoice_id = invoices.idIs the foreign key column on the child table indexed?
New column used for lookupsWHERE external_ref = ?Needs its own index, often unique
Soft delete or archival flagAND deleted_at IS NULL on every queryWould a partial index keep the hot rows small?
Case-insensitive or fuzzy searchWHERE lower(email) = ?, ILIKE '%term%'A plain index on email will not be used; needs an expression or trigram index
Offset pagination on a growing listORDER BY created_at OFFSET 50000Even with an index, deep offsets read and discard rows; consider keyset pagination

Here is a realistic example. A pull request adds an “overdue” view for invoices and a nightly reminder job. Neither touches a migration.

invoices/views.py + tasks.py · the PRtwo new query shapes, no index
# View: one account's overdue invoices, oldest first
def overdue(request):
    return (Invoice.objects
            .filter(account=request.account,
                    status="open",
                    due_at__lt=timezone.now())
            .order_by("due_at")[:50])

# Nightly job: every overdue invoice, all accounts
def send_reminders():
    overdue = Invoice.objects.filter(status="open",
                                     due_at__lt=timezone.now())
    for inv in overdue.iterator():     # no index starts with status or due_at
        notify(inv)

The only index that helps the view is Django’s automatic index on the account foreign key. For a small account that is fine. For the largest account, with hundreds of thousands of invoices, the database reads all of them, filters by status and date, and sorts the survivors. The job has nothing to use at all.

How does composite index column order decide what is covered?

A B-tree composite index stores entries sorted by its first column, then by the second within each value of the first, and so on. A query can seek directly to a contiguous range only when it constrains the leading columns. That is why the order of columns matters more than which columns are included.

INDEX (ACCOUNT_ID, STATUS, DUE_AT) · ENTRIES IN SORTED ORDER ( 7, open, 03-02) ( 7, open, 03-20) ( 7, open, 04-11) ( 7, paid, 01-15) ( 7, paid, 02-28) ( 9, open, 02-10) ( 9, open, 03-05) ( 9, open, 05-01) ( 9, paid, 01-03) (12, open, 03-01) (12, open, 06-30) (12, paid, 02-02) ● job● job● job● job● job “now” = 04-01 in this example THE VIEW · SERVED BY THIS INDEX WHERE account_id = 9   AND status = 'open'   AND due_at < '04-01' ORDER BY due_at LIMIT 50 One contiguous range, already sorted. Reads 2 entries, returns 2. No sort step. THE JOB · LEADING COLUMN MISSING WHERE status = 'open'   AND due_at < '04-01' Matches (●) are scattered under every account, so there is no range to seek to. Fix: a separate partial index (due_at) WHERE status = 'open'
Equality columns first, then the range or sort column. A query that skips the leading column cannot seek into the index.

The practical rules a reviewer can apply without a database:

Which query patterns stop an existing index being used?

Sometimes the index exists and the query still scans. The query wraps the column in something the index was not built on, or asks a question a B-tree cannot answer by range. These are easy to spot in review once you know the list.

Pattern in the queryWhy the index is skippedFix
WHERE lower(email) = ?The index stores email, not lower(email)Expression index on lower(email), or a case-insensitive type or collation
WHERE date(created_at) = ?Function on the columnRewrite as a range: created_at >= ? AND created_at < ?
Parameter type differs from the columnAn implicit cast can apply to the column side (common with MySQL string vs number comparisons)Bind parameters with the column’s type
LIKE '%term%' or ILIKENo fixed prefix to seek toTrigram index (pg_trgm) or full-text search
WHERE a = ? OR b = ?One index cannot cover both branchesIndex each column (the planner may combine them) or use UNION
Mixed sort directions, e.g. ORDER BY a ASC, b DESCIndex order does not match, so a sort step is addedDeclare the index with matching directions
Low-selectivity filter, e.g. status = 'open' when 90% are openThe planner correctly prefers a scanFilter on something selective, or a partial index for the rare value
The ORM hides these too

Django’s __iexact and __icontains, RailsRails’ case-insensitive findersrsquo; case-insensitive uniqueness validation, and Spring Data’s IgnoreCase derived queries generate UPPER(), LOWER(), or ILIKE in SQL, depending on the database. A one-word change to a lookup can switch a query from an index seek to a scan. Read the generated SQL, not the method name.

Does PostgreSQL index foreign keys automatically?

No. PostgreSQL creates an index for every primary key and unique constraint, but not for the referencing column of a foreign key. The PostgreSQL documentation on foreign keys notes that indexing the referencing columns is often a good idea but is not automatic. Two things get slow without it:

Whether the column gets an index depends on the framework as much as the database:

Framework or databaseForeign key column indexed by default?
PostgreSQL, plain DDLNo. Add the index yourself
MySQL InnoDBYes. InnoDB requires an index on foreign key columns and creates one if none exists
Django ForeignKeyYes, db_index=True by default
Rails t.references / add_referenceYes, index: true by default in modern Rails
SQLAlchemy / Alembic ForeignKey columnNo, unless the column sets index=True
JPA / Hibernate @ManyToOne on PostgreSQLNo in generated schemas; declare it with @Index or in the migration

A reviewer check that catches most of these: every new foreign key column in a migration either has an index in the same migration, or a comment explaining why the table will stay small.

What EXPLAIN evidence should a reviewer ask for?

Reading the query tells you whether an index should be used. EXPLAIN tells you whether it is. The useful version for review is EXPLAIN (ANALYZE, BUFFERS) on a database with production-like row counts, such as a staging copy, a sanitized snapshot, or a read replica. Plans on a 2,000-row development table prove nothing, because the planner is right to scan small tables.

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) · beforemissing index
Limit (actual time=4210.8..4210.9 rows=50 loops=1)
  ->  Sort (actual time=4210.8..4210.8 rows=50 loops=1)
        Sort Key: due_at
        Sort Method: top-N heapsort  Memory: 32kB
        ->  Index Scan using invoices_account_id_idx on invoices
              (actual time=0.05..4174.2 rows=1916 loops=1)
              Index Cond: (account_id = 9)
              Filter: ((status = 'open') AND (due_at < now()))
              Rows Removed by Filter: 481344
              Buffers: shared hit=21870 read=164032
Execution Time: 4211.0 ms
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) · aftercomposite index
Limit (actual time=0.04..0.19 rows=50 loops=1)
  ->  Index Scan using inv_acct_status_due_idx on invoices
        (actual time=0.04..0.18 rows=50 loops=1)
        Index Cond: ((account_id = 9) AND (status = 'open') AND (due_at < now()))
        Buffers: shared hit=54
Execution Time: 0.2 ms

Notice that the “before” plan does use an index. That is the trap: “it uses an index” is not the same as “it is indexed for this query”. The number that matters is rows read versus rows returned. Here the database read 483,260 rows to return 50.

Plan signalWhat it meansReviewer action
Seq Scan on a large tableEvery row is readExpected only for small tables or when most rows match
Large Rows Removed by FilterThe index narrowed on some columns, not allExtend or reorder the index so the filter becomes an Index Cond
Sort above a large inputEvery match is sorted before LIMIT appliesAdd the sort column after the equality columns
Sort Method: external merge DiskThe sort spilled to diskSame as above; do not just raise work_mem
Nested Loop with a scan on the inner sideOne scan of the child table per outer rowIndex the join column, usually a foreign key
High Buffers: readPages came from disk, not cacheThe query will be slower still under concurrent load
EXPLAIN ANALYZE runs the statement

ANALYZE executes the query to measure it. For an UPDATE or DELETE, wrap it in BEGIN; … ROLLBACK;, and never run a heavy statement against the primary in production to get a plan. Plain EXPLAIN without ANALYZE shows the chosen plan without running it, which is often enough to see a scan. See the PostgreSQL guide to using EXPLAIN.

After release, pg_stat_statements ranks queries by total time, and pg_stat_user_tables shows seq_scan and seq_tup_read per table. A jump in sequential rows read on a large table right after a deploy is the post-merge version of the same evidence.

How do you add the index without blocking writes?

Finding the missing index is half the review. The other half is making sure the migration that adds it does not cause its own outage. On a large table, index builds take minutes, and a plain CREATE INDEX in PostgreSQL blocks inserts, updates, and deletes on the table for the whole build. Reads continue. CREATE INDEX CONCURRENTLY avoids blocking writes, at the cost of a slower build and some rules of its own.

INDEX BUILD ON A LARGE TABLE · WHAT HAPPENS TO WRITES CREATE INDEX writes reads build, one table scan INSERT / UPDATE / DELETE blocked writes resume ... CONCURRENTLY writes reads wait tx scan 1: build scan 2: catch up wait tx writes continue throughout 03 min6 min9 min12 min CONCURRENTLY: not inside a transaction block; a failed build leaves an INVALID index to drop and retry.
A plain build blocks writes for its whole duration. A concurrent build is slower but keeps the table writable. Illustrative timings.

The catch is that most migration tools wrap each migration in a transaction, and CREATE INDEX CONCURRENTLY cannot run inside one. Each framework has an escape hatch, and the reviewer should see it in the diff:

Django · migrationnon-blocking
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models

class Migration(migrations.Migration):
    atomic = False   # required: CONCURRENTLY cannot run in a transaction
    dependencies = [("invoices", "0042_invoice_due_at")]
    operations = [
        AddIndexConcurrently("invoice", models.Index(
            fields=["account", "status", "due_at"], name="inv_acct_status_due_idx")),
        AddIndexConcurrently("invoice", models.Index(
            fields=["due_at"], name="inv_open_due_idx",
            condition=models.Q(status="open"))),   # partial index for the job
    ]
Alembic · Railsnon-blocking
# Alembic (PostgreSQL)
def upgrade():
    with op.get_context().autocommit_block():
        op.create_index("inv_acct_status_due_idx", "invoices",
                        ["account_id", "status", "due_at"],
                        postgresql_concurrently=True)

# Rails
class AddOverdueIndexToInvoices < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!
  def change
    add_index :invoices, [:account_id, :status, :due_at], algorithm: :concurrently
  end
end

Three more migration checks belong in the same review:

“It uses an index” is not the same as “it is indexed for this query”. Compare rows read with rows returned.

Can the new index make things worse?

Yes, on the write side. Every index is updated on insert and on any update to an indexed column, takes disk and cache, and in PostgreSQL an index on a frequently updated column can prevent heap-only tuple (HOT) updates. Reviewers should also catch duplicate indexes (a new (account_id, status) next to an existing (account_id, status, due_at)), and indexes added “just in case” on columns no query filters on. After release, pg_stat_user_indexes.idx_scan shows whether the index is actually used.

A review procedure for index risk

INDEX RISK IN A PULL REQUEST · FOUR QUESTIONS Q1 · NEW QUERY SHAPE? New filter, sort, join, lookup, or background job? Q2 · PRODUCTION SIZE Is the table large, or growing fast, in production? Q3 · LEADING COLUMNS Does an index prefix match equality, then range/sort? Q4 · DEFEATED INDEX Function, cast, or leading wildcard on the column? No index risk Nothing new to serve Note and move on Record the size assumption Needs an index New, extended, or partial Rewrite or expression Range, lower(), trigram NONONOYES YESYESYESNO Likely covered. Confirm with EXPLAIN on production-sized data; check the migration is non-blocking.
Most pull requests exit at Q1 or Q2. The ones that reach Q3 are where missing indexes come from.
  1. List the query shapes the PR adds or changes. Include ORM filters, sorts, relation loads, new lookups, and background jobs, and read the generated SQL where the ORM hides it.
  2. Estimate the production size of each table. Row count and growth rate, plus the largest tenant or account if queries are scoped per tenant.
  3. Match each shape against existing indexes. Equality columns first, then the range or sort column, against the leading columns of an index on that table. Check new foreign key columns separately.
  4. Look for patterns that defeat an index. Functions, casts, leading wildcards, OR across columns, and mismatched sort directions.
  5. Ask for EXPLAIN evidence. EXPLAIN (ANALYZE, BUFFERS) on production-like data, comparing rows read with rows returned.
  6. Review the migration itself. Non-blocking build, deploy order, handling of an invalid index, and no duplicate of an existing index.
Per new query
  • Generated SQL read, not just the ORM call
  • Production row count known
  • Index prefix matches equality, then range/sort
  • No function or cast on the filtered column
Per new foreign key
  • Referencing column indexed
  • Parent deletes and cascades considered
  • Join direction matches the index
Per migration
  • Built concurrently or online
  • Runs before the code that needs it
  • Not a duplicate of an existing index
  • Failure and invalid-index plan stated

This belongs in the same review pass as the other data-size questions: is the new query bounded, does a loop issue a query per item, and what happens at the largest customer’s volume. How to Review a Pull Request for Production Reliability Risks covers the full checklist; the sibling post on N+1 queries that pass tests covers the per-item query problem. A query that is slow because of a missing index also holds its database connection longer, which is one of the ways pools run dry (see Connection Leak vs. Pool Exhaustion).

How Tomosu helps

The hardest part of this review is context the diff does not contain: which indexes already exist, which other code paths query the same table, and how that table is used across the service. Tomosu analyzes the repository as a whole and each pull request against it for production reliability risk, and for index risk that means:

Tomosu reads indexes from the code, so an index created by hand in production and never added to a migration will not be visible to it; that is a gap worth closing anyway. Findings roll up into the Production Reliability Index alongside the pull request’s other risks.

Scan your repository with Tomosu →

Key takeaways

Frequently asked questions

How do I know if a pull request needs a database index?

When it introduces a new query shape against a table that is large in production: a new filter column, a new sort, a new join or foreign key, or a new background scan. Compare the columns in the WHERE, JOIN, and ORDER BY clauses with the leading columns of existing indexes. If no index prefix covers them, ask for EXPLAIN output on production-sized data.

Why does a query with a missing index pass tests?

Test and development databases hold hundreds or thousands of rows, so a sequential scan finishes in about a millisecond. The same scan over tens of millions of rows in production reads the whole table on every call. Nothing in a typical test suite measures rows read, so the missing index stays invisible until the data is large.

Does PostgreSQL automatically index foreign keys?

No. PostgreSQL creates indexes for primary keys and unique constraints, but not for the referencing column of a foreign key. Joins on that column, and deletes from the parent table that must check the child table, can then scan the whole child table. MySQL InnoDB does create an index on foreign key columns if none exists.

What order should columns be in a composite index?

Put columns compared with equality first, then the column used for a range filter or sort. An index on (account_id, status, due_at) serves WHERE account_id = ? AND status = ? ORDER BY due_at. A query that filters only on status cannot use it efficiently, because the leading column is missing.

How do I add an index in production without blocking writes?

In PostgreSQL, use CREATE INDEX CONCURRENTLY. A plain CREATE INDEX blocks inserts, updates, and deletes on the table until the build finishes. The concurrent build does not block writes, takes longer, cannot run inside a transaction block, and can leave an invalid index behind if it fails, which you drop and recreate.

What should I look for in EXPLAIN output during code review?

A Seq Scan on a large table, a large Rows Removed by Filter count, a Sort over many rows or one that spills to disk, and a nested loop whose inner side scans a table. Compare rows read with rows returned. Use EXPLAIN (ANALYZE, BUFFERS) on production-like data, and remember that ANALYZE actually executes the statement.

Can adding an index make performance worse?

Yes, for writes. Every index is updated on insert and on updates to indexed columns, uses disk and cache, and in PostgreSQL can prevent heap-only tuple (HOT) updates. Duplicate or unused indexes add that cost for no benefit, so review new indexes against existing ones and check their usage after release.


The index a query needs is decided by the query, not by the table. Review the shape each change adds, and the missing index is visible before it reaches production. Assess your repository →