Skip to content
Guide · Time-series data engineering

Time-Series Indexing for Asset and Timestamp Queries

How to index a sensor readings table for the queries building and IoT systems actually run: one point over a time range, every point of an asset, the latest value, a site-wide time window and exceptions. Each choice is shown with an EXPLAIN plan from PostgreSQL 18.6 on 2,016,000 one-minute readings, with a MySQL InnoDB comparison and the TimescaleDB defaults from its documentation.

Facts checked 27 October 2026 against the vendors' official documentation. Versions covered: PostgreSQL 18 (run on 18.6 in PGlite 0.5.11); MySQL 8.0 (run on 8.0.46); TimescaleDB from current documentation (not run). Next review due April 2027. Products, licences and prices in this field change quickly; confirm on the vendor's site before you choose. No benchmarks are quoted unless the source and test setup are named.
Short answer
  • Index for the query patterns you run, not for the columns you have. Where the dominant pattern is "these points between A and B", as it often is in building data, a primary key (point_id, ts) with the point first and ts second follows the documented multicolumn rule.
  • Column order matters: in one run on synthetic, time-ordered data, the same one-point, six-hour query read 647 buffers through a (ts, point_id) index and 364 through (point_id, ts), of which only 4 were index pages. These buffer counts are an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server.
  • When readings arrive in time order, one point's rows are spread across many heap pages. In the same run a covering index with INCLUDE (value) turned the query into an index-only scan of 5 buffers, at the cost of a second large index (an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server).
  • In that run a BRIN index on ts was 24 kB against 61 MB for the primary key (an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server); it can be enough for site-wide time-window reports when rows are stored roughly in time order.
  • Partial indexes can keep exception queries (bad quality, alarm flags) cheap when exceptions are rare: 1,048 kB in the run for the rows that matter instead of a full-table index (an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server).
How we know: Research-based from the PostgreSQL 18 documentation (multicolumn indexes, index-only scans and covering indexes, BRIN, partial indexes, index ordering, EXPLAIN, LATERAL subqueries), the MySQL 8.0 Reference Manual (InnoDB clustered and secondary indexes) and the TimescaleDB hypertable indexes page, checked 27 October 2026. All PostgreSQL SQL and EXPLAIN output on this page was run on PostgreSQL 18.6 (PGlite 0.5.11) with the session time zone set to UTC; the MySQL EXPLAIN output was run on MySQL 8.0.46. Every buffer count and size on this page is an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server; they show relative differences between plans in that one run on synthetic data and will differ on your hardware and data. The runs used a single session, so concurrent insert behaviour could not be checked; the latest_value upsert is labelled an educational example and its behaviour under concurrent workers is taken from the PostgreSQL INSERT documentation, not run. Index recommendations are framed with the query conditions they depend on. TimescaleDB behaviour is taken from its documentation and was not run.

Scope: which queries, which table

This guide is about choosing indexes for a narrow readings table, one row per point per timestamp, as designed in How to Store IoT Sensor Data in a Database. It assumes you already have that table, or something close to it, and want to know which indexes to add, in which column order, and what each costs. It is written for developers and DBAs working on BMS, CAFM, CMMS and IoT platforms in PostgreSQL, with notes for MySQL and TimescaleDB.

It does not repeat partitioning (see Choosing Time-Series Partition Intervals) or the write cost of indexes in general (see Time-Series Database Performance). If B-tree indexes are new to you, read SQL indexes explained for beginners and how SQL indexes improve performance first.

The query patterns this page indexes for
PatternTypical callerIndex that serves it
One point over a time rangeTrend chart, fault diagnosisB-tree (point_id, ts), optionally covering
All points of one asset over a rangeEquipment page, CMMS work order contextSame B-tree, reached through the point table
Latest value of each pointLive dashboard, alarm rulesSame B-tree, scanned backward
Every point in a time windowDaily energy report, export jobBRIN on ts (or partition pruning)
Exceptions onlyData quality and override reportsPartial index with the exception predicate

