Summary
On a 2M-row PostgreSQL orders table with random_page_cost = 1.1, a covering index (customer_id) INCLUDE (id, total, status, shipped_at) costs +28% on INSERTs and +25% on UPDATEs vs. a plain single-column (customer_id) index, and +88% / +30% vs. no secondary index at all. It buys you 1.26x faster reads vs. single-column and 2.33x vs. no index. Break-even: covering beats single-column below ~4,300 writes/minute per analytical read/minute. Storage: 112 MB vs. 18 MB vs. 0. Measured on PostgreSQL 17 using ExoBench with EXPLAIN (ANALYZE, BUFFERS). Full data, DDL, and break-even math below.
"It Has Write Overhead" Is Not a Number
Software architects love to speak in maxims:
"Adding a covering index is going to hurt your writes."
"Don't use UUIDs as primary keys, it kills your inserts."
"You shouldn't put that field on the hot path."
I've heard variations of these for fifteen years. In code reviews. In design docs. In Slack threads at midnight. From senior engineers, principal engineers, staff engineers, distinguished engineers, and even once the CTO. Every single time, I asked the same question:
"Sure but how much?"
Every single time, the answer was some version of: "I don't know, but it does."
Here's the thing, they're not always wrong. Half the time they're spot-on right. The problem is that nobody knows which is which. Real performance data for the actual use-case at hand typically doesn't exist so every design decision is a gut-feeling gamble where the senior engineer with the best poker-face wins by default. Trouble is, they're usually saying something they read on a blog in 2014. I know because when I became a senior developer I started doing exactly the same thing!
In my last post I showed that fixing random_page_cost = 1.1 makes the planner pick the right plan, and that the covering index closes the rest of the gap. I dutifully added the standard architectural hedge:
"the covering index physically holds copies of
id,total,status,shipped_at. At 2M rows, that's roughly 100MB of extra index. Every INSERT/UPDATE on those columns now updates this index too. Ifstatusorshipped_atare updated frequently (likely for an orders table), the index will churn."
I said "it'll cost you on writes" with no number attached. Then I realized, with ExoBench I don't every need to wave my hands again. I can literally measure the cost of every single SQL index I ever think of because the effort-level of it is one single prompt!
How I Measured This
If you read the last post you can skip this section. If not: I described the question to my AI assistant in plain English, the assistant called ExoBench (an MCP server) on my behalf, and ExoBench spun up an ephemeral PostgreSQL instance, generated 200K customers + 2M orders with realistic distributions, and ran EXPLAIN (ANALYZE, BUFFERS) against each operation. Real plans. Real timings. Real buffer counts. No code I had to write. No production database touched.
Every benchmark in this post uses random_page_cost = 1.1 (set via preBenchmarkSql), which is the SSD-correct value established in the previous post. These are the numbers you'd actually see on a properly-configured cloud Postgres. Running them with the broken 4.0 default would distort the read column (the planner would refuse to use the single-column index) and obscure the true comparison. We're past that.
ExoBench does not connect to your database. Every benchmark is a fresh Postgres instance with synthetic data. Your production data never leaves your systems.
If you want the architecture in full, here's how ExoBench works, and here's how it compares to chatbots and other tools.
What I Asked
The AI was honest with me up front. It said: the write penalty has three components, not one. So we measured all three across three index variants: no secondary index, single-column, and covering.
Component 1: INSERT cost. Every new row has to be added to every index. The covering index is wider (4 INCLUDE columns + 1 key vs. just 1 key), so each insert writes more bytes to the index B-tree. To measure: INSERT 100K new rows into orders with each index variant.
Component 2: UPDATE cost on indexed columns. This is the one that matters for an orders table. Every time status flips from pending to shipped, the covering index has to be touched, because status is one of its INCLUDE columns. Without an index covering those columns, the same UPDATE can use a Postgres optimization that the Postgres priesthood has decided to ordain with the sacred name Heap-Only Tuple, (abbreviated HOT). To measure: UPDATE 25K rows setting status = 'shipped' and shipped_at = NOW() with each index variant.
Component 3: Storage. Indexes take disk space. Covering indexes take more disk space because they store copies of the INCLUDE columns. To measure: pg_relation_size() on each index.
Three measurements. Nine benchmark runs (three variants × three operations) plus storage. Roughly two minutes of compute time. The AI handled all of it.
Sidebar: what is HOT and why should you care?
Normal Postgres UPDATEs are surprisingly expensive. Updating one row writes a new copy of the row, leaves a tombstone behind for the old copy, and writes a new entry in every index pointing at the new copy. Three indexes = three new index entries. Plus three more tombstones for the old index entries. Six writes you didn't ask for.
The heap-only-tuple optimization is the escape hatch. If (a) none of the columns you changed are part of any index and (b) there's room on the same disk page as the original row, Postgres skips all the index work. Zero new index entries. Zero tombstones. Just the new row version.
The Postgres priesthood calls this a Heap-Only Tuple, abbreviated HOT, so you can write it in conference slides and look serious. The rest of us would have called it "in-place update" and gone to lunch.
The catch: the heap-only-tuple optimization only works if none of the changed columns are indexed. UPDATE an indexed column, or an INCLUDE column in a covering index, and the optimization is off for that row. You pay the full index-maintenance cost.
I just told you a lie. Two, actually. Postgres doesn't actually write index "tombstones" the way I described, and the heap-only-tuple optimization is not exactly an "in-place update" either. Both metaphors are wrong in ways that don't matter for any decision you're making here. If you want the gory truth, it's at the end of the post in The Dirty Guts of Postgres Writes. For everyone else, the in-place-update-without-tombstones mental model is correct enough that you'll make the right decisions about indexes. Carry on.
The whole point of the experiment below is to measure exactly how much heap-only-tuples-disabled costs.
Result 1: INSERT Cost
Same schema as the previous post: 200K customers, 2M orders. The benchmark inserts 100,000 new rows into orders. Run three times: once with no secondary index, once with idx_orders_customer_id (customer_id), once with the covering index.
SET random_page_cost = 1.1;
EXPLAIN (ANALYZE, BUFFERS) INSERT INTO orders
SELECT
2000000 + i,
(random() * 199999)::int + 1,
random() * 500,
(ARRAY['pending','shipped','delivered','cancelled'])[(i % 4) + 1],
NOW() - (random() * 365 * interval '1 day')
FROM generate_series(1, 100000) i;
| Index variant | Execution time | Buffers (hit + read) | vs. no-index | vs. single-column |
|---|---|---|---|---|
| No secondary index | 545.15 ms | 203,325 hit + 0 read | baseline | — |
idx_orders_customer_id (single-column) | 799.28 ms | 501,092 hit + 2,268 read | +47% | baseline |
idx_orders_customer_cover (covering) | 1025.15 ms | 489,037 hit + 14,341 read | +88% | +28% |
The numbers that matter for the decision: covering vs single-column is +28% (226ms over 100K inserts, ~2.3µs per row). Covering vs no-index is +88% (480ms, ~4.8µs per row).
Look at the buffer read counts. No-index INSERT only touches the heap and the primary key, almost everything is in shared_buffers (203K hits, 0 cold reads). Single-column adds 2,268 cold reads. The covering index adds 14,341 cold reads, 6.3x more than single-column. That's the index B-tree pages being pulled in as inserts walk down to find their leaf nodes. The wider the leaves, the more pages, the more I/O.
Result 2: UPDATE Cost (The One That Actually Matters)
This is the test I expected to be brutal. UPDATEs that touch indexed columns disable the heap-only-tuple optimization. Without it, every updated row writes a new heap tuple and new entries in every index.
SET random_page_cost = 1.1;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders
SET status = 'shipped', shipped_at = NOW()
WHERE id BETWEEN 1 AND 100000 AND status = 'pending';
This hits ~25K rows (out of 100K candidates, since status is roughly evenly distributed across 4 values and we're only flipping pending ones).
| Index variant | Execution time | Pages dirtied | vs. no-index | vs. single-column |
|---|---|---|---|---|
| No secondary index | 417.27 ms | 686 | baseline | — |
idx_orders_customer_id (single-column) | 435.96 ms | 2,741 | +4% | baseline |
idx_orders_customer_cover (covering) | ~544 ms (range 497-590) | 12,258 | +30% | ~+25% |
The covering UPDATE measurement was noisy. Two runs gave 497ms and 590ms with identical plans and identical buffer counts (12,258 dirty pages, Index Scan on orders_pkey). I'm reporting the mean and acknowledging the spread.
Look at the dirty page counts. They tell the heap-only-tuple story precisely. No-index UPDATE dirties 686 pages: that's the heap-only-tuple optimization working perfectly. The new row version goes on the same heap page as the old one, no indexes are touched. Add the single-column index on customer_id: 2,741 dirty pages, 4x more. The optimization is still working because the customer_id value did not change, and customer_id is the only column the single-column index cares about. The extra pages come from heap maintenance overhead on a larger row footprint, not from the index itself. Add the covering index: 12,258 dirty pages, 18x more than no-index. The optimization is now broken because status and shipped_at are INCLUDE columns in the covering index, and they did change. Each of the 25K updated rows now writes a new heap tuple AND new entries into both the primary key index and the covering index.
The conventional wisdom that "any UPDATE to an indexed column disables the heap-only-tuple optimization and tanks your write performance" is directionally correct, and the dirty-page count is where you see how bad it gets.
Result 3: Storage
A query against pg_indexes and pg_relation_size():
| Index | Size on disk | Multiplier |
|---|---|---|
| (none) | 0 MB | — |
idx_orders_customer_id (customer_id) | 18 MB | 1.0x |
idx_orders_customer_cover (customer_id) INCLUDE (id, total, status, shipped_at) | 112 MB | 6.2x |
orders_pkey (for reference, always present) | 43 MB | 2.4x |
The covering index is bigger than the primary key. INCLUDE columns are stored verbatim in every leaf entry: customer_id (4 bytes) + id (4 bytes) + total (NUMERIC, ~14 bytes) + status (TEXT, ~10 bytes) + shipped_at (timestamp, 8 bytes) + tuple overhead ≈ 50 bytes per row × 2M rows = ~100 MB raw, plus B-tree overhead, lands at 112 MB. Matches the math.
The Full Picture, One Table
Everything I now know about the three index strategies, on this workload, at this scale, all under random_page_cost = 1.1.
| Operation | No index | Single-column | Covering |
|---|---|---|---|
| Read ("find orders for high-value NE customers") | 127.15 ms | 68.69 ms | 54.53 ms |
| INSERT 100K new rows | 545.15 ms | 799.28 ms | 1025.15 ms |
| UPDATE 25K rows setting status, shipped_at | 417.27 ms | 435.96 ms | ~544 ms |
| Storage (secondary indexes only) | 0 MB | 18 MB | 112 MB |
Read ratios:
- Single-column vs no-index: 1.85x faster (saves 58.5ms per query)
- Covering vs single-column: 1.26x faster (saves 14.2ms per query)
- Covering vs no-index: 2.33x faster (saves 72.6ms perquery)
Write penalties:
- INSERTs: single-column = +47% vs no-index, covering = +28% vs single-column (+88% vs no-index)
- UPDATEs: single-column = +4% vs no-index, covering = +25% vs single-column (+30% vs no-index)
This is what I have been wanting to have on hand for fifteen years. Not "it costs you on writes." A number. Three numbers, actually, with break-even math attached.
What Surprised Me
The single-column index is almost free on UPDATEs. Only +4% over no-index. The heap-only-tuple optimization stays alive because the customer_id key column is not being changed, only status and shipped_at are, and the single-column index does not cover those. This is the most underrated property of single-column indexes on stable foreign keys: they cost basically nothing on writes that do not touch the indexed column.
The INSERT penalty is much bigger than the UPDATE penalty. Counterintuitive at first. The math makes sense: INSERTs always pay full index maintenance (B-tree insertion + leaf split potential + WAL write). UPDATEs in this benchmark only paid the leaf-rewrite cost on the covering index, and almost nothing at all on the single-column index. INSERTs are where indexes actually hurt, not UPDATEs.
The read gap between single-column and covering shrunk a lot under the corrected cost model. With random_page_cost=4.0, the planner refused to use the single-column index, so the covering index was the only way to get a fast read (3.06x speedup in the previous post). With random_page_cost=1.1, the planner uses the single-column index, and the covering index only adds another 1.26x on top. The covering index is no longer essential for "fast reads," it's the last 26% of optimization on top of a properly-configured planner. This changes the break-even math significantly.
The Break-Even Math
Now I can do the calculation the architects never could. For this workload, with the corrected cost model:
Covering vs single-column index:
- Read savings: 14.2ms per query (68.69ms → 54.53ms)
- INSERT penalty: ~2.3 µs per row inserted
- UPDATE penalty: ~4.3 µs per row updated (touching
statusorshipped_at)
Assuming one analytical read per minute and an average write penalty of ~3.3µs/row:
14,200 µs saved/minute ≥ writes/minute × 3.3 µs
writes/minute ≤ ~4,300
Covering vs no secondary index:
- Read savings: 72.6ms per query (127.15ms → 54.53ms)
- INSERT penalty: ~4.8 µs per row inserted
- UPDATE penalty: ~5.1 µs per row updated
So at this scale, with this workload, with random_page_cost = 1.1:
- Covering vs single-column breaks even at ~4,300 writes/minute (per analytical read/minute). Above that, single-column wins.
- Single-column vs no-index breaks even at almost any write rate, because single-column has essentially zero write penalty on workloads that don't touch the key column.
The "can I afford a covering index?" decision is much more endurable than the architectural folklore suggests. The covering index pays for itself unless your write frequency exceeds a specific calculable threshold. and it happens to be quite high. Above that threshold, a plain single-column index plus random_page_cost = 1.1 is the right answer.
Why This Has Been Haunting Me for Half My Career
I want to step away from the benchmark for a minute and say something about software engineering culture.
The phrase "covering indexes have write overhead" is, technically, correct. So is "Hibernate is slow" and "UUIDs as primary keys are bad" and "joins don't scale" and a hundred other architectural folk-sayings. Each one of them contains a real signal underneath. The problem is that the signal-to-noise ratio in those folk-sayings is terrible, and nobody ever measures the actual number, because measuring the actual number has historically been a one-day project per claim.
I have been on architectural review boards where six engineers argued for forty-five minutes about whether to add an index with zero data, I was one of them. I have watched a staff engineer override a junior's correct proposal because "trust me, that pattern doesn't scale," when the pattern was in fact, 4x faster at the scale they needed. I have seen millions of dollars of engineering effort spent re-architecting systems based on architectural intuition that turned out to be measurably wrong the one time anyone actually checked.
The reason this happens is not that engineers are stupid. The reason this happens is that the activation energy to actually measure something has always been higher than the activation energy to assert something. So assertions win by default.
ExoBench changes that math. The activation energy to measure the write penalty of three index variants, in production-realistic conditions, at 2M rows, with full EXPLAIN ANALYZE output and break-even calculations, was: a handful of prompts, two minutes of compute time, and roughly five sentences of typing. The activation energy to make the assertion "covering indexes have write overhead" is roughly the same.
When the cost of measuring something falls below the cost of asserting it, the rational move is to measure. We are now in that regime. The architects on your team who say "it'll hurt your writes" with no number attached are doing the slower, lower-quality version of what is now trivially available.
This applies to far more than covering indexes. Any time someone says:
- "This query won't scale."
- "We need to denormalize this."
- "Don't use that index type, it's slow on reads."
- "Partial indexes don't help here."
- "Postgres planner won't pick the index at that selectivity."
- "Bitmap scans are faster than index scans for that workload."
- "JSONB is too slow for relational queries."
Any of these can be answered with one prompt. Not in a week. Not in a month. In one prompt. The era of architectural folklore as a decision-making input is, or should be, ending.
What I'd Actually Recommend, With Real Numbers
OK, back to covering indexes. Now that I have data, here's what I'd actually do:
- First, fix
random_page_cost = 1.1globally. This is the free win covered in the previous post. It's a prerequisite to everything else here. Without it, the planner ignores your single-column index entirely on this query shape, and you're forced into the covering index just to get reasonable read performance. - Default to a plain single-column index on the join key. It's almost free on writes (+4% UPDATEs, +47% INSERTs vs no-index, and the heap-only-tuple optimization stays alive on UPDATEs that don't touch the indexed column), and it gives you 1.85x faster reads vs no index. That's the cheapest available win.
- Add a covering index only when you actually need that last 26% of read performance AND your write rate is comfortably below ~4,300 writes/minute per analytical query/minute. For a once-per-minute dashboard against an orders table doing 1,000 writes/minute, covering is the right call. For a high-throughput OLTP system processing 10K status flips/minute against a dashboard that runs once an hour, single-column wins easily.
The decision is no longer "should I add a covering index?" It is "what is my read:write ratio, what is my analytical query frequency, and which side of the 4,300-writes-per-minute line am I on?" That is a measurable, decidable question. You no longer have to guess.
What This Doesn't Cover
My benchmark is one workload at one scale. 200K customers, 2M orders, 4-value status enum, ~10% high-value customers. Your data shape will differ. UPDATE penalty especially depends on how many rows match the filter (I tested 25K). At 250K rows touched, the penalty could compound. At 250 rows, it could vanish. Run your own numbers.
I only tested UPDATEs to INCLUDE columns. UPDATEs to the key column (customer_id) would be much more expensive, because they'd require B-tree traversal to re-find the index entry's correct position, AND they'd break the heap-only-tuple optimization on the single-column index too (because then the indexed column itself is changing). I didn't measure that because in real e-commerce, you essentially never change a customer_id on an order. If your workload changes the index key column frequently, this benchmark does not apply to you.
Heap-only-tuple behavior has a lot of nuances. Whether the optimization actually applies to a given UPDATE depends on fillfactor (Postgres's name for "how full do you let a page get before refusing to put more rows on it"), page fullness at the moment of the UPDATE, vacuum behavior, and tuple size. My benchmark was on a freshly-loaded table with default fillfactor (100, meaning "pack pages completely full"). On a table that has been running for a year with lots of dead tuples and a half-full page layout, the optimization may apply more or less often than my numbers suggest. Real production needs real production measurement.
Wall-clock variance on the covering UPDATE. Two runs gave 497ms and 590ms with identical plans and identical buffer counts. The variance is the ephemeral cluster, not the workload. I reported the mean and the range. Your own benchmark should run multiple iterations and bound the variance.
ExoBench limits. Max 5 scale points per benchmark request, 3M rows per scale point.
Try It On Your Architectural Folklore
Find the worst recurring architectural assertion in your team's design docs. The one that gets repeated in every review and never measured. Something like "JSONB is slow for relational queries" or "this index won't help, the data isn't selective enough" or "partial indexes don't pay for themselves in our workload."
Then run this against pg_stat_statements (Postgres's name for the catalog view that, against all reasonable expectations, tracks every query you've run and how slow it was; you may have to CREATE EXTENSION pg_stat_statements first if your DBA hasn't already) to find your candidate query:
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
Then point your AI at ExoBench. You already have Claude or Cursor or ChatGPT. Head to exobench.ai to connect it, then paste a prompt like:
Here's a query and schema [paste]. The team thinks adding [thing] will hurt write performance. Measure it. Run INSERTs, UPDATEs, and the read query with random_page_cost = 1.1. Tell me the actual penalty in percent and the break-even point.
That's it. A minute later you have a number.
Stop arguing about architectural folklore. Start measuring it. Find your next "I think it'll hurt writes" assertion and run it through ExoBench before your next design review.
Appendix: The Dirty Guts of Postgres Writes
You came down here voluntarily. I respect that. What follows is the actual mechanism behind the two metaphors I waved at above ("tombstones" and "in-place updates"). These are details that you, as a consumer of Postgres, basically never need. They are details that committers of Postgres care about. If you're trying to decide whether to add a covering index, you can close the tab now and you will be fine. If you want to know what's really happening under the floorboards, read on.
What I said about "tombstones"
The metaphor: every UPDATE writes new index entries for the new row version and tombstones for the old entries. Six writes for a three-index table.
The reality: Postgres does not write index tombstones at UPDATE time. Here is what actually happens.
Postgres indexes don't store row data. They store pointers called ctids, which are basically (page number, slot number on page). The index entry says "go look at page 47, slot 3." When you UPDATE a row, Postgres doesn't modify the existing row. It writes a brand new copy of the row at a different location, marks the old copy as xmax'd (a value used for MVCC visibility), and the new copy gets a new ctid like (page 51, slot 2). Now Postgres needs every index to be able to find the live version of the row, so it adds new index entries pointing at the new ctid. The old index entries pointing at (page 47, slot 3) are left alone. They still point at the old, now-dead row. Nothing actively tombstones them at UPDATE time.
So why did I say "tombstones"? Because functionally, those old entries are dead. They occupy space in the index. They point at nothing live. Index scans walk past them. VACUUM eventually cleans them up. They behave exactly like tombstones for the purpose of reasoning about cost and bloat. The only thing the metaphor gets wrong is the timing of when they get written off as dead, and the mechanism (passive abandonment vs active marking). Neither matters for any decision a consumer of Postgres has to make.
If you want to verify this, the source-of-truth document is README.HOT in the Postgres source tree, which spells out the t_ctid chain mechanics. The visible-from-outside consequence is "index bloat," which everyone has heard of and which is the practical reason this matters.
What I said about the heap-only-tuple optimization being an "in-place update"
The metaphor: the heap-only-tuple optimization is just an in-place update that skips the index work.
The reality: it does not literally update the row in place. The old row stays where it is. A new row version is written on the same heap page as the old one. The old version gets marked HEAP_HOT_UPDATED. Its t_ctid field is updated to point forward to the new version. The new version is marked HEAP_ONLY_TUPLE.
The indexes are not touched. They still point at the original (now HEAP_HOT_UPDATED) row. When an index scan follows the ctid into the heap, it lands on the old version, sees the HEAP_HOT_UPDATED flag, follows the t_ctid forward, and finds the live row. This is called walking the heap-only-tuple chain.
Chains can grow long if a row is updated many times. Postgres prunes them opportunistically during normal page reads (yes, a SELECT can do cleanup work) and during VACUUM.
So why did I say "in-place update"? Because functionally, that's what it feels like from the outside. The new version is on the same page. No indexes are written. No tombstones accumulate elsewhere. The cost is local. The behavior is what a database engineer means by "in-place" without getting into MVCC theology.
The key insight that makes the heap-only-tuple optimization a real optimization, not just a deferred cost: because no new index entries are created, there is nothing for VACUUM to clean up in the indexes later. Both the synchronous AND asynchronous costs are reduced. This is genuinely cheaper, not just deferred. The "in-place update" metaphor captures this. The literal mechanism (new tuple, chain of heap-only-tuples, prune-on-read) is the implementation detail under the metaphor.
Appendix: For Ye Shal Choose 1.1
Behold I have placed before you a random_page_cost of 1.1 and 4.0, and ye shal choose 1.1.
Every benchmark in the body of this post used random_page_cost = 1.1 (the SSD-correct value, as established in the previous post). But I also have the full set of measurements at the default random_page_cost = 4.0, from the two posts combined. Here is everything in one place. Reads, writes, both cost models, all three index strategies.
The Granddaddy Table
| Operation | random_page_cost | No 2ndary index | Single-column | Covering |
|---|---|---|---|---|
| Read | 4.0 | 124.35 ms | 125.98 ms | 41.16 ms |
| Read | 1.1 | 127.15 ms | 68.69 ms | 54.53 ms |
| INSERT 100K rows | 4.0 | 501.66 ms | 813.98 ms | 984.67 ms |
| INSERT 100K rows | 1.1 | 545.15 ms | 799.28 ms | 1025.15 ms |
| UPDATE 25K rows | 4.0 | 384.01 ms | 437.67 ms | 497.53 ms |
| UPDATE 25K rows | 1.1 | 417.27 ms | 435.96 ms | ~544 ms |
| Storage | (either) | 0 MB | 18 MB | 112 MB |
A few things you'll notice immediately:
-
Writes are roughly the same at both random_page_cost values. This makes sense. The cost model affects plan selection, not the cost of writing a tuple to the heap or an entry to an index. INSERTs and UPDATEs do the same physical work regardless of
random_page_cost. The small differences in the table are run-to-run variance on the ephemeral cluster. -
Reads are wildly different at the two random_page_cost values, but only for the single-column index. At random_page_cost=4.0 the single-column read is 125.98ms (the planner ignores the index entirely and does a Parallel Seq Scan on orders). At random_page_cost=1.1 the single-column read is 68.69ms (the planner uses the index for a Nested Loop). The cost model determines whether your existing index is even used.
-
The covering index "wins" at both random_page_cost values, but by very different margins. At random_page_cost=4.0 the covering index is 3.06x faster than single-column. At random_page_cost=1.1 it's only 1.26x faster. The covering index's apparent advantage at random_page_cost=4.0 is mostly a measurement of how broken the planner is, not how clever the index is.
The Break-Even Point Is Wildly Different
This is the part most people miss. Adding a covering index over a single-column index has the same write cost at both random_page_cost values. But the read savings are completely different. So the break-even math is completely different.
At random_page_cost=4.0 (broken cost model):
- Read savings (covering vs single-column): 125.98 - 41.16 = 84.82 ms per query
- Write penalty per row: ~2.05 µs (mix of INSERT and UPDATE)
- Break-even: 84,820 µs ÷ 2.05 µs ≈ ~41,000 writes/minute per analytical read/minute
At random_page_cost=1.1 (corrected cost model):
- Read savings (covering vs single-column): 68.69 - 54.53 = 14.16 ms per query
- Write penalty per row: ~3.3 µs (mix of INSERT and UPDATE)
- Break-even: 14,200 µs ÷ 3.3 µs ≈ ~4,300 writes/minute per analytical read/minute
The break-even moved by roughly 10x. A team running on the default cost model would conclude that the covering index pays for itself up to ~41,000 writes per minute, which is most workloads. A team running on the corrected cost model would conclude that the covering index only pays for itself up to ~4,300 writes per minute, which is a much narrower band.
Same hardware. Same data. Same query. Same indexes. Same write rate. Different decision, depending entirely on which random_page_cost value the cluster is using.
Why This Happens
The covering index's read advantage at random_page_cost=4.0 is almost entirely an artifact of the planner refusing to use the single-column index. Once you fix the cost model, the single-column index does most of the work, and the covering index is just adding the last 26%.
Put another way: at random_page_cost=4.0, you "need" the covering index to get fast reads. At random_page_cost=1.1, the covering index is a premium upgrade on top of an already-fast read path. The same physical schema choice means very different things depending on the cost model.
So What
Two practical conclusions:
-
If you don't fix
random_page_cost, you'll add covering indexes you don't need. Teams running on the default cost model will see that "the covering index made the query 3x faster" and conclude that covering indexes are amazing. They are not. The planner is just bad. Fix the planner first; reconsider the index second. -
The break-even point you compute depends on which world you're benchmarking in. A break-even based on random_page_cost=4.0 measurements will systematically over-recommend covering indexes. A break-even based on random_page_cost=1.1 measurements is closer to the real trade-off you'll experience in production with a properly-configured cluster.
Ye shal choose 1.1.