What does each extra index actually cost you?

Measured on Postgres 16 at one million rows: every extra btree index costs 76 bytes of WAL per row written and about 1.5 microseconds per inserted row. One index halved insert throughput; eight left 8 percent of it. But on two of the four write paths the extra indexes cost exactly nothing.

Two key schemes feeding one index

Each extra btree index cost 76 bytes of WAL per row written and about 1.5 µs per inserted row — enough that one index halved insert throughput on a one-million-row Postgres 16 table, from 754,000 rows a second to 378,000, and eight indexes left 8% of it. That is the part everyone expects. The part nobody publishes is that on two of the four write paths, extra indexes cost nothing at all — byte for byte, at zero indexes and at eight.

The short answer

  • On Postgres 16 at one million rows, each extra btree index on a random int column added 76 bytes of WAL per inserted row — 75.9, 76.5, 76.3 and 76.2 bytes at 1, 2, 4 and 8 indexes, stable to 1% across eight runs and two separate containers.
  • The same 76 bytes reappear on an UPDATE that cannot take the HOT path, because a non-HOT update inserts a fresh entry into every index on the table — including the seven whose columns the UPDATE never mentioned.
  • When HOT is available, extra indexes are free on UPDATE. Updating an unindexed column at fillfactor=25 cost 80.2 bytes of WAL per row at 100% HOT with zero indexes, and 80.2 bytes at 100% HOT with eight. Byte-identical, on every run.
  • DELETE is index-count-independent: 56.2 bytes of WAL per row at 0, 1, 2, 4 and 8 indexes, because a delete only stamps the heap tuple. The bill arrives at VACUUM, where cleaning 300,000 dead rows cost an extra 13.1 MB of WAL and roughly 40 ms per index.
  • The cost per index is a fixed amount of time, not a fixed percentage. Each index added ~1.5 µs to a 1.33 µs baseline, so the first index is the expensive one: it costs half the insert throughput, while going from seven indexes to eight costs 11% of what is left.

What was measured, and on what

Postgres 16.15 (aarch64) in Docker 29.5.2 on an Apple M3 laptop: 8 cores, 16 GB RAM, macOS 26.4.1, shared_buffers=512MB, --shm-size=1g, wal_level=replica, synchronous_commit=on, wal_compression=off. These are indicative figures from a laptop in Docker, not a tuned server benchmark. What transfers is the per-index increment, which reproduced to within a percent.

One table, one million rows, about 135 MB of heap:

CREATE TABLE t (id bigint PRIMARY KEY,
                k1 int, k2 int, k3 int, k4 int, k5 int, k6 int, k7 int, k8 int,
                hot_col int, payload text);

k1 through k8 hold uniformly random values in 1..1,000,000, so a btree on each is the realistic bad case: every insert lands on a different leaf page. hot_col and payload are never indexed.

On measuring WAL honestly. Every figure is a pg_current_wal_lsn() delta, not pg_stat_wal, which lags. Full-page images are the trap: the first write to a page after a checkpoint logs the whole page, which at eight indexes can be most of the window. So each measurement runs CHECKPOINT, then an identical warm-up statement to absorb the images, then the measured statement — with pg_walinspect's pg_get_wal_stats() confirming fpi_size. Quoted windows were FPI-free or under 0.5% FPI unless stated.

Each extra index adds about 76 bytes of WAL per row written

How much does each index slow down an INSERT?

Bulk-loading the million rows with the indexes already in place, eight runs per index count, median and full spread:

Secondary indexes Median rows/sec Range µs per row Added per index Share of no-index rate
0 753,702 548k – 1,035k 1.33 100%
1 378,144 305k – 454k 2.64 1.32 µs 50.2%
2 227,660 145k – 288k 4.39 1.53 µs 30.2%
4 134,240 67k – 158k 7.45 1.53 µs 17.8%
8 62,751 45k – 76k 15.94 1.83 µs 8.3%

The throughput column is noisy — this is a laptop, and I would not defend any single value to better than 30%. The µs per row column is not, and it is the one that matters: each index adds a roughly constant 1.3 to 1.8 µs per row.

That constant is why "indexes cost X% of your writes" is the wrong shape of rule. Against a 1.33 µs baseline the first index roughly doubles an insert; the eighth adds 13% to what seven already cost. If you are agonising over a ninth index, you are agonising over the cheapest one you will ever add.

