Skip to main content

Range vs Hash Partitioning for Time-Series Workloads

This walkthrough gives you a decision framework for choosing between time-based RANGE partitions and HASH partitions for a time-series table — a core layout choice within range partitioning strategies and the broader partitioning implementation patterns & routing guide. The short version: RANGE on the timestamp wins for almost every genuinely time-series workload because it buys partition pruning on time-window queries and constant-time retention, while HASH buys uniform write spread at the cost of both. The rest of this page shows how to verify that claim against your workload rather than take it on faith, and when the composite range-then-hash escape hatch is the right answer.

What you need to know first

Collect these facts about your workload before walking the steps — every decision below keys off one of them:


Range vs hash layout under a time-series workload Left panel: with RANGE partitioning by timestamp, all writes and recent-window reads land on the newest partition, older partitions sit idle, and retention is a DROP of the oldest partition. Right panel: with HASH partitioning by device id, writes spread evenly across all partitions but time-window reads must scan every partition, and retention requires DELETE statements. RANGE (reading_ts) Reads last 1 h Writes now() p_2026_05 cold p_2026_06 cold p_2026_07 HOT pruned: 1 of 3 all writes retention: DROP oldest HASH (device_id) Reads last 1 h Writes now() h0 warm h1 warm h2 warm fan-out: 3 of 3 even spread retention: DELETE rows

Step 1 — Profile your query shapes with EXPLAIN

The layouts are mirror images: RANGE prunes on time predicates, HASH prunes on key predicates. Build both layouts on a staging copy and compare plans for your top queries by frequency. The DDL for a 200-device sensor fleet ingesting one reading per device per second (~17M rows/day):

-- Layout A: RANGE by day
CREATE TABLE readings_range (
  device_id  BIGINT           NOT NULL,
  reading_ts TIMESTAMPTZ      NOT NULL,
  value      DOUBLE PRECISION NOT NULL,
  PRIMARY KEY (device_id, reading_ts)
) PARTITION BY RANGE (reading_ts);

CREATE TABLE readings_range_2026_07_05 PARTITION OF readings_range
  FOR VALUES FROM ('2026-07-05') TO ('2026-07-06');
CREATE TABLE readings_range_2026_07_06 PARTITION OF readings_range
  FOR VALUES FROM ('2026-07-06') TO ('2026-07-07');

-- Layout B: HASH by device, 8 partitions
CREATE TABLE readings_hash (
  device_id  BIGINT           NOT NULL,
  reading_ts TIMESTAMPTZ      NOT NULL,
  value      DOUBLE PRECISION NOT NULL,
  PRIMARY KEY (device_id, reading_ts)
) PARTITION BY HASH (device_id);

CREATE TABLE readings_hash_0 PARTITION OF readings_hash
  FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- ... repeat for REMAINDER 1 through 7

A dashboard query for the last hour prunes cleanly on the range layout:

EXPLAIN (COSTS OFF)
SELECT date_trunc('minute', reading_ts) AS m, avg(value)
FROM readings_range
WHERE reading_ts >= now() - interval '1 hour'
GROUP BY m;
 HashAggregate
   Group Key: date_trunc('minute', readings_range.reading_ts)
   ->  Seq Scan on readings_range_2026_07_06 readings_range
         Filter: (reading_ts >= (now() - '01:00:00'::interval))

One partition scanned out of however many exist — pruning happens at execution time even with the volatile now() predicate (“subplans removed” appears under EXPLAIN ANALYZE). The same query on the hash layout appends all eight partitions, because device_id hashes carry no time information:

 HashAggregate
   Group Key: date_trunc('minute', readings_hash.reading_ts)
   ->  Append
         ->  Seq Scan on readings_hash_0 readings_hash_1
               Filter: (reading_ts >= (now() - '01:00:00'::interval))
         ->  Seq Scan on readings_hash_1 readings_hash_2
               Filter: (reading_ts >= (now() - '01:00:00'::interval))
         ... 6 more partitions ...

Reverse the predicate and the tables turn: WHERE device_id = 4217 prunes to one partition on the hash layout but scans every daily partition on the range layout (each pruned further by its index). On a 90-day retention window that is 90 index probes vs 1.

Operational note: Run the comparison with EXPLAIN (ANALYZE, BUFFERS) and record shared read counts, not just plan shape. A fanned-out Append over 90 partitions whose indexes are cold can cost hundreds of random reads for a single-device query; that number is what your latency SLO feels.

