Skip to main content

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.

Level order decides which operation is cheap With time on top and hash on tenant underneath, retention drops one month in a single statement and every time-filtered query prunes to one month. With tenant on top and time underneath, a whole tenant can be dropped or moved in one statement and per-tenant retention becomes possible, but a monthly retention run must touch one sub-partition per tenant. A Β· RANGE(time) β†’ HASH(tenant) B Β· LIST(tenant) β†’ RANGE(time) events 2026_072026_08… h0h1h0h1 events t_acmet_globex… 07080708 drop a month: 1 statement tenant + month query: 1 leaf month-only query: 8 leaves per-tenant retention: not possible drop a month: 1 statement per tenant tenant + month query: 1 leaf month-only query: every tenant's month per-tenant retention: natural Layout A suits uniform retention and time-heavy reporting; layout B suits per-tenant policies and tenant offboarding. Both prune identically for the query that matters most β€” tenant plus month β€” which is why the other rows decide it.

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 $$;
Leaf count multiplies: interval Γ— fan-out Γ— retention With 25 months of retention, monthly partitions and a fan-out of four give 100 leaves, of eight give 200, and of sixteen give 400. Daily partitions with a fan-out of eight give 6,000 leaves, which is far past the point where planning time dominates. The fan-out is the cheaper of the two dimensions to keep small. First levelfan-out 4fan-out 8fan-out 16verdict monthly (25)100200400all workable weekly (109)4368721,744watch planning daily (760)3,0406,08012,160planner-bound Retention multiplies the first level, and the fan-out multiplies everything. Choose the interval from query windows and retention resolution, then take the smallest fan-out that removes the write contention you measured. Every cell above also multiplies by the number of indexes, which is what the backup and autovacuum costs actually track.

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'))
Leaves touched per query shape, out of 96 Against twelve months each split into eight hash buckets, a query filtering on both tenant and month touches one leaf of ninety-six. A month-only query touches eight. A tenant-only query touches twelve, one bucket per month. A query with neither predicate touches all ninety-six, which is the shape to eliminate before deploying this layout. tenant + month month only tenant only neither 1 of 96 8 of 96 β€” one month, all buckets 12 of 96 β€” one bucket, all months 96 of 96 β€” the shape a composite key cannot help The third row is the one to watch. A tenant-only query is common in admin tooling and touches every month, so it needs either a time bound added by the application or an acceptance that admin queries are slow by design. Guard the fourth shape in the ORM, as described in the ORM integration guides β€” it should be impossible to express.

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.