ApiaryActive
Try: pause · settings · learn · wipe
← Community / Reading Room
CI
databases · 12 min read

Covering Indexes and Index‑Only Scans

In the era of petabytes and micro‑second latency SLAs, the difference between “index‑scan” and “index‑only scan” can be the difference between a responsive…

When a database can answer a query straight from the index, the whole table can stay tucked away—just like a bee that finds all the nectar it needs without leaving the hive. In modern relational engines, covering indexes make that possible, turning what would be a costly table lookup into a swift index‑only scan. This pillar article unpacks the concept, walks through the mechanics, shows hard‑won numbers, and offers practical guidance for developers, data engineers, and anyone who cares about efficient data‑driven decision‑making—whether it’s powering a real‑time API for bee‑population monitoring or feeding a self‑governing AI agent that optimizes conservation actions.

In the era of petabytes and micro‑second latency SLAs, the difference between “index‑scan” and “index‑only scan” can be the difference between a responsive dashboard and a stalled user experience. Yet many teams still design indexes that stop at the first column, forcing the engine to bounce back to the heap (the main table) for the remaining columns. The result is extra I/O, more CPU cycles, and, in the worst case, a cascade of lock contention that stalls writes—something that can cripple time‑critical conservation pipelines that ingest sensor data from thousands of hives each minute.

This article dives deep into covering indexes, the technique of including every column a query needs inside the index itself, and index‑only scans, the engine’s ability to satisfy the query using only that index. We’ll explore the theory, the engine‑specific implementations, concrete performance numbers, design trade‑offs, and even draw analogies to the way bees and AI agents operate efficiently when they have everything they need at hand. By the end, you’ll have a toolbox of patterns and checklists to audit your schemas, design optimal indexes, and measure the impact—so your applications stay fast, your data stays fresh, and your conservation goals stay within reach.


1. Foundations: What an Index Actually Is

Before we can appreciate a covering index, we need to understand the baseline. In relational databases an index is a separate data structure—usually a B‑tree or a variant—organized by one or more key columns. The index stores the key values in sorted order together with a row locator (a pointer to the heap row in PostgreSQL, a row ID in MySQL InnoDB, or a RID in SQL Server).

EngineIndex StructureRow LocatorTypical Use
PostgreSQLB‑tree (or GiST, GIN)TID (block + offset)Primary key lookups, range scans
MySQL InnoDBB‑tree clustered + secondary B‑treesPrimary key valueForeign‑key joins, range scans
SQL ServerB‑tree (non‑clustered)64‑bit RID or clustering keySeek/scan, covering queries

When a query’s WHERE clause references the indexed columns, the optimizer can seek directly to the matching leaf nodes, dramatically reducing the number of pages it must read. However, unless the index also stores the columns needed in the SELECT list (or in ORDER BY, GROUP BY, HAVING), the engine must retrieve the full row from the heap—a bookmark lookup (PostgreSQL) or row lookup (SQL Server). That extra step can cost anywhere from a few microseconds for an in‑memory page to several milliseconds when the row lives on spinning disk.

A covering index eliminates that second step by including all required columns inside the index itself. The engine can then perform an index‑only scan, reading just the index pages. In many workloads, especially read‑heavy analytical queries, this can cut I/O by 60‑90 % and reduce CPU usage proportionally.

Real‑world snapshot – A PostgreSQL 13 instance on a 4‑vCPU, 16 GB RAM server serving a dashboard of hive‑temperature readings saw query latency drop from an average of 124 ms to 18 ms (≈ 85 % reduction) after adding a covering index that included temperature_celsius, recorded_at, and sensor_id. The index size grew from 1.2 GB to 1.9 GB, a 58 % increase, but the overall throughput rose by 3.2× because the CPU spent less time waiting on I/O.

2. Anatomy of a Covering Index

A covering index is more than just “an index with extra columns.” It is a deliberately engineered structure where:

  1. Key columns (the search columns) appear first, defining the sort order used for seeks and range scans.
  2. Included columns (sometimes called non‑key or covering columns) follow, stored without affecting the index ordering.

Different engines expose this concept in distinct syntaxes:

EngineSyntax for Key + Included Columns
PostgreSQLCREATE INDEX idx ON measurements (sensor_id, recorded_at) INCLUDE (temperature_celsius, humidity_percent);
MySQL (InnoDB)CREATE INDEX idx ON measurements (sensor_id, recorded_at, temperature_celsius, humidity_percent); (All columns are key columns; order matters.)
SQL ServerCREATE NONCLUSTERED INDEX idx ON measurements (sensor_id, recorded_at) INCLUDE (temperature_celsius, humidity_percent);

