Mapping IoT Sensor Workloads to Hash Partition Keys
Device telemetry has a distinctive shape: enormous append-only ingest, queries that are either βone device over a windowβ or βall devices in a windowβ, and a retention policy measured in months for raw data and years for aggregates. This guide maps that shape onto a concrete partition layout and shows what each decision costs. It applies the framework from Use-Case Mapping for Partition Strategies inside Database Partitioning Fundamentals & Architecture.
Prerequisites
Step 1 β Characterise the workload before choosing anything
Four numbers decide the layout:
-- ingest rate and device cardinality over the last hour
SELECT count(*) AS readings,
count(DISTINCT device_id) AS devices,
round(count(*) / 3600.0) AS rows_per_second,
round(count(*)::numeric / count(DISTINCT device_id), 1) AS rows_per_device
FROM readings
WHERE reading_ts >= now() - interval '1 hour';
readings | devices | rows_per_second | rows_per_device
-----------+---------+-----------------+-----------------
4218804 | 118204 | 1172 | 35.7
-- which query shape dominates?
SELECT count(*) FILTER (WHERE query ~* 'device_id\s*=') AS per_device,
count(*) FILTER (WHERE query !~* 'device_id\s*=') AS cross_device
FROM pg_stat_statements WHERE query ILIKE '%from readings%';
Operational note: Rows per device per hour is the number that decides whether per-device queries need their own partition dimension. At 36 rows an hour, one deviceβs month is 26,000 rows β small enough that an index on a time-partitioned table would serve it.
DBA tip: Measure device cardinality growth as well as its current value. A fleet growing by 10,000 devices a month changes which layouts stay viable within a year.
Step 2 β Build the range-then-hash layout
CREATE TABLE readings (
device_id bigint NOT NULL,
reading_ts timestamptz NOT NULL,
metric text NOT NULL,
value double precision NOT NULL,
PRIMARY KEY (device_id, reading_ts, metric)
) PARTITION BY RANGE (reading_ts);
CREATE TABLE readings_2026_08 PARTITION OF readings
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01')
PARTITION BY HASH (device_id);
DO $$ BEGIN
FOR i IN 0..7 LOOP
EXECUTE format('CREATE TABLE readings_2026_08_h%s PARTITION OF readings_2026_08
FOR VALUES WITH (MODULUS 8, REMAINDER %s)', i, i);
END LOOP;
END $$;
Operational note: Putting device_id first in the primary key makes per-device time-series queries an index range scan rather than a filter, which is the shape most telemetry dashboards issue.
DBA tip: Skip a separate index on reading_ts alone. The partition bounds already answer time-range questions, and an extra index on the hottest column doubles write amplification for no read benefit.
Step 3 β Batch inserts so routing happens once
Row-at-a-time inserts pay routing overhead per row. Batching by bucket pays it once per batch:
# ingest.py β group by target bucket, then COPY each group
from collections import defaultdict
def bucket_of(device_id: int, modulus: int = 8) -> int:
return hash_int64(device_id) % modulus # must match the server's hash
def ingest(readings, conn, month: str):
groups = defaultdict(list)
for r in readings:
groups[bucket_of(r.device_id)].append(r)
with conn.cursor() as cur:
for bucket, rows in groups.items():
cur.copy_records_to_table(
table_name=f"readings_{month}_h{bucket}",
records=[(r.device_id, r.ts, r.metric, r.value) for r in rows],
columns=("device_id", "reading_ts", "metric", "value"),
)
Operational note: Writing directly to the leaf is an optimisation with a cost: the application now knows the layout. Keep a fallback path that writes to the parent, and use it whenever the layout is mid-change.
SRE tip: COPY is several times faster than multi-row INSERT for this shape. If the ingest path is on the edge of keeping up, that change usually buys more than any partitioning adjustment.
Step 4 β Downsample so retention can be short
Raw readings are needed for days; trends are needed for years:
CREATE TABLE readings_hourly (
device_id bigint NOT NULL,
bucket_ts timestamptz NOT NULL,
metric text NOT NULL,
n int NOT NULL,
avg_value double precision NOT NULL,
min_value double precision NOT NULL,
max_value double precision NOT NULL,
PRIMARY KEY (device_id, bucket_ts, metric)
) PARTITION BY RANGE (bucket_ts);
-- run hourly, one bucket behind the current hour
INSERT INTO readings_hourly (device_id, bucket_ts, metric, n, avg_value, min_value, max_value)
SELECT device_id, date_trunc('hour', reading_ts), metric,
count(*), avg(value), min(value), max(value)
FROM readings
WHERE reading_ts >= date_trunc('hour', now()) - interval '1 hour'
AND reading_ts < date_trunc('hour', now())
GROUP BY 1, 2, 3
ON CONFLICT (device_id, bucket_ts, metric) DO NOTHING;
Operational note: Aggregate one full bucket behind the current hour, never the current one. Late-arriving readings for the in-progress hour would otherwise be silently excluded from a row already written.
DBA tip: ON CONFLICT DO NOTHING makes the aggregation job safe to re-run, which matters because it will be re-run β after a deploy, after a failure, and during a backfill.
Verification
Confirm the spread and the query shapes:
-- writes should be even across buckets
SELECT relname, n_tup_ins
FROM pg_stat_user_tables
WHERE relname LIKE 'readings_2026_08_h%'
ORDER BY relname;
relname | n_tup_ins
-----------------------+-----------
readings_2026_08_h0 | 52841002
readings_2026_08_h1 | 52903118
readings_2026_08_h2 | 52788410
readings_2026_08_h3 | 52950227
Within a percent or two across buckets, and a per-device query planning to a single leaf:
EXPLAIN (COSTS OFF) SELECT reading_ts, value FROM readings
WHERE device_id = 8842 AND reading_ts >= '2026-08-01' AND reading_ts < '2026-08-02';
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Insert latency spikes at month boundaries | the new monthβs sub-partitions did not exist yet, so rows landed in DEFAULT or failed |
pre-create months with their full hash set; alert on days-to-newest-bound as in the retention guide |
| One hash bucket is much larger than the others | a single device emits far more readings than the rest, or device ids are not uniformly distributed | give the dominant device its own list partition; verify the id distribution rather than assuming uniformity |
| Cross-device aggregates get slower every month | they scan every bucket of every month in range, and the bucket count multiplied the leaf count | serve them from readings_hourly instead of raw readings; keep raw scans to short windows |
FAQ
Should IoT telemetry be partitioned by time or by device?
By time first if retention matters, with a hash of the device id underneath if per-device queries or write contention matter. Time alone gives cheap retention and a contended index right edge; device alone gives even writes and makes retention a row-by-row delete. The two-level layout is the usual answer for telemetry because both properties are needed.
How many hash buckets should the second level have?
Enough to spread index contention across cores, and no more. Eight to sixteen is typical: it removes the single hot btree right edge without multiplying the leaf count unnecessarily. Measure buffer lock waits before and after β if they have not fallen, the contention was somewhere else and more buckets will not help.
Does downsampling replace retention?
It complements it. Downsampling writes a coarser aggregate into a separate table so the raw partitions can be dropped earlier without losing the trend. Retention still drops the raw data on its schedule; downsampling is what makes dropping it acceptable to the people who would otherwise insist on keeping everything.
Related
- Use-Case Mapping for Partition Strategies β the parent topic mapping workload archetypes to layouts
- Composite Partition Keys: Tenant and Time β the same two-level pattern with a tenant dimension
- Range vs Hash Partitioning for Time-Series Workloads β the decision this guide applies to a specific workload