Without indexes, general purpose relational databases lose efficient search, unique constraints and primary keys. On realistic data volumes, good performance is unattainable without them. The cost of building those structures on very large tables — sorting followed by insertion into the index — is what the measurements below examine.
Test data
The demo set is a single table with a billion rows, created as follows:
|
1 2 3 4 5 6 7 |
blog=# CREATE TABLE t_data AS SELECT id::int4, (random() * 1000000000)::int4 AS i, (random() * 1000000000) AS r, (random() * 1000000000)::numeric AS n FROM generate_series(1, 1000000000) AS id; Time: 1569002.499 ms (26:09.002) |
Its properties matter later. The id column is an ascending number, which is significant during index creation. The second column holds a random value multiplied by the number of rows, cast to integer. The third column is double precision, and the last stores comparable data as numeric — a floating point type that does not use the CPU's floating point unit internally.
Table size can be checked once the rows exist:
|
1 2 3 4 5 |
blog=# SELECT pg_size_pretty(pg_relation_size('t_data')); pg_size_pretty ---------------- 56 GB (1 row) |
Before comparing runs, set the hint bits so the measurements stay fair:
|
1 2 3 |
blog=# VACUUM ANALYZE; VACUUM Time: 91293.971 ms (01:31.294) |
Progress of that operation can be followed through a system view, which is also useful for diagnosing long VACUUM runs:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
demo=# SELECT * FROM pg_stat_progress_vacuum; -[ RECORD 1 ]------+-------------- pid | 5945 datid | 1515520 datname | blog relid | 1515537 phase | scanning heap heap_blks_total | 7352960 heap_blks_scanned | 212599 heap_blks_vacuumed | 0 index_vacuum_count | 0 max_dead_tuples | 291 num_dead_tuples | 0 |
Individual indexes on the columns are then created simply:
|
1 2 3 4 5 6 7 8 |
test=# \d t_data Table "public.t_data" Column | Type | Collation | Nullable | Default --------+------------------+-----------+----------+--------- id | integer | | | i | integer | | | r | double precision | | | n | numeric | | | |
Index creation on the default configuration
All timings below use PostgreSQL defaults and were taken on an AMD Ryzen Threadripper 2950X 16-Core processor. Each column is indexed in turn:
|
1 2 3 |
blog=# CREATE INDEX ON t_data (id); CREATE INDEX Time: 291700.318 ms (04:51.700) |
Around five minutes per index, before any tuning — with a billion rows. The system view reveals what the backend is doing:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
blog=# SELECT * FROM pg_stat_progress_create_index; -[ RECORD 1 ]------+------------------------------- pid | 5945 datid | 1515520 datname | blog relid | 1515537 index_relid | 0 command | CREATE INDEX phase | building index: scanning table lockers_total | 0 lockers_done | 0 current_locker_pid | 0 blocks_total | 7352960 blocks_done | 703280 tuples_total | 0 tuples_done | 0 partitions_total | 0 partitions_done | 0 |
First the table is scanned and data is prepared for the sort that follows:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
-[ RECORD 1 ]------+------------------------------------ pid | 5945 datid | 1515520 datname | blog relid | 1515537 index_relid | 0 command | CREATE INDEX phase | building index: sorting live tuples lockers_total | 0 lockers_done | 0 current_locker_pid | 0 blocks_total | 7352960 blocks_done | 7352960 tuples_total | 0 tuples_done | 0 partitions_total | 0 partitions_done | 0 |
The sort is the expensive part and deserves proper tuning. Once sorting is finished, the sorted tuples are inserted into the index:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
-[ RECORD 1 ]------+--------------------------------------- pid | 5945 datid | 1515520 datname | blog relid | 1515537 index_relid | 0 command | CREATE INDEX phase | building index: loading tuples in tree lockers_total | 0 lockers_done | 0 current_locker_pid | 0 blocks_total | 0 blocks_done | 0 tuples_total | 1000000000 tuples_done | 263748386 partitions_total | 0 partitions_done | 0 |
Indexing the second, randomly distributed column takes noticeably longer despite identical data type and volume, roughly two extra minutes — the sort is substantially larger when rows arrive in random order:
|
1 2 3 |
blog=# CREATE INDEX ON t_data (i); CREATE INDEX Time: 401634.592 ms (06:41.635) |
Data type matters as much as physical order:
|
1 2 3 |
blog=# CREATE INDEX ON t_data (r); CREATE INDEX Time: 441994.372 ms (07:21.994) |
The double precision column is another 40 seconds slower; a double precision value is larger than an integer, which contributes to the difference.
The numeric column behaves differently again:
|
1 2 3 |
blog=# CREATE INDEX ON t_data (n); CREATE INDEX Time: 799659.658 ms (13:19.660) |
Two factors dominate: whether the data is already sorted, and which data type is indexed. Both are routinely underestimated by people who reach only for more RAM and more CPUs.
Over-tuning
Throwing hardware and parameters at the problem is the usual first response, typically through these settings:
|
1 2 3 4 5 6 7 8 |
blog=# ALTER SYSTEM SET max_wal_size TO '100 GB'; ALTER SYSTEM blog=# ALTER SYSTEM SET max_parallel_maintenance_workers TO 10; ALTER SYSTEM blog=# ALTER SYSTEM SET maintenance_work_mem TO '16 GB'; ALTER SYSTEM blog=# ALTER SYSTEM SET shared_buffers TO '64 GB'; ALTER SYSTEM |
A restart is required afterwards; without changing shared_buffers, SELECT pg_reload_conf() would suffice. In detail:
- max_wal_size — controls the distance between checkpoints and the size of the WAL, reducing I/O volume and improving I/O speed generally.
- max_parallel_maintenance_workers — the upper limit on worker processes PostgreSQL may start for index builds.
- maintenance_work_mem — how much memory each operation may use.
- shared_buffers — the size of the I/O cache.
Re-running the index creation:
|
1 2 3 |
blog=# CREATE INDEX ON t_data (n); CREATE INDEX Time: 474907.805 ms (07:54.908) |
Time dropped from about 13 min 19 sec to under 8 minutes — not even a doubling of speed. The CPU view explains why:
|
1 2 3 4 5 6 7 8 9 |
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 8773 hs 20 0 67.8g 2.2g 196256 R 99.0 1.8 1:05.88 postgres 8824 hs 20 0 66.6g 968576 170576 R 99.0 0.7 0:18.96 postgres 8825 hs 20 0 66.6g 1.0g 170796 R 99.0 0.8 0:18.98 postgres 8826 hs 20 0 67.4g 1.8g 170796 R 99.0 1.4 0:18.97 postgres 8827 hs 20 0 67.4g 1.8g 170796 R 99.0 1.4 0:18.95 postgres 8829 hs 20 0 66.6g 990.8m 170796 R 99.0 0.8 0:18.93 postgres 8823 hs 20 0 67.4g 1.8g 170796 R 98.7 1.4 0:18.95 postgres 8828 hs 20 0 66.7g 1.0g 170376 R 98.7 0.8 0:18.95 postgres |
The sample was caught during the sort. Only the sort phase parallelizes well; many other stages cannot run in RAM or concurrently, so even with many cores the gain stays modest. The limiting factor is the local SSD at roughly 600 MB/sec during sorting. More striking still: the default configuration sorting integer values is faster than a fully parallel index creation with the raised parameter settings.
Configuration tuning is therefore not the only lever — choosing the right data type can make just as large a difference.