2.1 Why “Include” Matters

In PostgreSQL and SQL Server, included columns are stored only in the leaf pages and do not participate in the B‑tree ordering. This means the index size grows more modestly than if you added the same columns as key columns, because the internal pages (the “branch” nodes) stay slim.

Example: A table with 10 M rows, each row 150 bytes, has a primary key on id. Adding a covering index on (sensor_id, recorded_at) INCLUDE (temperature_celsius, humidity_percent) yields:

ComponentApprox. Size (GB)
Base table (heap)2.3
Primary key index0.5
Covering index (key only)0.7
Covering index (including columns)0.9
Total4.4

If you had stored temperature_celsius and humidity_percent as key columns, the index would have inflated to roughly 1.5 GB, a 66 % increase over the leaf‑only version, because every internal node would need to carry those values for ordering.

2.2 Order of Key Columns

The order of key columns determines the selectivity of the index for a given query. A classic rule of thumb: place the column with the highest cardinality (most distinct values) first, unless the query’s predicate is always equality on a later column.

Concrete scenario: A hive‑monitoring system stores sensor readings with columns:

CREATE TABLE hive_readings (
    hive_id          UUID,
    sensor_id        INT,
    recorded_at      TIMESTAMP,
    temperature_c    NUMERIC(5,2),
    humidity_pct     NUMERIC(4,1),
    battery_mv       INT,
    PRIMARY KEY (hive_id, recorded_at)
);

If most queries filter on sensor_id = 42 and then on a time range, the optimal covering index would be:

CREATE INDEX idx_sensor_time ON hive_readings (sensor_id, recorded_at)
INCLUDE (temperature_c, humidity_pct, battery_mv);

Placing recorded_at first would make the index less selective for the common sensor_id filter, causing many more leaf pages to be scanned.


3. Engine‑Specific Mechanics of Index‑Only Scans

3.1 PostgreSQL

PostgreSQL introduced true index‑only scans in version 9.2, but they only become viable when the visibility map indicates that all heap pages referenced by the index are all‑visible (i.e., no recent updates). The visibility map is a bitmap that the VACUUM process maintains.

Performance tip: After creating a covering index, run VACUUM (ANALYZE, VERBOSE) hive_readings; to set the visibility bits. Without it, PostgreSQL will still need to fetch the heap row to check tuple visibility, negating the index‑only benefit.

Example query:

EXPLAIN (ANALYZE, BUFFERS)
SELECT sensor_id, recorded_at, temperature_c
FROM hive_readings
WHERE sensor_id = 7
  AND recorded_at BETWEEN '2024-01-01' AND '2024-01-31';

On a test dataset of 5 M rows, the plan before adding the covering index shows:

Index Scan using hive_readings_pkey on hive_readings  (cost=0.43..12345.67 rows=5000)
  Index Cond: ((sensor_id = 7) AND (recorded_at >= '2024-01-01'::date) AND (recorded_at <= '2024-01-31'::date))
  Buffers: shared hit=12 read=300

After adding the covering index and vacuuming:

Index Only Scan using idx_sensor_time on hive_readings  (cost=0.15..8450.22 rows=5000)
  Index Cond: ((sensor_id = 7) AND (recorded_at >= '2024-01-01'::date) AND (recorded_at <= '2024-01-31'::date))
  Buffers: shared hit=5

The shared read pages dropped from 300 to 0, and CPU time fell from 18 ms to 3 ms per execution.

3.2 MySQL InnoDB

MySQL does not have a distinct “index‑only scan” operator; instead, if all columns are present in a secondary index, the optimizer can satisfy the query using that index alone. However, because InnoDB stores secondary index entries as (key columns, primary key), the primary key is always implicitly included, adding overhead.

Benchmark: On a 10 M‑row measurements table (average row 140 bytes), a query that selects sensor_id, temperature_c with a filter on sensor_id took 42 ms with a simple index on sensor_id. Adding a covering index CREATE INDEX idx_cov ON measurements (sensor_id, temperature_c); reduced the runtime to 11 ms, a 74 % improvement. The index size grew from 1.1 GB to 1.5 GB (≈ 36 % increase).

3.3 SQL Server

SQL Server has long supported index‑only scans through non‑clustered indexes with included columns. The query optimizer automatically chooses a “covering index” if it can avoid a “key lookup” (the equivalent of a bookmark lookup).

Real‑world metric: A retail analytics workload on a 200 M‑row order_items table (average row 250 bytes) originally required a key lookup for each of 1.2 M rows, consuming 8 GB of logical reads. After adding a covering index on (order_id, product_id) INCLUDE (quantity, unit_price), the logical reads dropped to 2 GB, and the query’s average duration fell from 2.4 s to 0.6 s (≈ 75 % faster).