The example data

The example models 50 air handling units with four points each (supply and return air temperature, zone CO2, fan speed): 200 points, one reading a minute for seven days, 2,016,000 rows. Rows are inserted in time order, as a live ingest would write them, so the readings for one point are interleaved with those of the other 199. One reading an hour per point (minute 17) has a non-zero quality code, to give the exception queries something to find.

Tables and sample data (PostgreSQL, run on 18.6)
SET TimeZone = 'UTC';

CREATE TABLE point (
    point_id   integer PRIMARY KEY,
    asset_tag  text NOT NULL,
    point_name text NOT NULL
);
INSERT INTO point
SELECT p,
       'AHU-' || lpad(((p - 1) / 4 + 1)::text, 2, '0'),
       (ARRAY['supply_air_temp', 'return_air_temp', 'zone_co2', 'fan_speed'])[(p - 1) % 4 + 1]
FROM generate_series(1, 200) AS p;

CREATE TABLE reading (
    point_id  integer NOT NULL,
    ts        timestamptz NOT NULL,
    value     double precision,
    quality   smallint NOT NULL DEFAULT 0
);
INSERT INTO reading (point_id, ts, value, quality)
SELECT p,
       g.ts,
       20 + (p % 7) + extract(minute FROM g.ts) / 10.0,
       CASE WHEN extract(minute FROM g.ts) = 17 THEN 1 ELSE 0 END
FROM generate_series(1, 200) AS p
CROSS JOIN generate_series('2026-10-01 00:00+00'::timestamptz,
                           '2026-10-07 23:59+00'::timestamptz,
                           interval '1 minute') AS g(ts)
ORDER BY g.ts, p;

SELECT count(*) AS rows FROM reading;
--   rows
-- ---------
--  2016000

Composite B-tree: point first, timestamp second

The PostgreSQL documentation states the rule for multicolumn B-trees precisely: equality constraints on leading columns, plus an inequality on the first column without an equality constraint, limit the part of the index that is scanned. Constraints on later columns are checked inside the index but do not necessarily shrink the scan. For WHERE point_id = 42 AND ts BETWEEN ... that means:

  • (point_id, ts): the scan starts at the first entry for point 42 inside the range and stops at the last one. Only matching entries are read.
  • (ts, point_id): the range on ts bounds the scan, but inside it every point's entries are read and filtered on point_id. For six hours that is 72,000 entries to find 360.

The run below creates the timestamp-first index, measures the query, then replaces it with the point-first primary key and measures again. EXPLAIN (ANALYZE, BUFFERS) runs the query and reports how many 8 kB buffers each plan node touched, which is a fairer comparison than timing on a small machine.

Timestamp-first index (run on 18.6)
CREATE INDEX reading_ts_point ON reading (ts, point_id);
VACUUM ANALYZE reading;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT ts, value FROM reading
WHERE point_id = 42
  AND ts >= '2026-10-03 00:00+00' AND ts < '2026-10-03 06:00+00';
Plan
Index Scan using reading_ts_point on reading (actual rows=360.00 loops=1)
  Index Cond: ((ts >= '2026-10-03 00:00:00+00'::timestamp with time zone) AND (ts < '2026-10-03 06:00:00+00'::timestamp with time zone) AND (point_id = 42))
  Index Searches: 1
  Buffers: shared hit=369 read=278
Planning:
  Buffers: shared hit=15
Point-first primary key instead (run on 18.6)
DROP INDEX reading_ts_point;
ALTER TABLE reading ADD PRIMARY KEY (point_id, ts);
VACUUM ANALYZE reading;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT ts, value FROM reading
WHERE point_id = 42
  AND ts >= '2026-10-03 00:00+00' AND ts < '2026-10-03 06:00+00';
