TigerData logo
TigerData logo
  • Product

    Product

    Tiger Cloud

    Robust elastic cloud platform for startups and enterprises

    TimescaleDB Enterprise

    Self-managed TimescaleDB for on-prem, edge and private cloud

    Open source

    TimescaleDB

    Time-series, real-time analytics and events on Postgres

    Search

    Vector and keyword search on Postgres

  • Industry

    Data Centers

    Energy & Utilities

    Oilfield Services

    Smart Manufacturing

    Crypto

  • Docs
  • Pricing
  • Developer Hub

    Changelog

    Benchmarks

    Blog

    Community

    Customer Stories

    Events

    Support

    Integrations

    Launch Hub

  • Company

    About

    TigerData logo

    Timescale

    Partners

    Security

    Careers

View on GitHub (opens in a new tab)

Contact usStart a free trial
Tiger Data

Products

  • TimescaleDB
  • Tiger Cloud
  • TimescaleDB Enterprise
  • Postgres Search Stack

Industry

  • Data Centers
  • Energy & Utilities
  • Oilfield Services
  • Smart Manufacturing
  • Crypto

Support

  • Cloud Status
  • Support
  • Security
  • Terms of Service
  • Code Of Conduct

Learn

  • Documentation
  • Blog
  • Tutorials
  • Changelog
  • Success Stories

Company

  • About
  • Contact Us
  • Careers
  • Newsroom
  • Brand
  • Events

Products

  • TimescaleDB
  • Tiger Cloud
  • TimescaleDB Enterprise
  • Postgres Search Stack

Industry

  • Data Centers
  • Energy & Utilities
  • Oilfield Services
  • Smart Manufacturing
  • Crypto

Support

  • Cloud Status
  • Support
  • Security
  • Terms of Service
  • Code Of Conduct

Learn

  • Documentation
  • Blog
  • Tutorials
  • Changelog
  • Success Stories

Company

  • About
  • Contact Us
  • Careers
  • Newsroom
  • Brand
  • Events
Privacy preferencesLegalPrivacySitemap
Strategic Partner with Inductive Automation — Ignition

2026 (c) Timescale, Inc., d/b/a Tiger Data.
All rights reserved.

Tiger Data

2026 (c) Timescale, Inc., d/b/a Tiger Data.
All rights reserved.

Privacy preferencesLegalPrivacySitemap

Knowing When Your Composite Index Earns Its Write Cost

J

By John Shi

September 18th, 2026

7 min

Share

J

By John Shi

September 18th, 2026

7 min

Share

Copy as HTML

Open in ChatGPT

Open in Claude

Open in v0

PostgreSQL

PostgreSQL Performance

Table of contents

  1. 01 Why the write cost compounds silently
  2. 02 Step 1: Know which queries the index can serve
  3. 03 Step 2: Measure what the index saves
  4. 04 Step 3: Measure what every insert now costs
  5. 05 Step 4: Benchmark Your ingest ceiling
  6. 06 The decision rule
  7. 07 Now run the same test on the indexes you already have
  8. 08 Validate that it worked
  9. 09 Next step
Get started for free
Knowing When Your Composite Index Earns Its Write Cost

Every insert pays for an index, but only the queries that use the index benefit.

That gap is what makes an index a decision instead of a default. The index in this guide changed a dashboard query from a full table scan to a seek, and charged every insert to do it. Which side wins depends on how often that query runs and how much ingest headroom you have.

By the end of this guide you'll know which query shapes a composite index accelerates, how to measure its write penalty, and how to decide using your ingest ceiling instead of a hunch. You'll leave with two queries you can run against production today.

Why the write cost compounds silently

Indexes get added during incidents and nobody comes back to audit them. Each one turns a logical insert into an extra physical write, with its own buffer to dirty and its own WAL record. Five indexes mean six writes per row. That's Phase 1 of the Optimization Treadmill, the cycle where each round of tuning buys a few months before the next ceiling arrives. Indexing buys three to six months, and then write throughput is the ceiling.

The write tax is invisible because nothing reports it. EXPLAIN shows what an index saves, down to the buffer, but it doesn’t show what it costs per row. The benefit comes as a number, the cost comes as a hunch, and the hunch loses every time.

Fortunately, both costs and benefits are measurable. The rest of this guide measures them in a specific order to help you decide whether a composite index is right for you.

Step 1: Know which queries the index can serve

Before pricing an index, check that it does anything for you. A composite index answers questions that start with its leading column. If yours don't, it's pure write cost, and nothing in the next three sections will save it.

That comes from how a B-tree stores keys. The sort is lexicographic: (tag_id, ts DESC) orders by tag_id, then by ts within each tag. No ts ordering spans tags.

Every example below runs against readings, which holds 40 million rows, 90 days of history, 10,000 tags.

