Product1 publisher3 min readPublished
Three indexes turn a 40-byte Postgres row into 345 bytes of write-ahead log
TigerData measured a single insert on PostgreSQL 16 and counted four write-ahead log records where the application had sent one row. The post also publishes the probe that lets you check the ratio on your own tables.
The Product Desk · Product desk

What happened
- A 40-byte sensor reading written to a table with three indexes produces four separate write-ahead log records, and the log grows by roughly 345 bytes before the commit returns.
- Because Postgres has no partial index maintenance on write, every insert updates every index, synchronously, inside the same transaction.
- The post publishes a probe built on pg_current_wal_lsn() and pg_stat_wal so a team can measure the real ratio on its own tables.
Compiled by The Product DeskSomething wrong?How this is made
Why it matters
- cost The index behind a weekly report is billed on every row for the life of the table. The ingest path pays that bill; the person who asked for the report does not.
- constraint A capacity plan sized on 40 MB per million rows is short by 305 MB of log on this table shape. That gap is how a workload that looks trivial on paper saturates disk throughput.
- decision An index request can be priced before it ships: the probe run on a scratch copy shows whether the index justifies its per-row cost.
- exposure Replicas are fed at the log rate, not the payload rate, so replica lag becomes an index-count question according to the publisher's companion article on performance limits.
An index goes in to fix a slow dashboard, and two weeks later p95 insert latency is climbing. TigerData's post opens on exactly that sequence [15]. The delay is why teams misfile the problem. The dashboard got faster the day the index shipped, so the index looks paid for. The write cost arrives later, charged on every row. It shows up as disk throughput, and there is no query anyone can point at.
The measured gap is wide. 345 bytes of log for a 40-byte payload is 8.6x [1], and the payload is about 12 percent of what the log holds [2]. Per million rows, that is 345 MB of write-ahead log where the application sent 40 MB [3].
Some of it is charged before any index exists. Every heap tuple carries a fixed 23-byte visibility header, padded to 24 bytes so data starts on an 8-byte boundary, and the page adds a 4-byte line pointer [6]. `pg_column_size` reports the 40-byte reading as 64 stored bytes, 68 with the pointer, which the post puts at 1.7x [7]. Postgres charges that bookkeeping on append-only rows that will never be updated [8].
The three indexes add roughly 32 bytes each [10], so 96 bytes of index tuple per row [4]. Tuple, line pointer and index entries come to about 164 bytes of page-level writes, 4.1x the payload, before the log records land [5]. Four records across 345 bytes averages 86 bytes each [6], and the post reports the total only, so a team dropping one of the three cannot assume it gets 86 bytes back.
The post says the traversal matters as much as the bytes [21]. Postgres has no partial index maintenance on write: every index is updated by every insert, synchronously, inside the same transaction [9]. That is three B-tree descents from root to leaf and three leaf page modifications per row [11]. On a monotonically increasing timestamp key, every insert lands on the same rightmost leaf page, which fills, splits, and can cascade toward the root [12]. A high-cardinality index spreads the same inserts across the tree, so the pages needed are less likely to be sitting in `shared_buffers` [13].
Every figure came from PostgreSQL 16 with default settings [3], on a four-column table the post creates for the purpose [5]. The publisher is a vendor whose own header sells Postgres as the 40-year-old database "still winning in the AI era" [19]. Defaults are the part to check locally, and the post raises `full_page_writes`, then points to the PostgreSQL documentation for the detail [18]. The probe is the part that travels: `pg_current_wal_lsn()` and `pg_stat_wal` report WAL bytes before and after a known count of inserts on your own table [4]. The post lists four ways to bring the ratio down [16].
For each index on a high-ingest table, two numbers sit next to each other: reads it serves in a week, and rows inserted in a week. The post's own example is the index behind one weekly report, charged 32 bytes and a tree descent on every insert for the life of the table [14]. Where the read count is small against the insert count, dropping it is the right call. The cost is that the report's owner feels the slower query on Monday morning, and the write saving stays invisible unless somebody measured it. The difference in WAL bytes per row, before and after, takes a few minutes to establish on a scratch copy [4].
What to watch
- Whether the post's four reduction techniques are published with measured before-and-after WAL bytes per row, or only described.
- Whether the same probe produces a different ratio on non-default settings, particularly with full_page_writes changed.
- Whether anyone publishes the per-record WAL split, which would tell you what each index costs individually.