How much extra WAL does each index generate?

This is where the measurement stops being noisy and starts being an equation.

Secondary indexes WAL per inserted row Delta per index
0 234.8 B
1 310.7 B 75.9 B
2 387.7 B 76.5 B
4 539.8 B 76.3 B
8 844.4 B 76.2 B

Eight runs each; the largest deviation across all forty was 0.03%. A four-byte key, a six-byte heap pointer, btree tuple headers and the WAL record framing come to 76 bytes on the wire per index per row. Multiply that by your write rate when sizing replication bandwidth or WAL archive storage: eight indexes on a table taking 5,000 inserts a second is an extra 3 MB/sec of WAL, forever.

Does an index on a column the UPDATE never touches still cost anything?

Yes — but only when HOT is unavailable, and then it costs full price.

A heap-only tuple update writes the new row version onto the same page as the old one and leaves every index alone. Postgres takes that path when no indexed column changed and there is room on the page. Break either condition and the new version needs an entry in every index, related to your change or not.

Same table, same 1M rows, UPDATE t SET hot_col = hot_col + 1 WHERE id <= 100000 — a column in no index at all. The only variable is fillfactor, which decides whether there is room on the page:

Indexes ff=25, WAL/row ff=25, HOT ff=100, WAL/row ff=100, HOT ff=100 rows/sec
0 80.2 / 82.5 / 82.5 B 100,000/100,000 315.2 B 0/100,000 371,733
1 80.2 / 82.5 / 82.5 B 100,000/100,000 392.3 B 0/100,000 140,999
2 80.2 / 82.5 / 82.5 B 100,000/100,000 468.7 B 0/100,000 85,115
4 80.2 / 82.5 / 82.5 B 100,000/100,000 622.6 B 0/100,000 47,644
8 80.2 / 82.5 / 82.5 B 100,000/100,000 930.9 B 0/100,000 24,406

Three runs per cell; the ff=100 WAL column is the median and includes under 0.5% full-page images. The left half is the result I did not expect to be this clean. Eight indexes, and the update path is byte-identical to a table with none: 8,023,5xx bytes of WAL for 100,000 rows on the first round and 8,252,8xx on the two after it, at every index count without exception. On the HOT path an index you never touch is not cheap. It is free.

The right half is the same statement at the default fillfactor=100, where a freshly loaded page has no room for a second row version. HOT drops to zero and the per-index cost reappears at exactly the insert-path rate: 76.6, 76.3, 76.4 and 76.5 bytes per index per row once full-page images are subtracted. The statement also runs 15x slower at eight indexes than at none, because each row now costs 4.5 µs per index instead of nothing.

So the honest form of the folklore is not "indexes slow down updates". It is an index you never touch costs 76 bytes and 4.5 µs on every update the moment HOT stops working — and on a default-fillfactor table just after loading, HOT is already not working.

Those 930.9 bytes are the steady state. Run the same statement as the first thing after a checkpoint and it cost 2,436.8 bytes per row, 65% of it full-page images — a production cost as well as a measurement hazard.

Where does HOT come back? Sweeping fillfactor at eight indexes, same update:

fillfactor HOT updates Heap size Rows/sec (3 runs)
100 0 / 100,000 148 MB 48,130 – 53,090
90 11,542 / 100,000 164 MB 64,014 – 70,866
70 45,000 / 100,000 206 MB 96,978 – 116,142
50 100,000 / 100,000 269 MB 1,243,812 – 1,326,348
25 100,000 / 100,000 558 MB 886,682 – 1,095,482

The HOT counts were identical to the row across all three runs — 11,542 at fillfactor=90, three times out of three. The cliff between 70 and 50 is a 12x throughput step bought with 30% more disk. On a table whose hot columns are unindexed and updated often, free space on the page is what makes the indexes free.

What does updating an indexed column cost?

Everything the fillfactor bought you. Same table at fillfactor=25 — plenty of room — but the update now changes k1, which one index covers:

Indexes Column updated WAL per row HOT Rows/sec (median)
8 hot_col, unindexed 80.2 B 100,000/100,000 1,158,816
1 k1, indexed 213.4 B 0/100,000 216,758
2 k1, indexed 294.7 B 0/100,000 62,444
4 k1, indexed 457.5 B 0/100,000 48,893
8 k1, indexed 783.1 B 0/100,000 22,003

Changing one indexed column disqualifies the row from HOT, and then all eight indexes must be updated — including the seven that have never heard of k1. The increment is 81.3 bytes per index per row, and the statement costs 9.8x the WAL and 50x the time of the identical update against an unindexed column on the same table. (Those are settled values; the first update after a fresh CREATE INDEX ran ~90 B/row higher at every count, because a newly built btree is packed to 90% and the new entries split pages. The increment held either way.)

This is the mechanism that makes a single index on a jsonb column so expensive: there, any edit to the document counts as changing the indexed column.

How much disk do the indexes take?

At one million rows, with the indexes built by CREATE INDEX after loading:

Secondary indexes Heap All indexes Total Index : heap
0 135 MB 21 MB 156 MB 0.16
1 135 MB 40 MB 175 MB 0.30
2 135 MB 59 MB 194 MB 0.44
4 135 MB 97 MB 231 MB 0.72
8 135 MB 172 MB 307 MB 1.28

Eight indexes on four-byte integers occupy more space than the table they index. Each btree is 18.9 MB for a column holding 4 MB of data: a key, a six-byte pointer, headers, roughly 10% slack. Grown by insert rather than built, they come to 191 MB instead of 151 MB, 26% larger. Bulk-loading a table? Load first, index after.

Are DELETEs slower with more indexes?

No, and this one is exact. DELETE FROM t WHERE id BETWEEN ... for 100,000 rows, with full_page_writes off so the windows are unambiguous:

Secondary indexes WAL for 100,000 deletes Per row
0 5,616,568 B 56.2 B
1 5,616,504 B 56.2 B
2 5,616,520 B 56.2 B
4 5,616,504 B 56.2 B
8 5,616,504 B 56.2 B

Three runs each; the total spread across all fifteen was 104 bytes on 5.6 MB. A DELETE stamps xmax on the heap tuple and stops. It never touches the indexes, so all eight entries are still there, still pointing at a dead row.

What does VACUUM cost per index?

That is where the deferred bill lands. After deleting 300,000 of the million rows:

Secondary indexes VACUUM time (median of 3) WAL written Full-page images
0 36 ms 9.4 MB 4,274
1 142 ms 22.5 MB 6,672
2 148 ms 35.6 MB 9,068
4 252 ms 61.9 MB 13,863
8 373 ms 114.3 MB 23,451

Perfectly linear: 13.12 MB of WAL and 2,397 full-page images per index at every index count, to remove 300,000 dead entries. Each index is only 2,409 pages in total, so vacuuming 30% of the table rewrites 99.5% of every secondary index — randomly scattered dead entries reach essentially every leaf page.

Turn full-page writes off and the same vacuum writes 1.20 MB per index instead of 13.12 MB. The logical work is 4 bytes per dead entry; the other 40 are the cost of dirtying a page untouched since the last checkpoint. On a high-churn table most of your vacuum WAL is not the vacuum, it is the checkpoint interaction — the effect that makes autovacuum the first thing to break under sustained queue load.

Does it matter whether the indexed values arrive in order?

More than the index count does. Same eight indexes, same million rows; the only change is whether the indexed values are random or monotonically increasing:

Indexed values Rows/sec (3 runs) WAL per row Index size
Random 71,889 / 73,754 / 70,509 844.4 B 212 MB
Sequential 231,389 / 185,406 / 245,850 765.2 B 193 MB

3.2x the insert throughput for the same eight indexes (WAL excludes full-page images). A sequential key appends to the rightmost leaf; a random key dirties a different page every time. So "eight indexes" is not one number — one more reason the primary key type matters.

So what is the rule?

For a narrow btree on Postgres 16, per extra index:

  • 76 bytes of WAL per row inserted, and per row updated when HOT is unavailable. Reproducible to 1%; multiply by your write rate for the replication and archive bill.
  • About 1.5 µs per inserted row and 4.5 µs per non-HOT updated row — roughly 1 / (1 + 1.15n) of the no-index insert rate at n indexes: 50% at one, 18% at four, 8% at eight.
  • Roughly 19 MB per million rows on disk for a 4-byte key, 24 MB if grown by random insert rather than built.
  • 13 MB of WAL and ~40 ms per 300,000 dead rows at vacuum time.
  • Zero on updates that stay HOT, and zero on deletes. Both exactly zero.