Plan
Bitmap Heap Scan on reading (actual rows=360.00 loops=1)
  Recheck Cond: ((point_id = 42) AND (ts >= '2026-10-03 00:00:00+00'::timestamp with time zone) AND (ts < '2026-10-03 06:00:00+00'::timestamp with time zone))
  Heap Blocks: exact=360
  Buffers: shared hit=360 read=4
  ->  Bitmap Index Scan on reading_pkey (actual rows=360.00 loops=1)
        Index Cond: ((point_id = 42) AND (ts >= '2026-10-03 00:00:00+00'::timestamp with time zone) AND (ts < '2026-10-03 06:00:00+00'::timestamp with time zone))
        Index Searches: 1
        Buffers: shared read=4
Planning:
  Buffers: shared hit=14

Reading the two plans

These buffer counts are an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server. They come from one run on synthetic, perfectly ordered data; read them as relative differences between the two plans, not as performance figures. The point-first index found the 360 rows by reading 4 index pages. The other 360 buffers are heap pages: Heap Blocks: exact=360 means each of the 360 readings sits on a different table page, because the table is in arrival order and 199 other points were written between consecutive readings of point 42. The timestamp-first plan touched 647 buffers for the same rows; the extra buffers are the index entries of the other points in the range. The gap grows with the number of points and the length of the range.

PostgreSQL 18 adds B-tree skip scan, which can sometimes use an index whose leading column has no condition. The documentation describes it as worthwhile mainly when the leading column has few distinct values; it is not a reason to put ts first. The plan above reports Index Searches: 1, so no skipping took place.

Asset queries and latest values use the same index

You do not need an asset_id column in the readings table to query by asset. Filter the small point table, then let the planner probe the primary key once per point. The plan is a nested loop: a scan of point for the asset, then a bitmap index scan of reading_pkey per point.

For the latest value of each point, a LATERAL subquery with ORDER BY ts DESC LIMIT 1 lets each point read one entry from the end of its part of the index. B-tree indexes can be scanned backward, so no separate descending index is needed.

All points of one asset for an hour (plan, run on 18.6)
EXPLAIN (COSTS OFF)
SELECT p.point_name, r.ts, r.value
FROM reading r
JOIN point p USING (point_id)
WHERE p.asset_tag = 'AHU-11'
  AND r.ts >= '2026-10-03 08:00+00' AND r.ts < '2026-10-03 09:00+00';

-- Nested Loop
--   ->  Seq Scan on point p
--         Filter: (asset_tag = 'AHU-11'::text)
--   ->  Bitmap Heap Scan on reading r
--         Recheck Cond: ((point_id = p.point_id) AND (ts >= '2026-10-03 08:00:00+00'::timestamp with time zone) AND (ts < '2026-10-03 09:00:00+00'::timestamp with time zone))
--         ->  Bitmap Index Scan on reading_pkey
--               Index Cond: ((point_id = p.point_id) AND (ts >= '2026-10-03 08:00:00+00'::timestamp with time zone) AND (ts < '2026-10-03 09:00:00+00'::timestamp with time zone))
Latest value per point of one asset (plan and result, run on 18.6)
EXPLAIN (COSTS OFF)
SELECT p.point_name, l.ts, l.value
FROM point p
CROSS JOIN LATERAL (
    SELECT ts, value FROM reading r
    WHERE r.point_id = p.point_id
    ORDER BY ts DESC
    LIMIT 1
) l
WHERE p.asset_tag = 'AHU-11';

-- Nested Loop
--   ->  Seq Scan on point p
--         Filter: (asset_tag = 'AHU-11'::text)
--   ->  Limit
--         ->  Index Scan Backward using reading_pkey on reading r
--               Index Cond: (point_id = p.point_id)

SELECT p.point_name, l.ts, l.value
FROM point p
CROSS JOIN LATERAL (
    SELECT ts, value FROM reading r
    WHERE r.point_id = p.point_id
    ORDER BY ts DESC
    LIMIT 1
) l
WHERE p.asset_tag = 'AHU-11';

