Index Build Memory and Parallel Builds #
Building an index on a 900 GB table takes 5 hours, which does not fit the maintenance window. Nothing about the table is unusual; the server has 256 GB of RAM and 32 cores, and the build is using 64 MB of memory and one core, because both relevant settings are at values chosen for small machines years ago.
Index maintenance operations are part of index maintenance and bloat. This page covers making them finish.
The Condition That Governs Build Speed #
CREATE INDEX on a B-tree builds by sorting: it scans the table, sorts the key values, and writes the tree bottom-up from the sorted stream. Two settings govern that sort:
maintenance_work_mem— the memory the sort may use, default 64 MB. When the data does not fit, the sort spills to temporary files and merges them, adding I/O proportional to the index size. Raising it to several gigabytes for a large build often halves the time.max_parallel_maintenance_workers(PostgreSQL 11+, default 2) — how many workers may help sort. The planner also considers the table size (min_parallel_table_scan_size) and the table’sparallel_workerssetting. Each participating process gets a share ofmaintenance_work_mem, so raising workers without raising memory can make each one spill.
Two important exclusions:
CREATE INDEX CONCURRENTLYcannot use parallel workers. It trades speed for not blocking writes: two table passes plus waits for concurrent transactions, single-threaded.- Not all index types parallelise. B-tree and BRIN builds can use workers; GIN and GiST builds are single-threaded, though they still benefit from
maintenance_work_mem.
maintenance_work_mem also governs the memory VACUUM uses to collect dead tuple identifiers, so raising it globally affects vacuum behaviour as well — usually beneficially.
Annotated Evidence #
SHOW maintenance_work_mem; -- 64MB
SHOW max_parallel_maintenance_workers; -- 2
\timing on
CREATE INDEX events_account_ts_idx ON events (account_id, occurred_at);
-- Time: 18204112.6 ms (5h 03m)
Watch a running build:
SELECT phase, blocks_done, blocks_total,
tuples_done, tuples_total, partitions_done, partitions_total
FROM pg_stat_progress_create_index;
phase | blocks_done | blocks_total | tuples_done | tuples_total
------------------------------+-------------+--------------+-------------+--------------
building index: loading tuples … | 41204110 | 118204110 | 1204110402 | 0
-- phases: initializing → waiting for writers → building index (scanning, sorting, loading)
With both settings raised for the session:
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX events_account_ts_idx ON events (account_id, occurred_at);
-- Time: 3102448.2 ms (51m)
Temporary file activity tells you whether memory was enough:
SELECT temp_files, pg_size_pretty(temp_bytes) FROM pg_stat_database WHERE datname = current_database();
-- before the change: temp_files climbing during the build
-- after: unchanged during the build
Step-by-Step Resolution #
-
Decide whether the build must be concurrent. Blocking writes for the duration is acceptable for maintenance windows and unacceptable for live tables;
CONCURRENTLYis the price for the latter. -
Raise memory for the session, not globally:
SET maintenance_work_mem = '8GB';Budget it against RAM: each parallel participant may use its share, and any concurrent vacuum uses its own allowance.
-
Raise the worker limit and make sure the global pool allows them:
SET max_parallel_maintenance_workers = 7; SHOW max_parallel_workers; -- must leave room beyond queries -
Encourage workers on the table if it is large but not obviously so to the planner:
ALTER TABLE events SET (parallel_workers = 8); -
Monitor progress with
pg_stat_progress_create_index, andpg_stat_activityto confirm workers exist. -
For partitioned tables, build per partition so each build is sized independently and can be parallel and concurrent; then attach the parent index.
-
Reset session settings afterwards; they are not meant for query workloads.
Before and After #
-- BEFORE: defaults (64MB, 2 workers considered but table not eligible)
CREATE INDEX events_account_ts_idx … Time: 5h 03m temp files written during sort
-- AFTER: maintenance_work_mem = 8GB, max_parallel_maintenance_workers = 7
CREATE INDEX events_account_ts_idx … Time: 51m no temp files
Planning the maintenance window #
An index build holds a SHARE lock on the table, which blocks writes but not reads. That makes the non-concurrent build viable for reporting tables and impossible for tables taking continuous writes. The concurrent build takes only a SHARE UPDATE EXCLUSIVE lock, allowing writes, but it waits twice for all transactions that started before each of its phases — so a single long-running transaction elsewhere can stall it indefinitely. Before starting one, check for long transactions, and consider setting a lock_timeout so the build fails fast rather than queueing behind a lock and blocking everything behind it.
Disk space matters too. A build needs room for the finished index plus, when the sort spills, temporary files of roughly the index’s size. A concurrent rebuild of an existing index needs room for both copies at once. Checking free space against pg_relation_size estimates before starting avoids the worst failure mode: running out of disk halfway through a five-hour operation.
Sizing memory without guessing #
A B-tree build sorts entries of roughly the key width plus about a dozen bytes of overhead, so an index on two 8-byte columns over 1.2 billion rows sorts around 30 GB of data. No practical maintenance_work_mem holds that, and the goal is not to avoid spilling entirely but to keep the number of merge passes low: a sort that fits its runs in a few gigabytes merges once, while a 64 MB allowance produces hundreds of runs and merges repeatedly. In practice, values between 2 GB and 8 GB cover most large builds, and going higher yields little.
Estimate before building:
SELECT pg_size_pretty(
(SELECT reltuples FROM pg_class WHERE relname = 'events')::numeric
* (8 + 8 + 12) -- two bigint keys + per-entry overhead
) AS approx_sort_volume;
Then compare it with free memory, remembering that each parallel participant takes a share, and that autovacuum workers use the same setting. On a server with other work running, a session-level SET before the build and a reset afterwards is safer than raising the global value, and it leaves vacuum’s allowance alone.
Common Pitfalls #
Leaving maintenance_work_mem at 64 MB. Every large build spills. Diagnostic signal: temporary files during builds. Fix: raise it per session.
Raising workers without memory. Each worker’s share shrinks and all of them spill. Diagnostic signal: more workers, no speedup. Fix: raise both together.
Expecting CONCURRENTLY to parallelise. It cannot. Diagnostic signal: one process, long runtime. Fix: plan for the longer build or use a maintenance window.
Global settings left raised. maintenance_work_mem applies to autovacuum workers too, multiplying memory use. Diagnostic signal: memory pressure after a maintenance change. Fix: set it per session or per maintenance role.
Frequently Asked Questions #
How do I make CREATE INDEX faster in PostgreSQL? #
Raise maintenance_work_mem for the session so the sort stays in memory, and allow parallel workers with max_parallel_maintenance_workers. Both together typically cut large B-tree build times by several times.
Can CREATE INDEX CONCURRENTLY use parallel workers? #
No. Concurrent builds are single-threaded and make two passes over the table while allowing writes. Use a non-concurrent build in a maintenance window when speed matters more than availability.
How can I monitor an index build’s progress? #
Query pg_stat_progress_create_index, which reports the current phase along with blocks and tuples processed. Combined with pg_stat_activity it also shows whether parallel workers joined the build.
Related #
- Index Maintenance and Bloat — parent guide: maintenance operations
- REINDEX CONCURRENTLY Without Downtime — sibling: rebuilding existing indexes
- Invalid Indexes After Failed Concurrent Builds — cleaning up when a build fails