DBA tip: Score each of your top ~10 queries as time-shaped, key-shaped, or both, weighted by calls/minute from pg_stat_statements. Most telemetry workloads come out 80–95% time-shaped, which is why the recommendation below defaults to RANGE. If your split is genuinely 50/50, jump ahead to the composite layout in Step 4.

Step 2 — Model retention and archival mechanics

Retention is where the two layouts stop being symmetrical and HASH starts losing. Time-series data almost always expires by age, and a range layout makes expiry a metadata operation:

-- Range layout: constant-time, non-blocking retention
ALTER TABLE readings_range DETACH PARTITION readings_range_2026_04_06 CONCURRENTLY;
DROP TABLE readings_range_2026_04_06;   -- or archive it first

DETACH ... CONCURRENTLY (PostgreSQL 14+) takes only a brief SHARE UPDATE EXCLUSIVE lock, so ingest never pauses. The freed space returns to the filesystem instantly, and the detached table can be dumped to object storage before the DROP. On the hash layout there is no partition whose contents are “the oldest day” — expiry means bulk DELETE on every partition:

-- Hash layout: retention is a rolling DELETE on all partitions
DELETE FROM readings_hash
WHERE reading_ts < now() - interval '90 days'
LIMIT 50000;  -- batched via a loop; plain DELETE has no LIMIT in PostgreSQL,
              -- so production jobs use ctid-batched or key-batched deletes

Every deleted row is a dead tuple that autovacuum must find and reclaim, the space does not return to the OS without VACUUM FULL or pg_repack, and the delete itself competes with ingest for I/O — every day, forever. Measured on the fleet above, dropping one daily range partition takes ~20 ms; deleting the equivalent 17M rows from the hash layout takes 40+ minutes of batched deletes followed by hours of vacuum activity.

Operational note: If a compliance archive is required, DETACH + COPY TO + DROP gives you an exact, restorable per-day artifact. Extracting the same day from a hash layout requires a filtered scan of all partitions before the delete even starts.

SRE tip: Whatever layout you pick, automate boundary management from day one — a missing future partition on a range layout rejects inserts outright. The partitioning implementation patterns & routing section’s automation guides cover schedulers for this; wire the creation job to an alert on relname absence, not just job success.

Step 3 — Compare index and vacuum behaviour under ingest

Write hot-spotting is the standard argument against time-range partitioning, so quantify what it actually costs. On the range layout, 100% of inserts land in today’s partition. On a single node this is the same B-tree behaviour as an unpartitioned append-only table: the right-most index leaf is hot, but PostgreSQL’s fastpath locking and (in 14+) bottom-up index deletion handle monotonic inserts well. The compensating win is cache locality — today’s partition indexes are the only hot indexes, they fit in shared_buffers, and yesterday’s partitions cool off completely: autovacuum touches them once after the day closes, then never again.

On the hash layout each partition receives 1/N of the ingest, so no single B-tree right edge is contended — genuinely useful past several hundred thousand rows/s or when partitions live on different tablespaces or shards (the same uniform-spread property that hash routing algorithms exploit across nodes). The costs: every partition’s index has a hot right edge (aggregate working set is N warm indexes, not one), and retention deletes keep autovacuum permanently busy on all partitions. Verify with:

SELECT relname,
       n_dead_tup,
       last_autovacuum,
       pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE relname LIKE 'readings_%'
ORDER BY n_dead_tup DESC
LIMIT 5;

On a healthy range layout, n_dead_tup is near zero everywhere except today’s partition. On a hash layout with rolling deletes, expect millions of dead tuples spread across all partitions between vacuum cycles — and plan autovacuum_max_workers accordingly.

DBA tip: Range partitions unlock BRIN indexes on the timestamp: because physical order correlates with reading_ts within each partition, a BRIN index a few hundred KB in size can replace a multi-GB B-tree for window scans. Hash partitions destroy that correlation, so they forfeit BRIN too.

Step 4 — Reach for range-then-hash sub-partitioning when both shapes matter

If Step 1 showed heavy time-window and single-key traffic, or Step 3 showed real insert contention, the escape hatch is composite partitioning: RANGE on time at the top level, HASH on the key inside each range. You keep time pruning and DROP-based retention, and spread each day’s ingest across N sub-partitions:

CREATE TABLE readings (
  device_id  BIGINT           NOT NULL,
  reading_ts TIMESTAMPTZ      NOT NULL,
  value      DOUBLE PRECISION NOT NULL,
  PRIMARY KEY (device_id, reading_ts)
) PARTITION BY RANGE (reading_ts);