The consequence is not "have fewer indexes". It is that an index's write cost is dominated by whether your updates stay on the HOT path, decided by two things you control: never index a column your hot UPDATE statements change, and leave room on the page for the new row version. Get those right and the eighth index costs almost nothing on update. Get them wrong and the index you added for one report sits on the critical path of every write — so read the plan first.

Check it yourself

One container, ninety seconds, nothing installed. It loads a million rows at 0, 1, 2, 4 and 8 indexes, measures insert throughput and WAL per row at each, then runs the same update three ways: no room on the page, room, and against an indexed column.

docker run --rm -d --name cite-idx-demo --shm-size=1g \
  -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=bench -p 55608:5432 \
  postgres:16 -c shared_buffers=512MB -c max_wal_size=4GB -c checkpoint_timeout=30min
until docker exec cite-idx-demo pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done

docker exec cite-idx-demo psql -U postgres -d bench -q \
 -c "CREATE EXTENSION pg_walinspect;" \
 -c "CREATE TABLE src AS SELECT g AS id,
       (random()*1000000)::int k1,(random()*1000000)::int k2,(random()*1000000)::int k3,
       (random()*1000000)::int k4,(random()*1000000)::int k5,(random()*1000000)::int k6,
       (random()*1000000)::int k7,(random()*1000000)::int k8,
       0 AS hot_col, md5(g::text)||md5((g+1)::text) AS payload
     FROM generate_series(1,1000000) g;" \
 -c "VACUUM ANALYZE src;"

The measurement itself. The warm-up UPDATE before the measured one exists only to absorb full-page images, so the WAL delta is the real cost and not the checkpoint's.

run() {   # run <n indexes> <fillfactor> <column to update>
  local N=$1 FF=$2 COL=$3 IDX="" i=1
  while [ $i -le $N ]; do IDX="$IDX CREATE INDEX ix$i ON t (k$i);"; i=$((i+1)); done
  docker exec cite-idx-demo psql -U postgres -d bench -q \
    -c "DROP TABLE IF EXISTS t;" \
    -c "CREATE TABLE t (id bigint PRIMARY KEY, k1 int,k2 int,k3 int,k4 int,
                        k5 int,k6 int,k7 int,k8 int, hot_col int, payload text)
        WITH (fillfactor=$FF, autovacuum_enabled=off);" \
    -c "$IDX" -c "CHECKPOINT;"
  docker exec -i cite-idx-demo psql -U postgres -d bench -tAq <<SQL
SELECT pg_current_wal_lsn() l0, clock_timestamp() t0 \gset
INSERT INTO t SELECT * FROM src;
SELECT clock_timestamp() t1, pg_current_wal_lsn() l1 \gset
SELECT rpad('n=$N ff=$FF',12) || ' INSERT '
  || lpad(round(1000000/extract(epoch from (:'t1'::timestamptz-:'t0'::timestamptz)))::text,8) || ' rows/s '
  || lpad(round(pg_wal_lsn_diff(:'l1',:'l0')/1000000.0,1)::text,7) || ' B WAL/row  fpi='
  || (SELECT coalesce(sum(fpi_size),0) FROM pg_get_wal_stats(:'l0',:'l1'));
SQL
  docker exec cite-idx-demo psql -U postgres -d bench -q -c "VACUUM ANALYZE t;" -c "CHECKPOINT;"
  docker exec cite-idx-demo psql -U postgres -d bench -q \
    -c "UPDATE t SET $COL = $COL + 1 WHERE id <= 100000;"   # warm-up: absorbs full-page images
  docker exec -i cite-idx-demo psql -U postgres -d bench -tAq <<SQL
