Skip to content
Explainer · Time-series data engineering

Continuous Aggregates vs Materialized Views

Dashboards over sensor data should read a small roll-up, not scan raw readings. The question is how that roll-up stays current. A PostgreSQL materialized view is recomputed in full on every refresh; a TimescaleDB continuous aggregate refreshes only the time buckets that changed; QuestDB and ClickHouse update their materialized views as rows are inserted. This article compares the refresh models, shows the trade-offs for late-arriving building data, and builds a roll-up in plain PostgreSQL that recomputes a recent window on every run, run on PostgreSQL 18.6, alongside a trigger-based incremental design kept as an educational example only.

Facts checked 27 October 2026 against the vendors' official documentation. Versions covered: PostgreSQL 18 (examples run on PostgreSQL 18.6 in PGlite 0.5.11); TimescaleDB 2.x documentation as published by Tiger Data on 27 October 2026; QuestDB and ClickHouse documentation as published on 27 October 2026. 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
  • A PostgreSQL materialized view stores a query result. REFRESH MATERIALIZED VIEW replaces the entire contents; CONCURRENTLY keeps readers unblocked but needs a unique index and still recomputes everything.
  • A TimescaleDB continuous aggregate is stored as a hypertable and refreshed incrementally: an invalidation log records which time ranges changed, and a policy re-materializes only buckets inside its refresh window.
  • QuestDB and ClickHouse materialized views are driven by inserts into the base (left-most) table. Changes to joined tables do not trigger a refresh in either product.
  • Store additive parts (sum, count, min, max) rather than only an average, so roll-ups can be corrected bucket by bucket and re-aggregated into coarser tiers correctly.
  • In plain PostgreSQL, the recommended pattern on this page is to recompute a bounded recent window of buckets from committed data on every run, with a periodic reconciliation of older buckets. A trigger and invalidation table design is also shown, but only as an educational example: it has a race with concurrent writers and is not safe for production as shown.
How we know: Research-based from the PostgreSQL 18 documentation (materialized views, CREATE and REFRESH MATERIALIZED VIEW), Tiger Data's TimescaleDB documentation (continuous aggregates, refresh policies, real-time and hierarchical aggregates), the QuestDB materialized view pages (concept, REFRESH MATERIALIZED VIEW, out-of-order data), the ClickHouse incremental materialized view guide and CREATE VIEW reference, and the PostgreSQL 18 concurrency chapter (transaction isolation, explicit locking, advisory lock functions), checked 27 October 2026. The plain PostgreSQL scripts (the recommended window recompute and the educational trigger-based design) were run on PostgreSQL 18.6 (PGlite 0.5.11) in a single session; outputs are from those runs, with timestamps shown in psql style (the probe prints them in ISO format). Behaviour under concurrent writers was not verified, because PGlite is single-session: the correctness argument for the window recompute rests on documented READ COMMITTED snapshot behaviour and ON CONFLICT DO UPDATE, and the races and the table-lock options for the trigger design are analysed from the PostgreSQL documentation. Window lengths and schedules are illustrative assumptions. TimescaleDB, QuestDB and ClickHouse syntax is taken from their documentation and was not run here; licence terms are described from Tiger Data's editions page and are not legal advice. No performance figures are given.

Scope: one decision, how roll-ups stay current

This article is for developers and data engineers who already keep raw sensor readings in SQL and now need fast trend charts, energy reports or KPI tiles. The decision it covers is narrow: once you have decided to pre-aggregate, which mechanism keeps the roll-up correct as new and late readings arrive, and what each one costs to run. It does not re-explain hypertables or retention policies; for those see TimescaleDB explained and retention and downsampling design. For the wider PostgreSQL versus TimescaleDB choice, see TimescaleDB vs PostgreSQL.

The running example is a central plant: chiller power (kW) for several chillers, read every minute by the BMS gateway, published over MQTT and written in batches to a chiller_kw table. Operators want hourly averages and peaks per chiller; facilities managers want daily totals; maintenance planners in the CMMS want peak-demand hours next to the chiller's open work orders.

Three refresh models

Full recompute (PostgreSQL materialized view). The view stores the output of its query. REFRESH MATERIALIZED VIEW re-runs the whole query and replaces the contents. Without CONCURRENTLY, readers are blocked while it runs; with it, readers continue, but the option requires at least one unique index on plain columns. Either way, cost grows with the size of the source table, not with the size of the change.