--    point_name    |           ts           | value
-- -----------------+------------------------+-------
--  supply_air_temp | 2026-10-07 23:59:00+00 |  31.9
--  return_air_temp | 2026-10-07 23:59:00+00 |  25.9
--  zone_co2        | 2026-10-07 23:59:00+00 |  26.9
--  fan_speed       | 2026-10-07 23:59:00+00 |  27.9

When to denormalise the asset

Copying asset_id into every reading and indexing (asset_id, ts) serves "everything for this asset" with one range scan instead of one probe per point. It costs 4 bytes per row and an extra index on every insert, and it goes stale if points move between assets. In our judgement it is worth considering only when assets have many points (hundreds is an illustrative figure, not a threshold) and asset-level queries dominate; compare both plans on your data. For a live dashboard over thousands of points, a small latest_value table updated by the ingest job (one row per point, upserted) may be cheaper than querying the history table, at the cost of an extra write per reading. With several ingest workers, two writes for the same point can race: a read-then-write pattern (look up the stored row, then UPDATE or INSERT) can let an older reading overwrite a newer one or fail on a duplicate key. The safer documented alternative is a single INSERT ... ON CONFLICT (point_id) DO UPDATE statement, which the PostgreSQL documentation says guarantees an atomic insert or update outcome even under high concurrency, with a WHERE on the update so that only a newer ts replaces the stored row; the documentation adds that the condition is evaluated after a conflict has been identified, and that all conflicting rows are locked when the update action is taken. The example below is educational: it ran in a single session only, so it shows the statement's logic, not its behaviour under concurrent workers, which rests on the documentation quoted here and was not run.

latest_value upsert that never moves backwards in time (run on 18.6, single session; educational example)
CREATE TABLE latest_value (
    point_id integer PRIMARY KEY,
    ts       timestamptz NOT NULL,
    value    double precision
);

-- the ingest job runs this once per reading (or per batch, one row per point)
INSERT INTO latest_value AS lv (point_id, ts, value)
VALUES (42, '2026-10-07 23:59+00', 21.7)
ON CONFLICT (point_id) DO UPDATE
    SET ts = EXCLUDED.ts, value = EXCLUDED.value
    WHERE lv.ts < EXCLUDED.ts;

-- a late, older reading for the same point arrives afterwards: not applied
INSERT INTO latest_value AS lv (point_id, ts, value)
VALUES (42, '2026-10-07 23:58+00', 21.5)
ON CONFLICT (point_id) DO UPDATE
    SET ts = EXCLUDED.ts, value = EXCLUDED.value
    WHERE lv.ts < EXCLUDED.ts;
SELECT point_id, ts::text AS ts, value FROM latest_value;
--  point_id |           ts           | value
-- ----------+------------------------+-------
--        42 | 2026-10-07 23:59:00+00 |  21.7

-- a newer reading: applied
INSERT INTO latest_value AS lv (point_id, ts, value)
VALUES (42, '2026-10-08 00:00+00', 21.9)
ON CONFLICT (point_id) DO UPDATE
    SET ts = EXCLUDED.ts, value = EXCLUDED.value
    WHERE lv.ts < EXCLUDED.ts;
SELECT point_id, ts::text AS ts, value FROM latest_value;
--  point_id |           ts           | value
-- ----------+------------------------+-------
--        42 | 2026-10-08 00:00:00+00 |  21.9

Covering indexes: avoid the heap for trend queries

The point-first plan still visited 360 heap pages to fetch value. A covering index stores the payload column in the index leaf with INCLUDE, so the query can be answered by an index-only scan. That works only for heap pages marked all-visible in the visibility map, which VACUUM (or autovacuum) maintains; on append-mostly readings that is most of the table once vacuum has run.

Covering index and the same query (run on 18.6)
CREATE INDEX reading_point_ts_incl ON reading (point_id, ts) INCLUDE (value);
VACUUM ANALYZE reading;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT ts, value FROM reading
WHERE point_id = 42
  AND ts >= '2026-10-03 00:00+00' AND ts < '2026-10-03 06:00+00';