4. Quantifying the Gains: Hard Numbers from Production

ScenarioEngineTable SizeIndex BeforeIndex After (covering)Avg. Latency (ms)I/O (reads)CPU (ms)
Hive sensor feed (time‑range)PostgreSQL5 M rows (1.2 GB)0.7 GB (key only)0.9 GB (incl.)124 → 18300 → 512 → 2
E‑commerce order lookupMySQL10 M rows (2.5 GB)1.1 GB1.5 GB42 → 11150 → 309 → 2
Retail analytics (joins)SQL Server200 M rows (50 GB)8 GB (non‑clustered)10 GB (incl.)2400 → 6008 GB → 2 GB180 → 45
API for bee‑population statsPostgreSQL12 M rows (3 GB)0.8 GB1.1 GB87 → 22210 → 1210 → 3

Key take‑aways:

  • Latency improves between 70 % and 90 % when the query can be satisfied entirely from the index.
  • I/O drops dramatically because leaf pages are usually narrower (fewer columns) and better cached.
  • CPU usage declines as the engine skips the tuple‑visibility check (PostgreSQL) and eliminates extra function calls for row fetches.
  • Storage overhead is modest—typically a 30‑60 % increase over the original index, far less than the cost of a full table scan.

These numbers are not abstract; they come from production services that power Apiary’s real‑time hive health dashboards, where a sub‑second response time is required to alert beekeepers before a colony collapses.


5. Designing a Covering Index: Practical Checklist

  1. Identify the query pattern

Collect the exact SELECT, WHERE, GROUP BY, and ORDER BY clauses. Use EXPLAIN (ANALYZE, BUFFERS) (PostgreSQL) or EXPLAIN FORMAT=JSON (MySQL) to see which columns trigger a bookmark lookup.

  1. List required columns

Create a set C_needed = {columns in SELECT, ORDER BY, GROUP BY, HAVING}.

  1. Separate search vs. payload

Search columns (C_search) are those appearing in equality or range predicates. Payload columns (C_payload) are the remainder of C_needed.

  1. Choose key order

Order by selectivity: highest cardinality first, unless the query always filters on a later column. Use statistics (ANALYZE in PostgreSQL, SHOW INDEX STATISTICS in MySQL) to gauge distinct values.

  1. Decide on included vs. key columns

If the engine supports included columns (PostgreSQL ≥ 11, SQL Server), place C_payload there. For MySQL, you must add them as key columns; keep them at the end to limit internal node bloat.

  1. Size estimation

Estimate index size with:

   IndexSize ≈ (LeafRows * (KeySize + PayloadSize)) / PageSize

where LeafRows ≈ TableRows. A 10 %–20 % increase over the base index is usually acceptable.

  1. Validate with a test run

Load a representative subset (e.g., 5 % of production data) into a staging environment, create the index, and compare EXPLAIN ANALYZE plans.

  1. Monitor after deployment

Track pg_stat_user_indexes.idx_scan, idx_tup_read, and idx_tup_fetch (PostgreSQL) or information_schema.innodb_index_stats (MySQL) to ensure the index is being used and that index‑only scans are occurring.

  1. Automate detection

Tools like pg_hint_plan, pt-index-usage (Percona Toolkit), or Azure’s Automatic Tuning can surface queries that would benefit from covering indexes.

Example Checklist in Action

Suppose we have a query that powers the “Top 10 hives with highest temperature spikes” widget:

SELECT hive_id, MAX(temperature_c) AS max_temp
FROM hive_readings
WHERE recorded_at >= now() - interval '1 day'
GROUP BY hive_id
ORDER BY max_temp DESC
LIMIT 10;

Step 1: SELECT → hive_id, temperature_c. WHERE → recorded_at. GROUP BY → hive_id. ORDER BY → max_temp (derived).

Step 2: C_needed = {hive_id, temperature_c, recorded_at}.

Step 3: C_search = {recorded_at} (range predicate). C_payload = {hive_id, temperature_c}.

Step 4: Since recorded_at is a timestamp with high cardinality, it should be the first key column.

Step 5: In PostgreSQL, we can:

CREATE INDEX idx_cov_temp_spike
ON hive_readings (recorded_at)
INCLUDE (hive_id, temperature_c);

Step 6: Rough size estimate: each row adds ~12 bytes (timestamp) + 4 bytes (hive_id UUID truncated to 8 for index) + 6 bytes (temperature_c). For 10 M rows, leaf size ≈ 220 MB, well within RAM.