Invalidation and refresh window (TimescaleDB continuous aggregate). The aggregate is a materialization hypertable plus an invalidation engine. Inserts with recent timestamps, beyond the invalidation threshold, need no extra bookkeeping; when older data is changed, each transaction logs the minimum and maximum timestamps it touched, and the next refresh recomputes only the complete buckets affected. A policy decides when to refresh and which window (start_offset to end_offset) to look at. Since TimescaleDB 2.13 real-time aggregation, which adds not-yet-materialized raw rows at query time, is off by default.

Insert-driven (QuestDB, ClickHouse). QuestDB materialized views are defined with SAMPLE BY over a base table and process only new rows as they arrive. A ClickHouse materialized view is described in its documentation as a trigger that runs a query on blocks of data as they are inserted, writing the result to a target table. In both, only the base table (in ClickHouse, the left-most table in the query) triggers updates.

Refresh models compared (from each product's documentation, checked 27 October 2026)
PostgreSQL materialized viewTimescaleDB continuous aggregateQuestDB materialized viewClickHouse materialized view
What triggers workYour REFRESH command or schedulerRefresh policy (background job) or refresh_continuous_aggregateNew rows in the base tableEach insert block into the left-most table
Work per refreshWhole queryInvalidated buckets in the refresh windowNew rowsThe inserted block
Late or corrected raw rowsPicked up at the next full refreshLogged as invalidations; re-materialized when inside the windowLate rows: the affected buckets are recomputed on the next incremental refresh. UPDATE on the base table makes the view invalid until a full refreshLate rows are processed like any other insert block; changes to existing rows (update, delete) do not change the view
Raw rows deletedDisappear from the view at the next refreshRemoved from the aggregate if the refresh window covers themTRUNCATE invalidates the view; after dropping base partitions the documentation calls for a full refreshNot reflected: deletes and dropped partitions do not change the view
JoinsAny SQLRestricted; only hypertable changes are trackedAllowed, SAMPLE BY required; joined-table changes do not triggerAllowed; right-side tables do not trigger
Freshness at query timeAs of last refreshAs of last refresh, or real-time if enabledAs of the rows processed so farAs of the blocks inserted so far; merge-based targets may need re-aggregation or FINAL

Design decisions before you choose

  • Bucket width and alignment. Pick the widths users read (15 minutes, 1 hour, 1 day) and one origin for all of them, for example date_bin(interval '1 hour', ts, '2000-01-01') in PostgreSQL or time_bucket in TimescaleDB. Mixing time zones between tiers is a common source of daily totals that do not add up.
  • What to store. Store sum, count, min and max; derive the average as sum / count. An average of averages is wrong when buckets hold different numbers of samples, which the Tiger Data hierarchical aggregates page also warns about; the downsampling article shows the difference on real output.
  • How late is late. BMS gateways that buffer through a network outage can deliver readings hours later. The refresh window (or the recompute window of your own job) must reach back at least that far, or late readings reach the roll-up only through a later reconciliation, if at all. See late-arriving and out-of-order events.
  • Freshness target. A TimescaleDB policy that runs hourly and excludes the latest hour can take up to two hours to reflect a new reading, according to the refresh policy documentation. If a control-room tile must be current to the minute, either enable real-time aggregation or read the latest raw values directly.
  • Interaction with retention. When raw data is dropped inside a refresh window, the next refresh can remove the aggregated rows too. Decide raw retention and refresh window together.

Without TimescaleDB, the simplest roll-up whose correctness rests only on documented PostgreSQL behaviour is a summary table that each scheduled run recomputes for a bounded window of recent buckets, from the raw rows committed at that moment, with an idempotent upsert. Nothing records which buckets changed, so there is no marker for a concurrent writer to lose. Two documented behaviours carry the argument. Under the default READ COMMITTED isolation, a query sees only data committed before it began, so a reading committed after one run started is seen by every later run. And INSERT ... ON CONFLICT DO UPDATE guarantees an atomic insert-or-update outcome even under concurrency. A late reading is therefore included by the first run that starts after it commits, provided its bucket is still inside that run's window. Buckets older than the window are repaired by a periodic reconciliation over a longer, closed range.

The script below was run in a single session on PostgreSQL 18.6 (PGlite). Concurrent sessions cannot be run here, so the timeline is simulated: run 1 at 09:05, late readings committed after it, run 2 at 09:35. The 3-hour window and the run times are illustrative assumptions.

Window recompute with periodic reconciliation (run on PostgreSQL 18.6, single session)
SET TIME ZONE 'UTC';
CREATE TABLE chiller_kw (
    point_id  integer          NOT NULL,
    ts        timestamptz      NOT NULL,
    kw        double precision NOT NULL,
    PRIMARY KEY (point_id, ts)
);
INSERT INTO chiller_kw
SELECT p, t, 100 * p + extract(minute FROM t)
FROM generate_series(1, 2) AS p,
     generate_series(timestamptz '2026-10-27 06:00+00',
                     timestamptz '2026-10-27 08:59+00', interval '1 minute') AS t;
CREATE MATERIALIZED VIEW chiller_kw_hourly AS          -- used only for the cross-check
SELECT point_id, date_bin(interval '1 hour', ts, timestamptz '2000-01-01') AS bucket,
       sum(kw) AS sum_kw, count(*) AS samples, min(kw) AS min_kw, max(kw) AS max_kw
FROM chiller_kw GROUP BY 1, 2;
CREATE UNIQUE INDEX ON chiller_kw_hourly (point_id, bucket);

CREATE TABLE chiller_kw_1h (
    point_id integer NOT NULL, bucket timestamptz NOT NULL,
    sum_kw double precision NOT NULL, samples integer NOT NULL,
    min_kw double precision NOT NULL, max_kw double precision NOT NULL,
    PRIMARY KEY (point_id, bucket)
);

-- Recompute every bucket from p_from (rounded down to the hour) to p_to from committed raw rows
CREATE PROCEDURE recompute_chiller_kw_1h(p_from timestamptz, p_to timestamptz DEFAULT 'infinity')
LANGUAGE sql AS $$
  INSERT INTO chiller_kw_1h (point_id, bucket, sum_kw, samples, min_kw, max_kw)
  SELECT point_id, date_bin(interval '1 hour', ts, timestamptz '2000-01-01'),
         sum(kw), count(*), min(kw), max(kw)
  FROM chiller_kw
  WHERE ts >= date_bin(interval '1 hour', p_from, timestamptz '2000-01-01') AND ts < p_to
  GROUP BY 1, 2
  ON CONFLICT (point_id, bucket) DO UPDATE
  SET sum_kw = EXCLUDED.sum_kw, samples = EXCLUDED.samples,
      min_kw = EXCLUDED.min_kw, max_kw = EXCLUDED.max_kw;
  -- buckets in the range whose raw rows have all been deleted
  DELETE FROM chiller_kw_1h r
  WHERE r.bucket >= date_bin(interval '1 hour', p_from, timestamptz '2000-01-01')
    AND r.bucket < p_to
    AND NOT EXISTS (SELECT 1 FROM chiller_kw c
                    WHERE c.point_id = r.point_id
                      AND c.ts >= r.bucket AND c.ts < r.bucket + interval '1 hour');
$$;

-- Run 1 at 09:05 with an illustrative 3-hour window (in a real job: now() - interval '3 hours')
CALL recompute_chiller_kw_1h(timestamptz '2026-10-27 09:05+00' - interval '3 hours');

-- After run 1, a gateway's late readings commit: two for 06:xx and one at 09:00
INSERT INTO chiller_kw VALUES (1, '2026-10-27 06:30:30+00', 500),
                              (1, '2026-10-27 06:31:30+00', 500),
                              (2, '2026-10-27 09:00+00', 200);
SELECT point_id, bucket, samples FROM chiller_kw_1h WHERE point_id = 1 AND bucket = '2026-10-27 06:00+00';

-- Run 2 at 09:35: 06:00 is still inside the window, so the late rows are included
CALL recompute_chiller_kw_1h(timestamptz '2026-10-27 09:35+00' - interval '3 hours');
SELECT point_id, bucket, samples, round((sum_kw / samples)::numeric, 2) AS avg_kw, max_kw
FROM chiller_kw_1h ORDER BY point_id, bucket;

-- A reading for 06:45 commits after the 06:00 bucket has left the window
INSERT INTO chiller_kw VALUES (2, '2026-10-27 06:45:30+00', 900);
CALL recompute_chiller_kw_1h(timestamptz '2026-10-27 10:05+00' - interval '3 hours');
SELECT point_id, bucket, samples FROM chiller_kw_1h WHERE point_id = 2 AND bucket = '2026-10-27 06:00+00';

-- Periodic reconciliation over a longer closed range (illustrative: the whole example day)
CALL recompute_chiller_kw_1h(timestamptz '2026-10-27 00:00+00', timestamptz '2026-10-28 00:00+00');
SELECT point_id, bucket, samples FROM chiller_kw_1h WHERE point_id = 2 AND bucket = '2026-10-27 06:00+00';

-- Cross-check against a full recompute
REFRESH MATERIALIZED VIEW CONCURRENTLY chiller_kw_hourly;
SELECT count(*) AS mismatches
FROM chiller_kw_hourly m FULL JOIN chiller_kw_1h r USING (point_id, bucket)
WHERE m.samples IS DISTINCT FROM r.samples OR m.sum_kw IS DISTINCT FROM r.sum_kw;
Output on PostgreSQL 18.6 (PGlite)
-- chiller 1 at 06:00 after run 1: the late rows committed after it
 point_id |         bucket         | samples
----------+------------------------+---------
        1 | 2026-10-27 06:00:00+00 |      60

-- chiller_kw_1h after run 2
 point_id |         bucket         | samples | avg_kw | max_kw
----------+------------------------+---------+--------+--------
        1 | 2026-10-27 06:00:00+00 |      62 | 141.45 |    500
        1 | 2026-10-27 07:00:00+00 |      60 | 129.50 |    159
        1 | 2026-10-27 08:00:00+00 |      60 | 129.50 |    159
        2 | 2026-10-27 06:00:00+00 |      60 | 229.50 |    259
        2 | 2026-10-27 07:00:00+00 |      60 | 229.50 |    259
        2 | 2026-10-27 08:00:00+00 |      60 | 229.50 |    259
        2 | 2026-10-27 09:00:00+00 |       1 | 200.00 |    200

-- chiller 2 at 06:00 after the 10:05 run (bucket outside the window), then after reconciliation
 samples = 60   (the 06:45:30 reading is not yet included)
 samples = 61   (after the reconciliation run)

 mismatches
------------
          0

What the run shows, and the limits that remain

The two late readings for chiller 1 committed after run 1, so run 1 could not include them; run 2 recomputed 06:00 because it was still inside the window and reached 62 samples, the same figures a full refresh of the materialized view produces. The reading for 06:45 committed when 06:00 had already left the 3-hour window, so the next run did not touch it; only the reconciliation over the whole day picked it up. That is the trade the pattern makes: anything later than the window waits for the reconciliation.

  • Window length. A reading is caught by the regular runs only if its bucket is still inside the window when the first run after its commit starts. So the window must be at least the longest expected gap between a reading's event time and its commit (gateway buffering plus the longest write transaction, since a row becomes visible only at commit) plus the interval between runs. The 3 hours here is illustrative; derive yours from measured lateness (see late-arriving and out-of-order events) and enforce an upper bound on writer transactions if you rely on one.
  • Reconciliation. Run it over closed periods (for example the previous day or week) on a slower schedule, and keep its range clear of raw data removed by retention: the DELETE step removes roll-up rows whose raw rows are gone, which is right for corrections but would erase history over dropped partitions. The DELETE step had nothing to remove in this run.
  • Cost. Every run re-reads the whole window, so cost grows with window length times data rate, not with the number of late rows. Keep the window as short as your lateness allows and make sure the raw table's key or index serves the time range.
  • Overlapping runs. Correctness does not depend on locks, but two overlapping runs waste work, and the one that commits last writes its own snapshot's figures, which the next run corrects while the bucket is still inside the window. Let the scheduler run one at a time.
  • Freshness. Figures are as of the last run; a dashboard that needs the current minute should read recent raw rows.

Other routes that rely only on documented behaviour: REFRESH MATERIALIZED VIEW CONCURRENTLY, which recomputes the whole view from committed data and keeps readers unblocked (practical while the full query stays cheap enough), or TimescaleDB continuous aggregates, whose invalidation and refresh are part of the product and are described from its documentation below.

Educational example: a trigger-maintained incremental roll-up

Educational example: shows the idea in a single session; not safe for production as shown, because of the race described below. Use the window recompute above for production work. The script below builds both options on the chiller data: a materialized view refreshed in full, and an incremental roll-up table maintained from an invalidation table. A statement-level trigger records which (chiller, hour) buckets each insert touched; a refresh procedure claims those buckets with DELETE ... RETURNING and recomputes only them with an upsert, in one statement. The script was run in a single session only; how the pattern behaves with concurrent writers is analysed, not demonstrated, in the section after the output.

Educational example, not production-safe as shown: materialized view versus a trigger-maintained incremental roll-up (run on PostgreSQL 18.6, single session)
SET TIME ZONE 'UTC';
CREATE TABLE chiller_kw (
    point_id  integer          NOT NULL,
    ts        timestamptz      NOT NULL,
    kw        double precision NOT NULL,
    PRIMARY KEY (point_id, ts)
);
-- Two chillers, one reading a minute, 06:00 to 08:59
INSERT INTO chiller_kw
SELECT p, t, 100 * p + extract(minute FROM t)
FROM generate_series(1, 2) AS p,
     generate_series(timestamptz '2026-10-27 06:00+00',
                     timestamptz '2026-10-27 08:59+00', interval '1 minute') AS t;

-- Option A: an ordinary materialized view
CREATE MATERIALIZED VIEW chiller_kw_hourly AS
SELECT point_id, date_bin(interval '1 hour', ts, timestamptz '2000-01-01') AS bucket,
       sum(kw) AS sum_kw, count(*) AS samples, min(kw) AS min_kw, max(kw) AS max_kw
FROM chiller_kw GROUP BY 1, 2;
REFRESH MATERIALIZED VIEW CONCURRENTLY chiller_kw_hourly;   -- fails: no unique index yet
CREATE UNIQUE INDEX ON chiller_kw_hourly (point_id, bucket);
REFRESH MATERIALIZED VIEW CONCURRENTLY chiller_kw_hourly;   -- now allowed

-- Option B: a roll-up table maintained incrementally
CREATE TABLE chiller_kw_1h (
    point_id integer NOT NULL, bucket timestamptz NOT NULL,
    sum_kw double precision NOT NULL, samples integer NOT NULL,
    min_kw double precision NOT NULL, max_kw double precision NOT NULL,
    PRIMARY KEY (point_id, bucket)
);
CREATE TABLE rollup_invalidation (
    point_id integer NOT NULL, bucket timestamptz NOT NULL,
    PRIMARY KEY (point_id, bucket)
);
CREATE FUNCTION log_invalidation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO rollup_invalidation
  SELECT DISTINCT point_id, date_bin(interval '1 hour', ts, timestamptz '2000-01-01') FROM changed
  ON CONFLICT DO NOTHING;
  RETURN NULL;
END $$;
CREATE TRIGGER chiller_kw_ins AFTER INSERT ON chiller_kw
REFERENCING NEW TABLE AS changed FOR EACH STATEMENT EXECUTE FUNCTION log_invalidation();

CREATE PROCEDURE refresh_chiller_kw_1h() LANGUAGE sql AS $$
  WITH claimed AS (
      DELETE FROM rollup_invalidation RETURNING point_id, bucket
  )
  INSERT INTO chiller_kw_1h (point_id, bucket, sum_kw, samples, min_kw, max_kw)
  SELECT c.point_id, cl.bucket, sum(c.kw), count(*), min(c.kw), max(c.kw)
  FROM claimed cl
  JOIN chiller_kw c ON c.point_id = cl.point_id
                   AND c.ts >= cl.bucket AND c.ts < cl.bucket + interval '1 hour'
  GROUP BY c.point_id, cl.bucket
  ON CONFLICT (point_id, bucket) DO UPDATE
  SET sum_kw = EXCLUDED.sum_kw, samples = EXCLUDED.samples,
      min_kw = EXCLUDED.min_kw, max_kw = EXCLUDED.max_kw;
$$;

-- Initial load: mark every existing bucket once, then refresh
INSERT INTO rollup_invalidation
SELECT DISTINCT point_id, date_bin(interval '1 hour', ts, timestamptz '2000-01-01') FROM chiller_kw;
CALL refresh_chiller_kw_1h();
SELECT count(*) AS buckets FROM chiller_kw_1h;

-- A gateway reconnects: two late readings for 06:xx, one new reading at 09:00
INSERT INTO chiller_kw VALUES (1, '2026-10-27 06:30:30+00', 500),
                              (1, '2026-10-27 06:31:30+00', 500),
                              (2, '2026-10-27 09:00+00', 200);
SELECT point_id, bucket FROM rollup_invalidation ORDER BY 1, 2;
CALL refresh_chiller_kw_1h();
SELECT point_id, bucket, samples, round((sum_kw / samples)::numeric, 2) AS avg_kw, max_kw
FROM chiller_kw_1h ORDER BY point_id, bucket;

-- The materialized view is stale until the next full refresh
SELECT point_id, bucket, samples FROM chiller_kw_hourly
WHERE point_id = 1 AND bucket = '2026-10-27 06:00+00';
REFRESH MATERIALIZED VIEW CONCURRENTLY chiller_kw_hourly;
SELECT point_id, bucket, samples FROM chiller_kw_hourly
WHERE point_id = 1 AND bucket = '2026-10-27 06:00+00';

-- Cross-check: both roll-ups agree
SELECT count(*) AS mismatches
FROM chiller_kw_hourly m FULL JOIN chiller_kw_1h r USING (point_id, bucket)
WHERE m.samples IS DISTINCT FROM r.samples OR m.sum_kw IS DISTINCT FROM r.sum_kw;
Output on PostgreSQL 18.6 (PGlite)
REFRESH MATERIALIZED VIEW CONCURRENTLY chiller_kw_hourly;   -- first attempt
ERROR:  cannot refresh materialized view "public.chiller_kw_hourly" concurrently
HINT:  Create a unique index with no WHERE clause on one or more columns of the materialized view.
SQLSTATE 55000

 buckets
---------
       6

-- rollup_invalidation after the late insert
 point_id |         bucket
----------+------------------------
        1 | 2026-10-27 06:00:00+00
        2 | 2026-10-27 09:00:00+00

-- chiller_kw_1h after the incremental refresh
 point_id |         bucket         | samples | avg_kw | max_kw
----------+------------------------+---------+--------+--------
        1 | 2026-10-27 06:00:00+00 |      62 | 141.45 |    500
        1 | 2026-10-27 07:00:00+00 |      60 | 129.50 |    159
        1 | 2026-10-27 08:00:00+00 |      60 | 129.50 |    159
        2 | 2026-10-27 06:00:00+00 |      60 | 229.50 |    259
        2 | 2026-10-27 07:00:00+00 |      60 | 229.50 |    259
        2 | 2026-10-27 08:00:00+00 |      60 | 229.50 |    259
        2 | 2026-10-27 09:00:00+00 |       1 | 200.00 |    200

-- chiller_kw_hourly, chiller 1 at 06:00: before and after the full refresh
 samples = 60   (stale)
 samples = 62   (after REFRESH ... CONCURRENTLY)

 mismatches
------------
          0

What the run shows, and what this pattern does not cover

The late insert invalidated exactly two buckets, and the refresh recomputed those two while the other five rows were left alone. The materialized view reached the same numbers only after recomputing all seven buckets. On three hours of data the difference is irrelevant; on two years of one-minute readings it is the difference between a refresh that touches a few thousand rows and one that scans the whole table.

The pattern is deliberately small. It handles inserts only: corrections by UPDATE or DELETE need equivalent triggers that log both old and new buckets. Every insert statement writes to the invalidation table, which TimescaleDB avoids for recent data with its invalidation threshold. If the raw table is partitioned, create the trigger on the partitioned parent; in a separate run on PostgreSQL 18.6, the same statement-level trigger with a transition table, created on a range-partitioned parent, logged the buckets of rows routed to its partition. As shown, the design also has the race described next, which is why it stays an educational example on this page.

Concurrent writers: known races (not verified here)

The run above used one session, so it says nothing about concurrent writers, and the probes used for this site cannot open a second PostgreSQL session. The following is an analysis of the design under PostgreSQL's default READ COMMITTED isolation, where each statement sees a snapshot of the data committed before it began. Treat it as a review checklist, not a verified result.

  • A marker that already exists. Writer W inserts readings for chiller 1 at 06:xx while a committed marker for (1, 06:00) is already in rollup_invalidation. Its trigger's ON CONFLICT DO NOTHING finds that committed row and inserts nothing. If the refresh statement starts before W commits, it deletes the marker and recomputes 06:00 from a snapshot that does not contain W's rows. When W commits, no marker is left, so the 06:00 bucket stays stale until another insert touches it. This is the important race: a "claim with DELETE ... RETURNING" alone does not prevent it.
  • A new marker. If W's marker did not exist before, it is an uncommitted row that the refresh cannot see or delete, so it survives for the next refresh; this case is safe by the same snapshot rule.
  • Two refreshes at once. The documented READ COMMITTED behaviour is that a second DELETE waits for the first and then skips rows the first deleted, so each marker should be recomputed once; both refreshes still upsert into chiller_kw_1h and can wait on each other. Simpler to make sure only one runs.

Documented options, not verified here. These are listed for readers who want to study the design further; the recommended path remains the window recompute above, whose correctness does not depend on locking that could not be exercised here. Take a table lock on the raw table in the refresh transaction before claiming markers. SHARE ROW EXCLUSIVE conflicts with the ROW EXCLUSIVE lock that every INSERT takes, so the refresh waits until in-flight writer transactions have committed (their markers and readings are then visible to the next statement) and holds new inserts back until it commits; the mode is also self-exclusive, so two refreshes cannot overlap. The cost is that ingestion pauses for the length of each refresh, so keep refreshes short. If you only need to stop refreshes overlapping, a transaction-level advisory lock (pg_advisory_xact_lock) is enough, but it does not fix the first race. As a backstop, a periodic recompute of recently closed periods (for example the previous day) repairs any bucket that was missed; how far back to go is a judgement for your data.

Documented option, not verified here: refresh under a table lock (ran on PostgreSQL 18.6 in one session, where the lock has nothing to wait for; its effect under concurrent writers was not verified here)
BEGIN;
LOCK TABLE chiller_kw IN SHARE ROW EXCLUSIVE MODE;  -- waits for writers in flight, blocks new ones
CALL refresh_chiller_kw_1h();
COMMIT;

The same roll-up as a continuous aggregate

In TimescaleDB the invalidation log, the refresh procedure and the scheduler are built in. The aggregate must group by a time_bucket on the hypertable's time column, and functions in SELECT, GROUP BY and HAVING must be immutable. Creating it WITH NO DATA avoids materializing all history inside the CREATE; you then backfill with a manual refresh over a bounded range. The documentation advises not refreshing the latest incomplete bucket, which is what end_offset achieves in a policy. The definition of the hourly view itself is on the TimescaleDB page; the statements below show the operational side.

Illustrative configuration, not run here: adapted from the TimescaleDB documentation; check every setting against the documentation for your version before use. Backfill, refresh policy, real-time toggle and a daily tier
-- chiller_kw is a hypertable; the view was created WITH NO DATA
CALL refresh_continuous_aggregate('chiller_kw_hourly', '2026-01-01', '2026-10-27');

-- Illustrative values: refresh the last 3 days every 30 minutes, leaving the current hour alone.
-- Choose start_offset from your worst expected data delay and schedule_interval from the freshness you need.
SELECT add_continuous_aggregate_policy('chiller_kw_hourly',
    start_offset      => INTERVAL '3 days',
    end_offset        => INTERVAL '1 hour',
    schedule_interval => INTERVAL '30 minutes');

-- Include not-yet-materialized raw rows at query time (off by default since 2.13)
ALTER MATERIALIZED VIEW chiller_kw_hourly SET (timescaledb.materialized_only = false);

-- Daily tier on top of the hourly one: the bucket must be a multiple of 1 hour
CREATE MATERIALIZED VIEW chiller_kw_daily
WITH (timescaledb.continuous) AS
SELECT point_id,
       time_bucket(INTERVAL '1 day', bucket) AS day,
       sum(sum_kw) / sum(samples) AS avg_kw,   -- from sum and count, not avg(avg)
       max(max_kw) AS max_kw
FROM chiller_kw_hourly
GROUP BY point_id, day
WITH NO DATA;

Trade-offs and limitations

Licence note. Tiger Data's editions page says TimescaleDB Community Edition is "available under the terms of the Tiger Data License (TSL)", and its feature table lists continuous aggregates and their refresh policies for Community Edition only, not for the Apache 2 Edition. Tiger Data's licenses page names it the Timescale License and links the licence text. What the licence permits in your situation is not something this page decides: this is not legal advice; read the licence and take advice for your situation.

Two limitations catch people out. First, a continuous aggregate tracks changes to the hypertable only: if it joins to a standard PostgreSQL table such as a point register, edits to that table are not tracked and old buckets keep the old values. Second, insert-driven views in QuestDB and ClickHouse have the same property for joined tables, and ClickHouse targets that use merge-based engines may hold several rows per key until a background merge, so queries should aggregate again rather than assume one row per bucket.

Choosing a mechanism for sensor roll-ups
ConcernMaterialized viewContinuous aggregateRoll-up table, window recompute (recommended DIY)
Refresh costWhole source query each timeChanged buckets in the windowEvery bucket in the recent window each run, plus a periodic reconciliation
Late dataCaught at the next refresh, at full costCaught if inside the refresh windowCaught if the row commits while its bucket is inside the window; otherwise at the next reconciliation
Keeps history after raw data is droppedNo: a refresh removes itYes, if the window does not reach dropped dataYes, if neither the window nor the reconciliation range reaches dropped data
Query flexibilityAny SQL, including joins and window functionsRestricted joins; DISTINCT ON not allowed in the definitionAny SQL you write
Extra softwareNoneTimescaleDB Community Edition (see the licence note below)None
Operational burdenSchedule refreshes; watch durationWatch policy jobs and refresh lagOwn the procedure, the schedule, the window length and the reconciliation

When to use which, and when to use neither

  • Materialized view: small or slow-changing sources (a few hundred meters at 15-minute intervals), reports refreshed nightly, or complex SQL that the other mechanisms do not allow. Add a unique index so you can refresh CONCURRENTLY.
  • Continuous aggregate: you already run TimescaleDB Community Edition (check its licence terms for your use), sources are large, late data is bounded, and you want the refresh and scheduling handled for you.
  • Insert-driven views: the data already lives in QuestDB or ClickHouse and corrections are rare or written as new rows.
  • Roll-up table with a window recompute: plain PostgreSQL, the source has outgrown full refreshes, lateness is bounded, and you can own and monitor the job. Treat the trigger-based design on this page as a learning example, not a production pattern.
  • Neither: the query is already fast on raw data with the right index (see what drives query speed), the roll-up is needed once (run it as a report), or the numbers must be exact to the latest second, in which case query the raw table or enable real-time aggregation.

Operations and security

  • Privileges. In PostgreSQL 18, refreshing a materialized view needs the MAINTAIN privilege on it; give that to the job role, not to dashboard users.
  • Monitoring. For continuous aggregates, watch job failures and how far the materialized data lags behind the raw data. For materialized views, record refresh duration; a steadily growing duration is the signal to move to an incremental design.
  • Backfills. After loading historical data, refresh the affected range explicitly; with TimescaleDB, the documentation warns that refreshing very large ranges can affect performance and interact with retention.
  • Testing changes. Before replacing a full-refresh view with an incremental one, run both side by side and compare them, as the cross-check query above does.

Frequently asked questions

What is the difference between a continuous aggregate and a materialized view?

A PostgreSQL materialized view is recomputed in full when refreshed. A TimescaleDB continuous aggregate is stored as a hypertable and refreshed incrementally: only time buckets that changed, inside a configured refresh window, are recomputed, usually by a background policy.

Can PostgreSQL refresh a materialized view incrementally?

Not natively. REFRESH MATERIALIZED VIEW always recomputes the full query; CONCURRENTLY only avoids blocking readers. The approach recommended on this page is a summary table that each run recomputes for a recent window of buckets from committed data, with a periodic reconciliation for older buckets; or use an extension such as TimescaleDB. A trigger-and-invalidation design is shown only as an educational example, because it has a race with concurrent writers.

Why does my continuous aggregate not show the latest readings?

Because the policy's end_offset excludes recent buckets and the policy runs on a schedule, and real-time aggregation is off by default from TimescaleDB 2.13. Enable it with timescaledb.materialized_only = false or read recent raw data directly.

Can I build a daily roll-up from an hourly one?

Yes. TimescaleDB supports continuous aggregates on continuous aggregates when the new bucket is a multiple of the underlying one. Compute averages from stored sums and counts, not by averaging averages.

Do continuous aggregates keep data after raw readings are deleted?

Only if the refresh window does not cover the dropped range. If the policy refreshes a window that includes deleted raw data, the aggregated rows for that window are removed too.

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.