Plan
Index Only Scan using reading_point_ts_incl on reading (actual rows=360.00 loops=1)
  Index Cond: ((point_id = 42) AND (ts >= '2026-10-03 00:00:00+00'::timestamp with time zone) AND (ts < '2026-10-03 06:00:00+00'::timestamp with time zone))
  Heap Fetches: 0
  Index Searches: 1
  Buffers: shared hit=1 read=4
Planning:
  Buffers: shared hit=19

What the covering index costs

In this run (an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server), five buffers instead of 364, but the covering index was 78 MB next to a 61 MB primary key and a 116 MB table (sizes below). Keeping both doubles the B-tree work on every insert. Two common ways out: make the primary key itself covering (PRIMARY KEY (point_id, ts) INCLUDE (value), which the documentation allows), or accept the heap visits and rely on partitioning to keep the working set small. Do not include wide or rarely read columns; the documentation warns that payload columns bloat the index.

BRIN for site-wide time windows

Reports such as "all meters yesterday" constrain only ts. A B-tree on ts would work, but it is as large as the primary key and adds the same write cost. A BRIN index stores a summary (here the minimum and maximum timestamp) per range of table blocks and is very small (24 kB in the illustrative PGlite run below). It works because readings are written roughly in time order, so each block range covers a narrow slice of time. BRIN is lossy: the executor rechecks every row in the matching block ranges and discards the rest, which the plan shows as Rows Removed by Index Recheck.

BRIN on ts and a one-hour, all-points query (run on 18.6)
CREATE INDEX reading_ts_brin ON reading USING brin (ts);
VACUUM ANALYZE reading;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*), avg(value) FROM reading
WHERE ts >= '2026-10-05 00:00+00' AND ts < '2026-10-05 01:00+00';
Plan
Aggregate (actual rows=1.00 loops=1)
  Buffers: shared hit=133
  ->  Bitmap Heap Scan on reading (actual rows=12000.00 loops=1)
        Recheck Cond: ((ts >= '2026-10-05 00:00:00+00'::timestamp with time zone) AND (ts < '2026-10-05 01:00:00+00'::timestamp with time zone))
        Rows Removed by Index Recheck: 5408
        Heap Blocks: lossy=128
        Buffers: shared hit=133
        ->  Bitmap Index Scan on reading_ts_brin (actual rows=1280.00 loops=1)
              Index Cond: ((ts >= '2026-10-05 00:00:00+00'::timestamp with time zone) AND (ts < '2026-10-05 01:00:00+00'::timestamp with time zone))
              Index Searches: 1

Where BRIN stops working

BRIN depends on physical order matching time order. A gateway that uploads a week of buffered readings after an outage, a bulk back-fill, or heavy updates that move rows all widen the block ranges, and the index then matches more of the table. New block ranges are summarised by vacuum (or immediately if the index has autosummarize enabled), so very recent data may be read without help from the index until then. If you partition by time, partition pruning already removes most of the table for these queries and BRIN only narrows the scan within each partition. For late data and how it affects storage order, see Late-Arriving and Out-of-Order Sensor Events.

Partial indexes for exception queries

Data quality and override reports ask for the small fraction of rows with a non-zero quality code or a status flag set. A partial index contains only rows that match its predicate, so it stays small and its maintenance cost falls only on those rows. The query must include the same predicate for the planner to use it.

Partial index on non-zero quality (run on 18.6)
CREATE INDEX reading_bad_quality ON reading (point_id, ts) WHERE quality <> 0;
ANALYZE reading;

EXPLAIN (COSTS OFF)
SELECT point_id, ts FROM reading
WHERE quality <> 0 AND point_id = 7 AND ts >= '2026-10-06 00:00+00';

-- Index Only Scan using reading_bad_quality on reading
--   Index Cond: ((point_id = 7) AND (ts >= '2026-10-06 00:00:00+00'::timestamp with time zone))
Sizes of everything created on this page (run on 18.6; illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server)
SELECT c.relname AS relation, pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relname IN ('reading', 'reading_pkey', 'reading_ts_brin',
                    'reading_point_ts_incl', 'reading_bad_quality')