-- Each daily range is itself hash-partitioned
CREATE TABLE readings_2026_07_06 PARTITION OF readings
  FOR VALUES FROM ('2026-07-06') TO ('2026-07-07')
  PARTITION BY HASH (device_id);

CREATE TABLE readings_2026_07_06_h0 PARTITION OF readings_2026_07_06
  FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE readings_2026_07_06_h1 PARTITION OF readings_2026_07_06
  FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE readings_2026_07_06_h2 PARTITION OF readings_2026_07_06
  FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE readings_2026_07_06_h3 PARTITION OF readings_2026_07_06
  FOR VALUES WITH (MODULUS 4, REMAINDER 3);

A query filtering on both reading_ts and device_id now prunes on both levels — one day, one hash bucket. Retention still detaches the whole daily range (children come with it). The price is table-count multiplication: 90 days × 4 buckets = 360 leaf tables, each with its own indexes, stats, and planner entries. The same layering works with LIST as the inner or outer level — see composite list-range partitioning for regional time-series data for the regional variant.

Operational note: Keep total leaf count in the low thousands. Planning time grows with partition count, and versions before PostgreSQL 13 lock every partition at plan time for some statement shapes.

SRE tip: Automate creation of the whole daily sub-tree atomically (one transaction). A daily range created without its hash children accepts no rows and fails ingest at midnight — the worst possible time.

Step 5 — Apply the decision table

Dimension RANGE (timestamp) HASH (key) Composite range→hash
Time-window query Prunes to matching ranges Scans all partitions Prunes to matching ranges
Single-key lookup Probes every range’s index Prunes to 1 partition Prunes to 1 bucket per range
Write distribution 100% to newest partition Uniform 1/N Uniform 1/N within newest range
Retention by age DETACH + DROP, ~ms Batched DELETE + vacuum, hours DETACH + DROP, ~ms
Vacuum load One hot partition All partitions, continuously N hot buckets in newest range
Index working set Newest partition only All partitions warm Newest range’s buckets
BRIN on timestamp Effective Ineffective Effective within buckets
Table count 1 per period Fixed N periods × N

Recommendation checklist — choose the first row that matches:

Failure mode table

Failure mode Root cause SRE mitigation
Chose hash; retention now requires DELETE and the table bloats Age-based expiry has no partition alignment under HASH, so expired rows become dead tuples instead of dropped files Batch deletes off-peak with key-range chunks, raise autovacuum priority for the table, and plan a migration to a range layout via logical replication into a new partitioned table
Dashboard latency degrades as data grows despite partitioning Time-window queries fan out across every hash partition, so each query’s I/O scales with total data, not window size Confirm with EXPLAIN (ANALYZE, BUFFERS) showing an Append over all partitions; add a covering index on (reading_ts) per partition as a stopgap, then re-platform to range
Inserts rejected at midnight with “no partition of relation found for row” Range layout’s next boundary was never created (automation gap or a composite sub-tree created without hash children) Alert on absence of the next period’s partition in pg_class 12+ hours ahead; create a DEFAULT partition as a safety net and monitor it for stray rows

FAQ

Does hash partitioning ever win outright for time-series data?

Only when the workload is not really time-series shaped: queries look up individual keys rather than time windows, data is never expired by age, and write throughput on a single partition is the binding constraint. If any query filters on time or any retention policy drops data by age, RANGE on the timestamp should be the outer layout, with HASH at most as an inner sub-partition level as shown in Step 4.

Is the newest-partition write hot-spot actually a problem in PostgreSQL?

Usually not on a single node. All writes landing in one partition is equivalent to writing an unpartitioned table of that size, and PostgreSQL handles append-heavy tables well. It becomes a real problem in two cases: partitions map to different shards or tablespaces and one node absorbs all ingest, or a single B-tree right edge becomes a lock contention point at very high insert rates (typically several hundred thousand rows/s). Range-then-hash sub-partitioning addresses both without giving up time pruning.

How many hash sub-partitions should each time range get?

Start with a small power of two, typically MODULUS 4 or 8 per time range. Each sub-partition adds planner overhead and per-table files, and a year of daily ranges with 16 sub-partitions is already 5,840 leaf tables. Size the modulus so one sub-partition’s active index fits comfortably in shared_buffers, and increase it only when measurements show insert contention on the current layout — changing the modulus later only requires re-creating future daily sub-trees.