SELECT pg_current_wal_lsn() l0, clock_timestamp() t0 \gset
BEGIN;
UPDATE t SET $COL = $COL + 1 WHERE id <= 100000;
SELECT pg_stat_get_xact_tuples_hot_updated('t'::regclass) hot \gset
COMMIT;
SELECT clock_timestamp() t1, pg_current_wal_lsn() l1 \gset
SELECT rpad('n=$N ff=$FF',12) || ' UPDATE $COL '
  || lpad(round(100000/extract(epoch from (:'t1'::timestamptz-:'t0'::timestamptz)))::text,8) || ' rows/s '
  || lpad(round(pg_wal_lsn_diff(:'l1',:'l0')/100000.0,1)::text,7) || ' B WAL/row  HOT '
  || lpad(:'hot',6) || '/100000  idx=' || pg_size_pretty(pg_indexes_size('t'))
  || ' fpi=' || (SELECT coalesce(sum(fpi_size),0) FROM pg_get_wal_stats(:'l0',:'l1'));
SQL
}

for n in 0 1 2 4 8; do run $n 100 hot_col; done   # no room on the page: no HOT
for n in 0 8;       do run $n  25 hot_col; done   # room on the page: all HOT
for n in 1 8;       do run $n  25 k1;      done   # HOT killed by an indexed column

docker rm -f cite-idx-demo

A run of exactly that on the machine described above printed:

--- default fillfactor: no room on the page, so no HOT updates ---
n=0 ff=100   INSERT  1103042 rows/s   234.8 B WAL/row  fpi=14376
n=0 ff=100   UPDATE hot_col   380575 rows/s   315.2 B WAL/row  HOT      0/100000  idx=24 MB fpi=0
n=1 ff=100   INSERT   534868 rows/s   311.2 B WAL/row  fpi=0
n=1 ff=100   UPDATE hot_col   181026 rows/s   388.7 B WAL/row  HOT      0/100000  idx=51 MB fpi=0
n=2 ff=100   INSERT   303880 rows/s   387.4 B WAL/row  fpi=0
n=2 ff=100   UPDATE hot_col   122091 rows/s   461.3 B WAL/row  HOT      0/100000  idx=78 MB fpi=5072
n=4 ff=100   INSERT   167175 rows/s   539.7 B WAL/row  fpi=0
n=4 ff=100   UPDATE hot_col    73860 rows/s   608.4 B WAL/row  HOT      0/100000  idx=133 MB fpi=5072
n=8 ff=100   INSERT    80441 rows/s   844.8 B WAL/row  fpi=0
n=8 ff=100   UPDATE hot_col    37423 rows/s   902.4 B WAL/row  HOT      0/100000  idx=242 MB fpi=5072
--- fillfactor 25: HOT updates possible, same eight indexes ---
n=0 ff=25    INSERT   666679 rows/s   234.8 B WAL/row  fpi=0
n=0 ff=25    UPDATE hot_col  1293946 rows/s    80.2 B WAL/row  HOT 100000/100000  idx=21 MB fpi=0
n=8 ff=25    INSERT    70697 rows/s   845.1 B WAL/row  fpi=149968
n=8 ff=25    UPDATE hot_col  1115934 rows/s    80.2 B WAL/row  HOT 100000/100000  idx=213 MB fpi=0
--- fillfactor 25, but the UPDATE changes an indexed column ---
n=1 ff=25    INSERT   337278 rows/s   311.2 B WAL/row  fpi=0
n=1 ff=25    UPDATE k1   283010 rows/s   229.5 B WAL/row  HOT      0/100000  idx=54 MB fpi=0
n=8 ff=25    INSERT    76939 rows/s   844.8 B WAL/row  fpi=0
n=8 ff=25    UPDATE k1    41667 rows/s   742.4 B WAL/row  HOT      0/100000  idx=238 MB fpi=0

The three blocks are the whole article. In the first, WAL per row climbs by a flat 76 bytes per index on the insert line and 73 on the update line, and HOT is zero throughout. In the second, HOT is 100,000 out of 100,000 and the update line reads 80.2 bytes per row at eight indexes and 80.2 at none, while the insert line still shows all eight at 845 bytes. In the third, the only change is which column the UPDATE names — and the same eight indexes go from free to 742 bytes a row.

Index sizes here are larger than the table above (242 MB against 172 MB) because the script creates the indexes before loading rather than after.

Where this goes next

The tables behind Workflow Builder carry a status column that every run updates and a set of reporting indexes that never touch it. These measurements are why the status column is not in any of them.