ORDER BY pg_relation_size(c.oid) DESC;

--        relation        |  size
-- -----------------------+---------
--  reading               | 116 MB
--  reading_point_ts_incl | 78 MB
--  reading_pkey          | 61 MB
--  reading_bad_quality   | 1048 kB
--  reading_ts_brin       | 24 kB

MySQL InnoDB and TimescaleDB differences

MySQL InnoDB. The primary key is the clustered index: the table rows are stored in primary-key order. With PRIMARY KEY (point_id, ts), one point's readings sit together, so the heap-scatter problem above does not arise for point queries, but inserts arriving in time order land in many places in the tree instead of at one end. Every InnoDB secondary index carries the primary-key columns, so a secondary index on ts already covers point_id. The plans below are from MySQL 8.0.46 on 72,000 rows (50 points, one day, one-minute data).

MySQL 8.0 table and plans (run on 8.0.46)
CREATE TABLE reading (
    point_id  int NOT NULL,
    ts        datetime(3) NOT NULL,
    value     double,
    quality   smallint NOT NULL DEFAULT 0,
    PRIMARY KEY (point_id, ts),
    KEY reading_ts (ts)
) ENGINE=InnoDB;

-- 50 points x one day of one-minute readings (72,000 rows)
SET SESSION cte_max_recursion_depth = 2000;
INSERT INTO reading (point_id, ts, value)
WITH RECURSIVE m (n) AS (SELECT 0 UNION ALL SELECT n + 1 FROM m WHERE n < 1439),
               p (id) AS (SELECT 1 UNION ALL SELECT id + 1 FROM p WHERE id < 50)
SELECT p.id, TIMESTAMP '2026-10-05 00:00:00' + INTERVAL m.n MINUTE, 20 + p.id / 10
FROM p CROSS JOIN m;
ANALYZE TABLE reading;

EXPLAIN FORMAT=TREE SELECT ts, value FROM reading
WHERE point_id = 42 AND ts >= '2026-10-05 06:00' AND ts < '2026-10-05 12:00';
-- -> Filter: ((reading.point_id = 42) and (reading.ts >= TIMESTAMP'2026-10-05 06:00:00') and (reading.ts < TIMESTAMP'2026-10-05 12:00:00'))  (cost=72.4 rows=360)
--     -> Index range scan on reading using PRIMARY over (point_id = 42 AND '2026-10-05 06:00:00.000' <= ts < '2026-10-05 12:00:00.000')  (cost=72.4 rows=360)

EXPLAIN FORMAT=TREE SELECT point_id, ts FROM reading
WHERE ts >= '2026-10-05 06:00' AND ts < '2026-10-05 06:05';
-- -> Filter: ((reading.ts >= TIMESTAMP'2026-10-05 06:00:00') and (reading.ts < TIMESTAMP'2026-10-05 06:05:00'))  (cost=50.4 rows=250)
--     -> Covering index range scan on reading using reading_ts over ('2026-10-05 06:00:00.000' <= ts < '2026-10-05 06:05:00.000')  (cost=50.4 rows=250)

EXPLAIN FORMAT=TREE SELECT ts, value FROM reading
WHERE point_id = 42 ORDER BY ts DESC LIMIT 1;
-- -> Limit: 1 row(s)  (cost=145 rows=1)
--     -> Index lookup on reading using PRIMARY (point_id=42) (reverse)  (cost=145 rows=1440)
TimescaleDB: indexes on a hypertable. Illustrative DDL, not run here: adapted from the TimescaleDB documentation; check it against the documentation for your version before use
-- A hypertable gets a B-tree on the time column (descending) by default.
-- Unique indexes and primary keys must include the time (partition) column,
-- because uniqueness is enforced per chunk.
CREATE TABLE reading (
    point_id  integer NOT NULL,
    ts        timestamptz NOT NULL,
    value     double precision,
    quality   smallint NOT NULL DEFAULT 0,
    PRIMARY KEY (point_id, ts)
) WITH (tsdb.hypertable);