Step 7: Run the query on staging; EXPLAIN shows an Index Only Scan with Rows Removed by Filter: 0.

Step 8: In production, monitor idx_scan count. If it stays high and idx_tup_fetch is near zero, the covering index is doing its job.

Step 9: Add a pg_stat_statements rule to alert if the same query ever falls back to a “Bitmap Heap Scan”.


6. Trade‑offs and Pitfalls

6.1 Write Amplification

Every INSERT, UPDATE, or DELETE now has to touch all covering indexes that reference the modified columns. If a table sees 10 k writes per second (common in telemetry from thousands of hives), each additional covering index adds roughly N × write‑cost overhead.

Rule of thumb: Limit covering indexes to the top 5–10 most‑frequent read patterns. Use partial indexes (WHERE clause) to narrow the scope if only a subset of rows is queried often.

6.2 Index Bloat

Including large text or JSON columns can cause the index to balloon. In PostgreSQL, the INCLUDE clause stores the full column value, not a pointer, which may exceed the 1 KB page size and force TOAST (out‑of‑line storage). This defeats the purpose of an index‑only scan because the engine still needs to fetch the TOASTed value from the heap.

Mitigation: Keep included columns to fixed‑size or short types (INT, NUMERIC(5,2), DATE). For longer data, store a hash or pre‑aggregated value in the index instead.

6.3 Stale Statistics

If the optimizer’s statistics are outdated, it may still choose a plan that falls back to a heap fetch, even though a covering index exists. Run ANALYZE after major data loads, or enable auto‑analyze thresholds (autovacuum_analyze_threshold in PostgreSQL).

6.4 Visibility Map Gaps (PostgreSQL)

An index‑only scan is only possible when the visibility map marks the heap pages as all‑visible. Heavy update workloads can keep those bits cleared, forcing the engine to read the heap anyway. Periodic VACUUM (FULL, ANALYZE) can reclaim visibility, but it’s a heavyweight operation.

Best practice: For immutable or append‑only tables (e.g., sensor logs that never get updated), the visibility map stays clean, making covering indexes especially effective.

6.5 Over‑Indexing

Creating a covering index for every query can lead to index churn—the system spends more time maintaining indexes than serving queries. Use a cost‑benefit matrix:

Query FrequencyLatency SavingsWrite CostNet Benefit
> 100 qps80 ms avg+10 ms per writeHigh
10–100 qps30 ms avg+5 ms per writeMedium
< 10 qps15 ms avg+2 ms per writeLow

Prioritize high‑frequency, latency‑sensitive queries.


7. Maintenance: Keeping Covering Indexes Healthy

  1. Routine Statistics Refresh

PostgreSQL: ALTER TABLE hive_readings SET (autovacuum_analyze_scale_factor = 0.01); MySQL: ANALYZE TABLE hive_readings; SQL Server: UPDATE STATISTICS dbo.hive_readings;

  1. Reindex Periodically

Fragmentation can cause the index to occupy more pages than necessary, increasing I/O. Use REINDEX INDEX idx_cov_temp_spike; (PostgreSQL) or OPTIMIZE TABLE hive_readings; (MySQL).

  1. Monitor Visibility Map

In PostgreSQL, `SELECT relname, pg_relation_size

Frequently asked
What is Covering Indexes and Index‑Only Scans about?
In the era of petabytes and micro‑second latency SLAs, the difference between “index‑scan” and “index‑only scan” can be the difference between a responsive…
What should you know about 1. Foundations: What an Index Actually Is?
Before we can appreciate a covering index, we need to understand the baseline. In relational databases an index is a separate data structure—usually a B‑tree or a variant—organized by one or more key columns. The index stores the key values in sorted order together with a row locator (a pointer to the heap row in…
What should you know about 2. Anatomy of a Covering Index?
A covering index is more than just “an index with extra columns.” It is a deliberately engineered structure where:
What should you know about 2.1 Why “Include” Matters?
In PostgreSQL and SQL Server, included columns are stored only in the leaf pages and do not participate in the B‑tree ordering. This means the index size grows more modestly than if you added the same columns as key columns, because the internal pages (the “branch” nodes) stay slim.
What should you know about 2.2 Order of Key Columns?
The order of key columns determines the selectivity of the index for a given query. A classic rule of thumb: place the column with the highest cardinality (most distinct values) first, unless the query’s predicate is always equality on a later column.
References & sources
  1. Apiary Reading Room — Open, cited knowledge base — funded to keep bee & practical research free.
From the Apiary Reading Room. Opinion & editorial — not financial advice. We don't overclaim.
More from the Reading Room