Composite Partition Keys: Tenant and Time
Some workloads have two dimensions that both matter: queries filter by tenant, retention runs by month, and writes concentrate on the newest data for the largest tenants. A composite key serves both β at the cost of a partition count that multiplies. This guide builds one carefully, choosing the level order from the query mix and keeping the leaf count bounded. It extends Partition Key Selection & Design within Database Partitioning Fundamentals & Architecture.
Prerequisites
Step 1 β Decide the level order from the evidence
The top level is the one nearly every query filters on; the second level breaks up whatever remains too large.
-- how often does each candidate appear as a predicate?
SELECT count(*) FILTER (WHERE query ~* 'occurred_at\s*[><=]') AS with_time,
count(*) FILTER (WHERE query ~* 'tenant_id\s*=') AS with_tenant,
count(*) FILTER (WHERE query ~* 'occurred_at\s*[><=]'
AND query ~* 'tenant_id\s*=') AS with_both,
count(*) AS total
FROM pg_stat_statements
WHERE query ILIKE '%from events%';
with_time | with_tenant | with_both | total
-----------+-------------+-----------+-------
412 | 388 | 371 | 441
Both predicates appear in 84% of statements, which is the profile that justifies a composite key. If only one had appeared, a single-level key plus an index on the other column would be the simpler and better answer.
Operational note: Retention overrides the query evidence when the two disagree. A per-tenant retention policy forces tenant to the top level even if time appears in more queries, because only a top-level partition can be dropped independently.
DBA tip: Count statements, not executions, when deciding level order β then weight by executions to sanity-check. A single high-frequency query can justify a layout that the statement count alone would not.
Step 2 β Create the two-level structure with a bounded fan-out
CREATE TABLE events (
id bigint GENERATED BY DEFAULT AS IDENTITY,
tenant_id bigint NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb,
PRIMARY KEY (id, tenant_id, occurred_at)
) PARTITION BY RANGE (occurred_at);
-- one month, itself partitioned by hash on tenant
CREATE TABLE events_2026_08 PARTITION OF events
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01')
PARTITION BY HASH (tenant_id);
DO $$
BEGIN
FOR i IN 0..7 LOOP
EXECUTE format(
'CREATE TABLE events_2026_08_h%s PARTITION OF events_2026_08
FOR VALUES WITH (MODULUS 8, REMAINDER %s)', i, i);
END LOOP;
END $$;
Operational note: Every unique constraint must contain both levelsβ keys, which is why the primary key is (id, tenant_id, occurred_at). That constraint propagates to every leaf automatically.
DBA tip: Fix the modulus at creation and never change it. Changing the hash fan-out later re-assigns every tenant, which is a full data movement of that month.
Step 3 β Verify pruning for each query shape
Two levels mean four possible pruning outcomes, and all four should be checked:
-- both predicates: prunes to one leaf
EXPLAIN (COSTS OFF) SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01' AND tenant_id = 8842;
-- time only: prunes to one month, scans its 8 leaves
EXPLAIN (COSTS OFF) SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';
-- tenant only: cannot prune the top level at all
EXPLAIN (COSTS OFF) SELECT count(*) FROM events WHERE tenant_id = 8842;
Aggregate
-> Index Only Scan using events_2026_08_h3_pkey on events_2026_08_h3 events
Index Cond: ((tenant_id = 8842) AND (occurred_at >= '2026-08-01') AND (occurred_at < '2026-09-01'))
Operational note: The tenant-only shape is where composite layouts disappoint people. It is not a bug: no key ordering makes both single-predicate shapes cheap, which is the fundamental trade of a two-dimensional layout.
SRE tip: Add a default time window in the application for admin queries β βlast 90 days unless specifiedβ β rather than letting them scan the full history by accident.
Step 4 β Keep retention working
With time on top, retention is unchanged from a single-level layout:
-- detaching the month detaches all eight sub-partitions with it
ALTER TABLE events DETACH PARTITION events_2026_02 CONCURRENTLY;
DROP TABLE events_2026_02; -- children go with it
With tenant on top, retention iterates:
DO $$
DECLARE t record;
BEGIN
FOR t IN SELECT relname FROM pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE i.inhparent = 'events'::regclass
LOOP
EXECUTE format('ALTER TABLE %I DETACH PARTITION %I_2026_02', t.relname, t.relname);
EXECUTE format('DROP TABLE %I_2026_02', t.relname);
END LOOP;
END $$;
Operational note: Dropping a parent partition drops its children automatically β no separate cleanup, and no orphaned leaves.
DBA tip: Whichever order you choose, generate the retention statements from the catalog rather than from a naming convention. Names drift; pg_inherits does not.
Verification
Confirm the leaf count is what you expect and that no leaf is disproportionate:
SELECT count(*) AS leaves,
pg_size_pretty(sum(pg_total_relation_size(c.oid))) AS total,
pg_size_pretty(max(pg_total_relation_size(c.oid))) AS largest,
round(max(pg_total_relation_size(c.oid))::numeric
/ nullif(avg(pg_total_relation_size(c.oid)), 0), 2) AS skew_ratio
FROM pg_class c
WHERE c.relkind = 'r' AND c.relname ~ '^events_\d{4}_\d{2}_h\d+$';
leaves | total | largest | skew_ratio
--------+--------+---------+------------
96 | 1188 GB| 19 GB | 1.54
A skew ratio near one means the hash level is doing its job. A ratio above two means one tenant dominates a bucket and deserves its own list partition instead.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Partition count explodes | both levels have unbounded cardinality β for example list-on-tenant over range-on-day | bound the second level to a fixed hash modulus; keep total leaves under roughly a thousand |
| Retention suddenly takes hours | tenant was chosen as the top level and each monthly cleanup now issues one statement per tenant, serially | generate statements from the catalog and run them in one transaction; or move time to the top level if per-tenant retention is not actually required |
| A single bucket grows far beyond its siblings | one tenant dominates the hash bucket it landed in | promote that tenant to its own list partition alongside the hash pool, as in the multi-tenant layout guides |
FAQ
Which level should come first, tenant or time?
Put the level that appears in the most queries on top, and the level that drives retention underneath β unless retention differs per tenant, in which case tenant must be on top so a whole tenantβs history can be dropped independently. For most SaaS workloads time on top with hash-on-tenant underneath is the better default, because retention is uniform and every query filters on time.
Does a composite key double the number of partitions?
It multiplies rather than doubles: levels compose, so twelve months with eight hash buckets is ninety-six leaves, not twenty. That is the main cost, and it is why the second level should have a small, fixed fan-out β eight or sixteen β rather than one bucket per tenant.
Can retention still drop whole partitions with two levels?
Yes, if time is the top level: detaching a month detaches its sub-partitions with it, and the drop is still a catalog operation. If tenant is the top level, dropping a month means dropping one sub-partition per tenant, which is more statements but still metadata-only. What breaks retention is putting a non-time column at both levels.
Related
- Partition Key Selection & Design β the parent topic and the four tests a candidate key must pass
- Composite List-Range Partitioning for Regional Time-Series Data β the same idea with region rather than tenant on the first level
- Tuning Planner Settings for Large Partition Counts β what to do when the multiplied leaf count starts costing planning time