TimescaleDB notes

In TimescaleDB each index exists per chunk, so the index being written to covers only the current chunk rather than the whole table; this is the same effect as partitioning in plain PostgreSQL. The documentation says the default time index can be turned off with timescaledb.create_default_indexes = false if you plan to create your own, which avoids maintaining both a time-only index and a (point_id, ts) key. Once chunks move to the columnstore, row-level B-tree indexes are no longer the main access path; see Time-Series Compression and Columnar Storage and TimescaleDB Explained.

Decision checklist, limitations and when not to add an index

Start from the primary key and add the others only when a query pattern you actually run shows up in EXPLAIN as reading far more than it returns.

A starting index set to test for a narrow readings table (conditions, not universal rules)
IndexAdd whenCost to watch
PRIMARY KEY (point_id, ts)Most queries filter on one or a few points plus a time range, and one reading per point per timestamp is the rule: it then serves point ranges, asset queries, latest value and duplicate rejectionOne B-tree insert per row
Covering INCLUDE (value) on that keyTrend queries over long ranges dominate and heap reads show up in plansLarger key index; replace the plain key rather than adding a second one
BRIN on tsReports filter on time only and rows arrive roughly in orderVery small; loses selectivity with late or back-filled data
Partial index on exceptionsException reports run often and exceptions are rareOnly rows matching the predicate are indexed
B-tree on ts aloneRarely: time-only queries that must be exact and fast on unpartitioned, unordered dataAs large as the key index, on every insert

Limitations and when not to add an index

Limitations. The numbers above are an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server, from one run on synthetic, perfectly regular data in an in-process PostgreSQL build. Real tables have dead rows, uneven point counts and late data; confirm with EXPLAIN (ANALYZE, BUFFERS) on a copy of your own data before and after each change, and watch insert throughput as well as query plans.

When not to add an index. Do not index a column because it appears in a WHERE clause once a month; a scan of one partition may be acceptable. Do not add a (ts) B-tree "for safety" on a table partitioned by time without checking a plan first: for queries that filter on the partition key, pruning already handles the time predicate. And do not index tag-like columns with many distinct values per row without checking their cardinality first; see High-Cardinality Tags in Time-Series Databases. For the overall picture of time-series storage, return to the time-series overview.

Frequently asked questions

What is the best index for time-series data in PostgreSQL?

There is no single best index; it depends on your queries. For a narrow readings table whose main queries are one point over a time range, the latest value and duplicate rejection, a B-tree on (point_id, ts), usually the primary key, serves them. Add a BRIN index on ts for time-only reports and a partial index for exception queries if you run them often.

Should the timestamp be the first column of a composite index?

Not for point or asset queries. With the timestamp first, the index scan reads every point's entries in the time range and filters them; in one run on synthetic data on this page that cost 647 buffers against 364 for (point_id, ts) (an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly); not a production benchmark and not representative of your server). For queries with an equality on the point and a range on time, the PostgreSQL multicolumn rule favours the equality column first and the range column second.

Is a BRIN index enough for time-series queries?

For time-window queries across all points, often yes, provided rows are stored roughly in time order. It does not help "one point over a range" queries; those need the B-tree.

When should I use a covering index?

When plans show many heap blocks for queries that read only a few columns, typically long trend queries. Prefer making the existing key covering over adding a second index with the same key columns.

Does TimescaleDB need different indexes?

The same principles apply per chunk. TimescaleDB creates a time index by default and requires unique indexes to include the time column; compressed (columnstore) chunks rely on other mechanisms such as segment and order settings.

Sources

Checked 27 October 2026.

How we research these articles: our editorial method. Research-based from official documentation; SQL examples are run on a local database where the page says so. CodeWithSQL earns nothing from the vendors mentioned.

Building a sensor or telemetry database?

Start with the overview, or compare time-series databases side by side.