CREATE INDEX readings_tag_ts_idx ON readings (tag_id, ts DESC);

That index serves queries like WHERE tag_id = 4417 AND ts >= now() - interval '30 days', one tag across a third of that history, because both columns are used left to right. It serves WHERE tag_id = 4417 ORDER BY ts DESC LIMIT 500, the newest 500 readings for that tag, with no sort node. (Postgres reads B-trees in both directions, so plain (tag_id, ts) would serve that one too. The explicit DESC earns its place only on mixed-direction sorts.)

This index cannot seek on ts alone, and that failure is worse than being ignored. Ask for one hour across all tags, 18,500  rows out of the 40 million, and Postgres still picks it, reading 161,347 buffers to find them against roughly 254,000 to scan the entire table. The index gets used and barely helps. Widen the window to a day and the planner abandons it.

Run that test before you run any benchmarks. If your queries lead with the indexed column, start pricing. If not, you have your answer already.

Step 2: Measure what the index saves

Pricing starts on the read side, with the query that motivated the index. Run that query under EXPLAIN (ANALYZE, BUFFERS) and read the buffer counts rather than the timings. Buffer counts hold steady across runs. Timings swing with whatever happens to be cached.

Without the index, the dashboard query from Step 1 has to scan the whole table:

Limit (actual time=1569.568..1569.651 rows=500 loops=1) Buffers: shared hit=130716 read=124065 -> Sort (actual time=1504.763..1504.807 rows=500 loops=1) Sort Key: ts DESC -> Seq Scan on readings (actual time=1019.622..1504.049 rows=1333 loops=1) Filter: ((tag_id = 4417) AND (ts >= (now() - '30 days'::interval))) Rows Removed by Filter: 39998667 Execution Time: 1583.016 ms

That is the same full-table scan Step 1 priced at roughly 254,000 buffers, plus a sort to pull the newest 500 rows out of the 1,333 that matched.

With the index:

Limit (actual time=0.061..3.676 rows=500 loops=1) Buffers: shared hit=500 read=6 -> Index Scan using readings_tag_ts_idx on readings (actual time=0.060..3.594 rows=500 loops=1) Index Cond: ((tag_id = 4417) AND (ts >= (now() - '30 days'::interval))) Execution Time: 3.763 ms

254,781 buffers down to 506, and the sort node disappears, because the index already holds each tag's rows in ts DESC order.

That saving is the entire benefit side of the ledger. Everything from here measures what it costs.

Step 3: Measure what every insert now costs

The cost side is harder to see, because no query plan reports it. It depends almost entirely on which column leads the index, and that is where most teams guess wrong.

(ts) [leaf][leaf][leaf][leaf*] 1 insertion point, packs to 90% (tag_id, ts) [leaf*][leaf][leaf*][leaf*] 10,000 of them, all half empty

With a ts-led index, every new row sorts after every existing one, so all inserts land on the rightmost leaf page. Postgres splits that page to the index fillfactor, 90 percent by default, rather than down the middle. With (tag_id, ts DESC) and 10,000 tags there are 10,000 insertion points instead of one. The page an insert needs is rarely the page the previous insert touched, and each split leaves both halves half full.

That difference is measurable. Load 5 million rows into two empty tables, one indexed on ts and one on (tag_id, ts DESC), then read the result with pgstatindex from the pgstattuple extension:

IndexBuilt bySizeLeaf density
(ts)5M inserts107 MB90.1%
(tag_id, ts DESC)5M inserts236 MB57.6%
(tag_id, ts DESC)one CREATE INDEX150 MB90.0%

The third row is the control: the same index over the same rows, built in a single pass instead of row by row. Built in one pass it holds 90 percent density. Built by inserts it settles at 57.6 percent and takes 57 percent more disk. That gap is page-split waste, and only REINDEX reclaims it.

WAL carries the same penalty. Each index insertion writes its own record, and the first change to a page after a checkpoint writes a full-page image of that page into WAL. Appending keeps reusing pages already dirtied since the last checkpoint, so it skips most of those images. Scattering does not. Across the same 5 million inserts, the scattered index wrote 834MB of WAL against 699MB, and took 22.1 seconds against 15.7.

So the question is not whether an index slows writes, but instead whether it scatters them.

Step 4: Benchmark Your ingest ceiling

Density and WAL explain the mechanism. What you decide with is throughput, and that takes a benchmark.

Run the benchmark against a copy of production, never an empty table. An empty table hides the cost, because the whole index stays in cache. This is easy to get wrong: the first run of the numbers below used a 10-million-row table and reported a 4.7 percent penalty, because the index still fit inside shared_buffers.

\set tag random(1, 10000) INSERT INTO readings (ts, tag_id, value) VALUES (now(), :tag, random() * 100);
pgbench -n -f insert.sql -c 8 -j 2 -T 60 yourdb

