- Store two timestamps: event time (when the sensor measured) as the series key, and ingest time (when the database received it). Lateness is the difference, and you cannot measure or manage it without both.
- A reading is out of order when its event time is earlier than one already received for the same series; it is late when it arrives after the window it belongs to has already been processed. The two usually coincide, and both are normal in IoT pipelines.
- Decide a grace period per data type. Rows inside it are inserted and picked up by the next roll-up run; rows beyond it go to a quarantine table for review rather than silently changing closed reports.
- Recompute a bounded window of recent buckets from committed data on every run, sized from the grace period, and reconcile older periods on a slower schedule. In the run on this page, a 30-minute backfill committed after one run was included by the next, restoring 15 samples per bucket. A dirty-bucket list is shown only as an educational example, because it is not safe with concurrent ingestion as shown.
- According to the documentation of the engines covered here, late rows cost more than in-order rows: they reduce physical time order (BRIN), land in older partitions, force QuestDB to rewrite parts of partitions, and make InfluxDB un-compact cold shards.
Scope and definitions
This page is for engineers who own an ingestion pipeline for building, energy or industrial sensor data and need roll-ups, alarms and reports to stay correct when data arrives late. It covers the timing problem only. Removing duplicate deliveries is covered in handling duplicate telemetry events, and how roll-ups are refreshed in general in continuous aggregates vs materialized views.
The terms are used loosely across products, so these are this page's working definitions; where a vendor defines a term, the vendor's wording is noted.
- Event time: when the measurement happened at the source. Kafka Streams' documentation uses the same term, with a GPS sensor as its example.
- Ingest time (Kafka Streams also distinguishes processing time): when the pipeline or database received the record.
- Out of order: the event time is earlier than one already received for the same series. QuestDB defines it against the latest timestamp already committed to the table.
- Late: the record arrives after the window it belongs to has been aggregated, reported or closed. The two usually coincide, but not always: a reading a few seconds out of order can still arrive before its window is processed, and a point with no other recent readings can be late without being out of order.
Common causes in building and IoT systems: gateways that store and forward after a network outage, MQTT clients retrying after reconnecting, Kafka consumers replaying from an earlier offset, device clocks that drift, data loggers and utility meter files uploaded in batches, and manual corrections. QuestDB's documentation lists most of these, including sensors that buffer readings and flush them on reconnect.
Data model: record event time and ingest time
Key the readings table on event time, because queries, roll-ups and alarms are about when things happened. Add received_at as ingest time. Alongside the roll-up table, late_rejects holds rows that arrived beyond the grace period, and rollup_dirty lists buckets that need recalculating; rollup_dirty is used only by the educational dirty-bucket example further down, and the recommended window recompute does not need it.
SET TIME ZONE 'UTC';
CREATE TABLE reading (
point_id integer NOT NULL,
ts timestamptz NOT NULL, -- event time: when the sensor measured
value double precision,
received_at timestamptz NOT NULL, -- ingest time: when the database got it
PRIMARY KEY (point_id, ts)
);
CREATE TABLE reading_15m (
point_id integer NOT NULL,
bucket timestamptz NOT NULL,
avg_value double precision,
samples integer NOT NULL,
PRIMARY KEY (point_id, bucket)
);
CREATE TABLE rollup_dirty (
point_id integer NOT NULL,
bucket timestamptz NOT NULL,
PRIMARY KEY (point_id, bucket)
);
CREATE TABLE late_rejects (
point_id integer NOT NULL,
ts timestamptz NOT NULL,
value double precision,
received_at timestamptz NOT NULL,
reason text NOT NULL
);
-- On-time data: points 1-3, one reading a minute 08:00-09:59, received 5 s after measurement,
-- except point 1 from 08:20 to 08:49 (its gateway lost the uplink)
INSERT INTO reading (point_id, ts, value, received_at)
SELECT p, g.ts, 20 + p + extract(minute FROM g.ts) / 100, g.ts + interval '5 seconds'
FROM generate_series(1, 3) AS p
CROSS JOIN generate_series(timestamptz '2026-10-27 08:00+00', timestamptz '2026-10-27 09:59+00', interval '1 minute') AS g(ts)
WHERE NOT (p = 1 AND g.ts >= '2026-10-27 08:20+00' AND g.ts < '2026-10-27 08:50+00')
ORDER BY g.ts, p;
-- Roll-up as of 10:00
INSERT INTO reading_15m
SELECT point_id, date_bin(interval '15 minutes', ts, timestamptz '2000-01-01 00:00+00'), avg(value), count(*)
FROM reading GROUP BY 1, 2;
SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket; bucket | samples
------------------------+---------
2026-10-27 08:15:00+00 | 5
2026-10-27 08:45:00+00 | 10Apply a grace period on ingest
At 10:05 the gateway reconnects and flushes its 30 buffered readings for point 1, and a replay also delivers a reading for point 2 measured at 05:10. The ingestion statement below applies a grace period in one statement; the 4 hours used here is an illustrative assumption for the example, not a recommended value (the factors for choosing yours follow the table): rows older than that go to late_rejects, the rest are inserted (duplicates ignored), and the buckets of the rows actually inserted are recorded in rollup_dirty (for the educational example only; with the recommended window recompute, drop the last step). It relies on two documented PostgreSQL behaviours: data-modifying statements in WITH always run to completion, and RETURNING after ON CONFLICT DO NOTHING returns only the rows that were inserted.
WITH incoming (point_id, ts, value, received_at) AS (
SELECT 1, g.ts, 21 + extract(minute FROM g.ts) / 100, timestamptz '2026-10-27 10:05+00'
FROM generate_series(timestamptz '2026-10-27 08:20+00', timestamptz '2026-10-27 08:49+00', interval '1 minute') AS g(ts)
UNION ALL
SELECT 2, timestamptz '2026-10-27 05:10+00', 22.1, timestamptz '2026-10-27 10:05+00'
),
rejected AS (
INSERT INTO late_rejects
SELECT point_id, ts, value, received_at, 'older than 4 h grace period'
FROM incoming WHERE received_at - ts > interval '4 hours'
RETURNING 1
),
inserted AS (
INSERT INTO reading
SELECT * FROM incoming WHERE received_at - ts <= interval '4 hours'
ON CONFLICT (point_id, ts) DO NOTHING
RETURNING point_id, ts
)
INSERT INTO rollup_dirty
SELECT DISTINCT point_id, date_bin(interval '15 minutes', ts, timestamptz '2000-01-01 00:00+00')
FROM inserted
ON CONFLICT DO NOTHING;
SELECT point_id, bucket::text FROM rollup_dirty ORDER BY 1, 2;
SELECT point_id, ts::text, (received_at - ts)::text AS lateness, reason FROM late_rejects; point_id | bucket
----------+------------------------
1 | 2026-10-27 08:15:00+00
1 | 2026-10-27 08:30:00+00
1 | 2026-10-27 08:45:00+00
point_id | ts | lateness | reason
----------+------------------------+----------+-----------------------------
2 | 2026-10-27 05:10:00+00 | 04:55:00 | older than 4 h grace period| Data | Illustrative grace period | Beyond the grace period |
|---|---|---|
| BMS trend points for dashboards | A few hours to a day | Quarantine; reload on request |
| Energy meter data used for billing or carbon reporting | Until the reporting period closes, often days | Accept as a restatement with an audit record |
| Alarms and events | No grace period: accept late events | Store with both timestamps; do not drop |
| Device health (battery, signal) | Minutes | Drop or quarantine; only the latest value matters |
Choosing your own grace period
No official source gives a universal value. Base yours on: the lateness you actually observe (the p99 and maximum from the queries in the next section, measured over weeks that include outages); how long gateways can buffer and how long the longest plausible outage lasts; when reports are sent or closed and whether they may be restated; whether the data feeds billing, carbon or contractual figures; how long raw data is kept (a grace period cannot be longer than raw retention); and what reprocessing a late window costs on your system.
Why quarantine instead of dropping
A reading beyond the grace period is usually still correct data. Dropping it hides a pipeline problem; inserting it silently changes reports that may already have been sent. A quarantine table makes the decision visible, and a periodic job can load approved rows and, once that load has committed, recompute the roll-up over their time range. Stream processors make the same trade-off: Kafka Streams discards a record that arrives after a window's grace period has passed.
Measure lateness and out-of-order rows
With both timestamps stored, lateness and out-of-order rates are ordinary queries. Track them per gateway or point group; a change from your normal baseline is the signal to investigate.
SELECT point_id,
count(*) AS readings,
count(*) FILTER (WHERE received_at - ts > interval '1 minute') AS late_rows,
max(received_at - ts)::text AS max_lateness,
(percentile_cont(0.99) WITHIN GROUP (ORDER BY extract(epoch FROM received_at - ts)))::int AS p99_lateness_s
FROM reading GROUP BY point_id ORDER BY point_id;
SELECT point_id, count(*) AS out_of_order_rows
FROM (
SELECT point_id, ts,
max(ts) OVER (PARTITION BY point_id ORDER BY received_at, ts
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS max_ts_before
FROM reading
) s
WHERE ts < max_ts_before
GROUP BY point_id ORDER BY point_id; point_id | readings | late_rows | max_lateness | p99_lateness_s
----------+----------+-----------+--------------+----------------
1 | 120 | 30 | 01:45:00 | 6229
2 | 120 | 0 | 00:00:05 | 5
3 | 120 | 0 | 00:00:05 | 5
point_id | out_of_order_rows
----------+-------------------
1 | 30Order by ingest time, then event time
The out-of-order query walks each point's rows in arrival order and compares each event time with the latest one seen before it. Rows that arrive in one batch share a received_at, so event time breaks the tie. If your ingestion service writes batches with a single received_at, this is exactly the behaviour you want; if it stamps rows individually, the result is the same.
Keep roll-ups correct: recompute a recent window
The recommended job does not track which buckets changed. On every run it recomputes every 15-minute bucket inside a bounded recent window from the raw rows committed at that moment, with an idempotent upsert, and a slower reconciliation run does the same for older closed periods. Its correctness rests only on documented PostgreSQL behaviour: under the default READ COMMITTED isolation a query sees only data committed before it began, so a late batch committed after one run is seen by every later run; and INSERT ... ON CONFLICT DO UPDATE gives an atomic insert-or-update outcome even under concurrency. A late row is therefore included by the first run after its commit, as long as its bucket is still inside the window.
Size the window from the grace period, because rows older than the grace period never reach reading: window = grace period + a margin that covers the longest ingest transaction (a row is visible only when it commits) + the interval between runs. Here that is 4 hours plus an illustrative 1-hour margin. The script below, which continues from the tables and on-time data of the first script, was run in a single session on PostgreSQL 18.6 (PGlite); concurrent sessions cannot be run here, so the timeline is simulated with explicit run times.
SET TIME ZONE 'UTC';
-- reading and reading_15m as defined above, with the same on-time data loaded
-- Recompute every 15-minute bucket from p_from (rounded down) onwards from committed rows
CREATE PROCEDURE recompute_reading_15m(p_from timestamptz, p_to timestamptz DEFAULT 'infinity')
LANGUAGE sql AS $$
INSERT INTO reading_15m (point_id, bucket, avg_value, samples)
SELECT point_id, date_bin(interval '15 minutes', ts, timestamptz '2000-01-01 00:00+00'),
avg(value), count(*)
FROM reading
WHERE ts >= date_bin(interval '15 minutes', p_from, timestamptz '2000-01-01 00:00+00')
AND ts < p_to
GROUP BY 1, 2
ON CONFLICT (point_id, bucket) DO UPDATE
SET avg_value = EXCLUDED.avg_value, samples = EXCLUDED.samples;
-- buckets in the range whose raw rows have all been deleted
DELETE FROM reading_15m r
WHERE r.bucket >= date_bin(interval '15 minutes', p_from, timestamptz '2000-01-01 00:00+00')
AND r.bucket < p_to
AND NOT EXISTS (SELECT 1 FROM reading x
WHERE x.point_id = r.point_id
AND x.ts >= r.bucket AND x.ts < r.bucket + interval '15 minutes');
$$;
-- Run at 10:00. Illustrative window: the 4 h grace period plus a 1 h margin
-- (in a real job: CALL recompute_reading_15m(now() - interval '5 hours');)
CALL recompute_reading_15m(timestamptz '2026-10-27 10:00+00' - interval '5 hours');
SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket;
-- 10:05: the gateway's buffered readings commit after that run (grace-period filter as above)
INSERT INTO reading
SELECT 1, g.ts, 21 + extract(minute FROM g.ts) / 100, timestamptz '2026-10-27 10:05+00'
FROM generate_series(timestamptz '2026-10-27 08:20+00', timestamptz '2026-10-27 08:49+00', interval '1 minute') AS g(ts)
WHERE timestamptz '2026-10-27 10:05+00' - g.ts <= interval '4 hours'
ON CONFLICT (point_id, ts) DO NOTHING;
-- Next run at 10:15: the 08:15 to 08:45 buckets are still inside the window
CALL recompute_reading_15m(timestamptz '2026-10-27 10:15+00' - interval '5 hours');
SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket;
SELECT count(*) AS buckets, sum(samples) AS samples FROM reading_15m;-- after the 10:00 run
bucket | samples
------------------------+---------
2026-10-27 08:15:00+00 | 5
2026-10-27 08:45:00+00 | 10
-- after the 10:15 run
bucket | samples
------------------------+---------
2026-10-27 08:15:00+00 | 15
2026-10-27 08:30:00+00 | 15
2026-10-27 08:45:00+00 | 15
buckets | samples
---------+---------
24 | 360Limits of the window recompute
- Rows later than the window are not picked up by the regular runs. Quarantined rows released by an operator are the usual case: after the release transaction has committed, run
CALL recompute_reading_15m(...)over the released range, and schedule a reconciliation over recently closed periods (for example the previous day) as a backstop. Keep reconciliation clear of raw data removed by retention, because theDELETEstep removes roll-up rows whose raw rows are gone. - Cost grows with the window length times the data rate, not with the number of late rows, so a long grace period makes every run more expensive. That is the trade against the dirty-bucket design below, which is cheaper per run but not safe with concurrent ingestion as shown.
- Overlapping runs do not break correctness, but waste work, and the run that commits last writes its own snapshot's figures until the next run; let the scheduler run one at a time.
- Freshness: figures are as of the last run.
Other documented routes: a materialized view refreshed with REFRESH MATERIALIZED VIEW CONCURRENTLY recomputes everything from committed data, and TimescaleDB continuous aggregates handle invalidation internally (see continuous aggregates vs materialized views).
Educational example: refresh only the dirty buckets
Educational example: shows the idea in a single session; not safe for production as shown, because of the race described below. This job joins the dirty list from the grace-period ingest to the raw readings, upserts the recalculated buckets and clears the list, in one transaction so that a failure leaves the list intact. Its cost depends on the number of dirty buckets, not on the size of the table. The table lock in the second line is one of the documented options discussed below, not verified here; the run was a single session.
BEGIN;
LOCK TABLE reading IN SHARE ROW EXCLUSIVE MODE; -- see the concurrency caveat below
INSERT INTO reading_15m
SELECT r.point_id, d.bucket, avg(r.value), count(*)
FROM rollup_dirty d
JOIN reading r ON r.point_id = d.point_id
AND r.ts >= d.bucket AND r.ts < d.bucket + interval '15 minutes'
GROUP BY r.point_id, d.bucket
ON CONFLICT (point_id, bucket) DO UPDATE
SET avg_value = EXCLUDED.avg_value, samples = EXCLUDED.samples;
DELETE FROM rollup_dirty;
COMMIT;
SELECT bucket::text, samples FROM reading_15m
WHERE point_id = 1 AND bucket >= '2026-10-27 08:15+00' AND bucket < '2026-10-27 09:00+00' ORDER BY bucket; bucket | samples
------------------------+---------
2026-10-27 08:15:00+00 | 15
2026-10-27 08:30:00+00 | 15
2026-10-27 08:45:00+00 | 15Concurrency caveat for the educational example (not verified here)
Every statement on this page was run in a single session, so nothing here demonstrates behaviour under concurrent ingestion. The following is an analysis under PostgreSQL's default READ COMMITTED isolation, where each statement sees only data committed before it began. Without the LOCK TABLE line, two races would apply:
- Markers lost between the two statements. The
INSERTand theDELETEtake separate snapshots. A marker for a new bucket committed by an ingest after theINSERTstarted is not recalculated, but theDELETEsees it and removes it, so that bucket is never refreshed until another late row touches it. - A marker that already existed. An ingest whose bucket is already marked inserts nothing into
rollup_dirty(ON CONFLICT DO NOTHING). If the refresh runs before that ingest commits, it recalculates the bucket without the uncommitted rows and then deletes the marker. Deleting only the rows the refresh read (for example withDELETE ... RETURNINGfeeding the recalculation in one statement) fixes the first race but not this one.
Documented options, not verified here. The recommended path remains the window recompute above, whose correctness does not depend on locking that could not be exercised here. LOCK TABLE reading IN SHARE ROW EXCLUSIVE MODE conflicts with the ROW EXCLUSIVE lock that the ingest statement takes on reading, so the refresh waits for in-flight ingests to commit and blocks new ones until it commits; the mode is self-exclusive, so two refreshes cannot overlap either. This only works if everything that marks buckets dirty also writes to reading in the same transaction (the ingest above does). The price is that ingestion pauses while the refresh runs, so keep the dirty list small and the refresh frequent. Another option is to run ingest and refresh from one scheduler so they never overlap. TimescaleDB's continuous aggregates handle the same problem internally with an invalidation log: when data older than the invalidation threshold changes, each transaction logs the range it modified and the affected buckets are recalculated on the next refresh.
What late data does to storage
In-order data is appended to the newest partition, in time order. Late data is not, and each engine pays for that differently.
PostgreSQL: physical order and BRIN
A BRIN index works when a column correlates with physical position in the table, and pg_stats.correlation reports that correlation from -1 to +1. In the same run, the on-time load gave a correlation of 1.000 for ts; after the 30 late rows were appended it fell to 0.752. These 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. On a large table a sustained late-data share widens BRIN block ranges and makes the index less selective, so check the statistic and consider the minmax-multi operator class or a B-tree if it stays low. Late rows also need their partition to exist: do not drop or detach a partition while data for it can still arrive.
ANALYZE reading;
SELECT attname, round(correlation::numeric, 3) AS correlation FROM pg_stats
WHERE tablename = 'reading' AND attname = 'ts';
-- after the on-time load: ts | 1.000
-- after the late batch: ts | 0.752Engine behaviour from the documentation
- QuestDB accepts rows in any timestamp order and merges them into position. The cost is write amplification, which its documentation says scales with partition size and how far behind the data arrives; it may split a partition rather than rewrite it whole. Its tuning advice is to fix accidental out-of-order sources first, then consider smaller partitions and deduplication for replays.
- InfluxDB OSS v2 writes to the newest shard group normally; when backfilling it writes to older shards that must first be un-compacted, and re-compacts them afterwards.
- TimescaleDB: the rowstore is designed for upserts and late-arriving rows, and columnstore chunks accept inserts without a manual decompress step. Continuous aggregates pick up changes below the invalidation threshold through the invalidation log, and a refresh policy only re-materializes buckets inside its window.
- Kafka Streams: windowed aggregations wait for out-of-order records up to a configurable grace period and discard records that arrive after it.
The partition interval decides how much each late batch rewrites; see choosing time-series partition intervals.
Cross-database note: the same refresh in MySQL
MySQL has no date_bin or ON CONFLICT; bucket with UNIX_TIMESTAMP arithmetic and upsert with ON DUPLICATE KEY UPDATE. Referring to the new values with VALUES() is deprecated from MySQL 8.0.20, so the statement below selects from a derived table and refers to its alias. INSERT IGNORE is used for the duplicate here to keep the example short; it also turns some other errors into warnings, so prefer an explicit upsert in production.
Educational example: shows the idea in a single session; not safe for production as shown, because of the races described in the PostgreSQL section above. The script translates the dirty-bucket refresh and was run in a single session. Its concurrent behaviour differs from PostgreSQL's and was not verified here: InnoDB's default isolation level is REPEATABLE READ, and under it INSERT ... SELECT sets shared next-key locks on the rows it reads. Do not assume the race analysis above carries over either way. The window recompute idea (recompute every bucket in a recent window from the raw table on every run, plus a reconciliation) can be written in MySQL with the same ON DUPLICATE KEY UPDATE upsert, but its locking under InnoDB was not analysed or run here, so check it on your own server; serialising the job against ingestion (for example with LOCK TABLES ... WRITE, or one scheduler for both) remains a documented option, not verified here.
SET time_zone = '+00:00';
CREATE TABLE reading (
point_id INT NOT NULL,
ts DATETIME(3) NOT NULL,
value DOUBLE,
received_at DATETIME(3) NOT NULL,
PRIMARY KEY (point_id, ts)
);
CREATE TABLE reading_15m (
point_id INT NOT NULL,
bucket DATETIME NOT NULL,
avg_value DOUBLE,
samples INT NOT NULL,
PRIMARY KEY (point_id, bucket)
);
CREATE TABLE rollup_dirty (
point_id INT NOT NULL,
bucket DATETIME NOT NULL,
PRIMARY KEY (point_id, bucket)
);
INSERT INTO reading VALUES
(1, '2026-10-27 08:15:00', 21.15, '2026-10-27 08:15:05'),
(1, '2026-10-27 08:16:00', 21.16, '2026-10-27 08:16:05');
INSERT INTO reading_15m VALUES (1, '2026-10-27 08:15:00', 21.155, 2);
-- Late batch: two readings for the same bucket arrive at 10:05; one is a duplicate
INSERT IGNORE INTO reading VALUES
(1, '2026-10-27 08:16:00', 21.16, '2026-10-27 10:05:00'),
(1, '2026-10-27 08:20:00', 21.20, '2026-10-27 10:05:00');
-- 1 row affected; Warning 1062: Duplicate entry '1-2026-10-27 08:16:00.000' for key 'reading.PRIMARY'
INSERT IGNORE INTO rollup_dirty
SELECT DISTINCT point_id, FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(ts) / 900) * 900)
FROM reading WHERE received_at = '2026-10-27 10:05:00';
INSERT INTO reading_15m (point_id, bucket, avg_value, samples)
SELECT * FROM (
SELECT r.point_id, d.bucket, AVG(r.value) AS avg_value, COUNT(*) AS samples
FROM rollup_dirty d
JOIN reading r ON r.point_id = d.point_id
AND r.ts >= d.bucket AND r.ts < d.bucket + INTERVAL 15 MINUTE
GROUP BY r.point_id, d.bucket
) AS s
ON DUPLICATE KEY UPDATE avg_value = s.avg_value, samples = s.samples;
SELECT point_id, bucket, ROUND(avg_value, 3) AS avg_value, samples FROM reading_15m;
-- point_id | bucket | avg_value | samples
-- 1 | 2026-10-27 08:15:00 | 21.17 | 3Worked example: a BMS gateway outage
A plant-room gateway loses its uplink for 30 minutes during a chiller trip, buffers its readings and reconnects. What each layer should do:
- Gateway: buffer locally with the original measurement timestamps, and send them oldest first on reconnect.
- MQTT broker: delivers what it receives; with QoS 1 a retry can deliver a reading twice. It is not a long-term buffer.
- Kafka (if used): keeps the events for its retention period, so the consumer can be rewound if the database was also down; a rewind replays rows already stored, which the primary key absorbs.
- Ingestion service: stamps
received_at, applies the grace period and inserts withON CONFLICT DO NOTHING, as above. - Roll-up job: its next run recomputes the recent window, which still contains the outage period, so the chiller's 15-minute energy figures for the outage become complete.
- Alarm and CMMS rules: evaluate on event time, and treat a burst of late readings as history rather than new alarms, or a 30-minute-old high-temperature reading will raise a work order after the chiller has already restarted.
Trade-offs, operations and when not to use this pattern
- Latency against correctness. A longer grace period keeps reports correct for longer but delays the point at which they are final. Kafka Streams' documentation describes the same trade-off between latency, cost and correctness.
- Ingest time as the main timestamp. QuestDB suggests that when most data arrives far behind its event time, making ingestion time the designated timestamp turns the workload into appends, at the cost of event-time queries reading more partitions. Our editorial view: it is rarely worth it for building data, where reports are about event time.
- Restated reports. If closed reports can change, keep a restatement log so you can show what changed and why.
- Monitoring: track p99 lateness, the share of out-of-order rows, quarantined rows per gateway, and the duration of each roll-up run. A sudden change usually means a clock, network or consumer problem, not a change in the building.
- Security: the ingestion role needs insert on readings, the dirty list and the quarantine table; only an operator role should be able to release quarantined rows.
- When not to bother: if nothing is aggregated or reported until the next day, a nightly roll-up of the previous day absorbs most late data without any of this machinery.
Frequently asked questions
What is out-of-order time series data?
Readings whose event time is earlier than a reading already received for the same series. It is common when gateways buffer data during outages, when consumers replay a stream, or when device clocks drift.
What is the difference between late and out-of-order data?
Out of order is about sequence: the reading arrived after a newer one. Late is about processing: it arrived after its time window was already aggregated or reported. They usually go together, but a reading can be slightly out of order without being late, and the other way round.
Should I store the time a reading was received?
Yes. Keep event time as the key and add the ingest time as a column. Without it you cannot measure lateness, apply a grace period or explain why a report changed.
How do I update roll-ups when late data arrives?
The approach recommended on this page is to recompute every bucket in a bounded recent window (sized from your grace period) from committed data on every run with an upsert, and to reconcile older periods on a slower schedule. Recording only the buckets late rows touched is cheaper but, as shown here, races with concurrent ingestion, so it is kept as an educational example. TimescaleDB continuous aggregates track changes automatically through an invalidation log, within the refresh window of their policy.
Does late data slow down a time-series database?
Usually somewhat. It is written into older partitions, reduces physical time ordering, and in some engines forces partitions to be rewritten or shards to be un-compacted. Vendors document the mechanisms; measure the effect on your own workload.
Sources
- Apache Kafka documentation: Kafka Streams core concepts
- QuestDB documentation: Out-of-order data
- InfluxDB OSS v2 documentation: InfluxDB shards and shard groups
- Tiger Data documentation: Continuous aggregates
- Tiger Data documentation: Understand hypercore
- PostgreSQL 18 documentation: WITH Queries
- PostgreSQL 18 documentation: INSERT
- PostgreSQL 18 documentation: BRIN Indexes
- PostgreSQL 18 documentation: pg_stats
- MySQL 8.0 Reference Manual: INSERT ... ON DUPLICATE KEY UPDATE
- OASIS MQTT Version 5.0 specification
- Apache Kafka: Introduction
- PostgreSQL 18 documentation: Transaction Isolation
- PostgreSQL 18 documentation: Explicit Locking
- MySQL 8.0 Reference Manual: Transaction Isolation Levels
- MySQL 8.0 Reference Manual: Locks Set by Different SQL Statements in InnoDB
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.