How to reduce an index from 387 MB to 3 MB using a partial index

· postgresql, indexing

Now for the fourth case study. We are going to retrieve the 100 transactions that have been flagged the longest to build an anti-fraud review queue.

Scenario: PostgreSQL 18.4, a transactions table with 10 million rows, 100,194 of them with the flagged status, and a warm cache.

Scenario Time Blocks accessed Index size
No index for the queue 229.133 ms 114,670 —
Full index (status, created_at, id) 0.071 ms 104 387 MB
Partial index (created_at, id) where status = 'flagged' 0.047 ms 103 3,104 kB

The query

For the queue, we want the transactions that have been waiting the longest to come first. We therefore sort by created_at in ascending order and retrieve only the first 100:

select
    id,
    account_id,
    counterparty_id,
    amount,
    direction,
    category,
    created_at
from
    transactions
where
    status = 'flagged'
order by
    created_at,
    id
limit 100;

Notice that we use id as a tiebreaker. If two transactions have the same created_at, it guarantees a deterministic order between them.

With no index at all

If we run this query without an index for the queue, the database has to scan the entire table to find every transaction with the flagged status.

Only then can it determine which 100 are the oldest:

QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------
Limit  (cost=169105.41..169117.05 rows=100 width=53) (actual time=223.663..228.834 rows=100.00 loops=1)
  Buffers: shared hit=912 read=113758
  ->  Gather Merge  (cost=169105.41..179665.07 rows=90667 width=53) (actual time=221.458..226.622 rows=100.00 loops=1)
        Workers Planned: 2
        Workers Launched: 2
        Buffers: shared hit=912 read=113758
        ->  Sort  (cost=168105.38..168199.83 rows=37778 width=53) (actual time=208.029..208.040 rows=79.67 loops=3)
              Sort Key: created_at, id
              Sort Method: top-N heapsort  Memory: 47kB
              Buffers: shared hit=912 read=113758
              Worker 0:  Sort Method: top-N heapsort  Memory: 49kB
              Worker 1:  Sort Method: top-N heapsort  Memory: 48kB
              ->  Parallel Seq Scan on transactions  (cost=0.00..166661.54 rows=37778 width=53) (actual time=2.460..201.730 rows=33398.00 loops=3)
                    Filter: (status = 'flagged'::text)
                    Rows Removed by Filter: 3299935
                    Buffers: shared hit=820 read=113758
Planning Time: 0.091 ms
Execution Time: 229.133 ms

The Parallel Seq Scan split the work among three processes. Each one discarded an average of 3,299,935 rows to find approximately 33 thousand flagged transactions.

Even with LIMIT 100, the database cannot simply stop after finding 100 rows. Without an index that returns the results in created_at, id order, it has to find every candidate and use a top-N heapsort to determine which 100 are the oldest.

In total, it accessed 114,670 blocks—approximately 896 MB—to return 100 transactions.

A first alternative: full index

As a first approach, we could create an index starting with the status, followed by the columns used for sorting:

create index idx_transactions_status_queue
    on transactions (status, created_at, id);

Because status is used with = in the where clause, all flagged transactions are grouped together in the index. Within that set, created_at and id are already in the order requested by the query.

Running it again:

QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit  (cost=0.56..159.84 rows=100 width=53) (actual time=0.019..0.060 rows=100.00 loops=1)
  Buffers: shared hit=104
  ->  Index Scan using idx_transactions_status_queue on transactions  (cost=0.56..144411.93 rows=90667 width=53) (actual time=0.019..0.054 rows=100.00 loops=1)
        Index Cond: (status = 'flagged'::text)
        Index Searches: 1
        Buffers: shared hit=104
Planning Time: 0.037 ms
Execution Time: 0.071 ms

The Parallel Seq Scan and Sort are gone. The database now descends the B-tree to the first status = 'flagged' entry and reads the leaf nodes in created_at, id order. As soon as it finds the first 100 entries, Limit stops the execution.

The query dropped from 229.133 ms to 0.071 ms. However, this index has a problem: it also stores every settled and pending transaction, even though they are never used in the anti-fraud queue.

Out of 10 million transactions, only 100,194—approximately 1%—have the status we care about. Even so, the full index occupies 387 MB to store all of them.

Partial index

But what if we could create an index containing only the transactions that belong in the queue?

In PostgreSQL, we can do that with a partial index:

create index idx_transactions_flagged_queue
    on transactions (created_at, id)
    where status = 'flagged';

Notice that status does not have to be one of the index columns. The index itself contains only rows where status = 'flagged', so every one of its entries already satisfies that condition.

The only key columns are created_at and id, exactly the order in which the queue needs to be read:

QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------------
Limit  (cost=0.42..290.05 rows=100 width=53) (actual time=0.007..0.037 rows=100.00 loops=1)
  Buffers: shared hit=103
  ->  Index Scan using idx_transactions_flagged_queue on transactions  (cost=0.42..262604.12 rows=90667 width=53) (actual time=0.007..0.032 rows=100.00 loops=1)
        Index Searches: 1
        Buffers: shared hit=103
Planning Time: 0.046 ms
Execution Time: 0.047 ms

The plan still uses an Index Scan without having to sort the results, and it accessed practically the same number of blocks as the full index: 103 versus 104.

The biggest difference appears in their sizes:

select
    pg_size_pretty(pg_relation_size('idx_transactions_status_queue')) as full_index_size,
    pg_size_pretty(pg_relation_size('idx_transactions_flagged_queue')) as partial_index_size;
 full_index_size | partial_index_size
-----------------+-------------------
 387 MB          | 3104 kB

The partial index occupies only 0.78% of the space used by the full index because it does not carry the other 99% of the transactions.

What changed

Without an index, the database read approximately 896 MB from the table, discarded almost 10 million rows, and still had to sort them with a top-N heapsort. The query took 229.133 ms.

With either index, the database could return the data in the requested order and stop reading after the first 100 rows. That is why the execution times for the full and partial indexes were close: 0.071 ms and 0.047 ms.

So why use the partial index?

Because it provides the same access path while occupying 3,104 kB instead of 387 MB. Besides being easier to keep in cache, it does not have to be updated when a settled or pending transaction is inserted. In this case, the main advantage is not the 0.024 ms saved on the lookup, but avoiding the maintenance of an index that is 127 times larger than necessary.

There is just one caveat: PostgreSQL can only use a partial index when it can prove that the query’s where clause satisfies the index predicate. Therefore, the query must explicitly keep status = 'flagged'. In a prepared statement with status = $1, for example, a generic plan might not use this index because the parameter value is unknown at planning time.