Run that script twice against the 40-million-row table, once with the index in place and once without:

Those two figures are your ingest ceiling with and without the index. Expect a wider gap once the index outgrows memory. At 1.2GB on a 7GB box this one still lived in page cache, so the scattered reads never reached disk. Size the test to your own memory ratio.

ConfigurationInserts/secChange
No index12,623baseline
(tag_id, ts DESC)11,639−7.8%

The decision rule

Both sides are now on the table, in different units. A read saving measured in buffers cannot be weighed against a write cost measured in percent. Convert the cost into the one number that actually constrains you: your ingest ceiling.

Step 4 put that ceiling at 11,639 inserts per second with the index and 12,623 without. Compare it against your peak ingest rate:

  • Peak ingest well below 11,639: the index is free. Ship it.
  • Peak ingest near 11,639: it costs headroom you use. The query has to be worth capacity, not milliseconds.
  • Peak ingest above 11,639: don't ship it. Widen an index you already have, or move the query to a continuous aggregate.

Focus on peak, not average. A pipeline averaging 4,000 rows a second and spiking to 12,000 at the top of each hour breaks at the spike, and the index is what put it there.

Now run the same test on the indexes you already have

That rule works on an index you are considering. It works just as well on the ones already installed, and those have usually never been checked. This query lists them with their scan count and their footprint:

SELECT s.indexrelname AS index_name, s.idx_scan, pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size, i.indisunique AS is_unique FROM pg_stat_user_indexes s JOIN pg_index i ON i.indexrelid = s.indexrelid WHERE s.relname = 'readings' ORDER BY pg_relation_size(s.indexrelid) DESC;

Look for a large index_size beside an idx_scan at or near zero. Those indexes charge every insert and return nothing.

Three checks before dropping one:

  • Check pg_stat_database.stats_reset. If counters reset last week, a zero scan count means nothing. You need a full business cycle, monthly reports included.
  • idx_scan is per node. An index unused on the primary may be carrying every analytics query on a replica.
  • Never drop a unique index or one backing a constraint. It's enforcing correctness, not serving a plan.

The audit also turns up redundancy. A standalone (tag_id) index sitting next to (tag_id, ts DESC) is close to dead weight, because any query the narrow index serves, the composite index serves from its leading column. Two write penalties, one capability.

Validate that it worked

After dropping or adding an index, reset the counters and let one full cycle run. pg_stat_reset_single_table_counters takes one relation at a time, and an index counts as its own relation, so pass each index OID rather than the table's:

SELECT pg_stat_reset_single_table_counters(indexrelid) FROM pg_index WHERE indrelid = 'readings'::regclass;

A week later, run the audit again. Every index still on the table should map to a query you can name, and your ingest ceiling should have moved by roughly what Step 4 predicted. If the ceiling did not move, the index was never the bottleneck, and WAL flushing or autovacuum is the next place to look.

Next step

Run the audit query against your highest-volume table this week. Indexes with real size and near-zero scans are the easiest throughput you'll ever recover. Use DROP INDEX CONCURRENTLY so the drop does not block writes.

Once the dead indexes are gone, the survivors are real trade-offs, and adding more B-trees stops being the answer. That is where chunk-level indexing changes the arithmetic. A Tiger Data hypertable splits the table into time-based chunks and indexes each chunk separately, so an insert touches a small, recent index instead of one that grows without bound. Test that against your own workload with a free Tiger Data trial today.


About the author

J

By John Shi

// Related posts

Building an Ingest Safety Monitor for Your IIoT Pipeline in SQL
Building an Ingest Safety Monitor for Your IIoT Pipeline in SQL

IIoT

PostgreSQL

Building an Ingest Safety Monitor for Your IIoT Pipeline in SQL

Ingest fails suddenly, not gradually. Sample WAL volume, replica lag, and dead tuples every minute, and get one headroom percentage you can alert on.

By John Shi

October 16th, 2026

Measuring How Fast Your IIoT Queries Are Degrading
Measuring How Fast Your IIoT Queries Are Degrading

IIoT

PostgreSQL

Measuring How Fast Your IIoT Queries Are Degrading

Stop waiting for the dashboard to get slow. Record latency against row count, fit the curve, and get a date when your query breaches its target.

By John Shi

October 9th, 2026

Sizing Your IIoT Table Before You Deploy
Sizing Your IIoT Table Before You Deploy

IIoT

PostgreSQL

Sizing Your IIoT Table Before You Deploy

A 20-byte sensor reading costs 95.6 on disk. Measure bytes per row, project 631 billion rows, and test your ingest ceiling before the schema review.

By John Shi

September 25th, 2026

Stay updated with new
posts and releases.

Receive the latest technical articles and release notes in your inbox.