Recipes from real systems¶
Configurations for the shapes production systems build for themselves. Each was checked
against the source of a project that hand-rolls it (see the
OSS research); each is a complete TablePartitionConfig you
can start from. The names used below are importable from pg_partsmith, except
BasePartitionLifecycleHooks, which comes from pg_partsmith.aio (or pg_partsmith.sync).
Error monitoring: weekly UUIDv7 events split by organisation¶
GlitchTip's issue_events: the partition key is a UUIDv7 id, periods are calendar
weeks, each week is hashed by tenant, and the bucket count has changed over time.
config = TablePartitionConfig(
schema="public",
table_name="issue_events",
scheme=RangePartitioning(
key="id",
boundaries=TimeBoundaries(granularity=PartitionGranularity.WEEK, codec="uuidv7"),
child=HashPartitioning(key="organization_id", modulus=2, name_suffix="_h{remainder}"),
),
lifecycle=LifecyclePolicy(creation=CreateAhead(count=3), retention=KeepNewest(count=12)),
)
Requires PRIMARY KEY (id, organization_id). Existing weeks built with MODULUS 4 are
preserved; an incomplete historical week is repaired at its own modulus; a legacy plain
leaf stays valid. Walkthrough: a multi-tenant event store.
Queue: partitions every N message ids¶
PGMQ's partitioned queue, without pg_partman:
config = TablePartitionConfig(
table_name="q_orders",
scheme=RangePartitioning(key="msg_id", boundaries=NumericBoundaries(step=10_000)),
lifecycle=LifecyclePolicy(creation=CreateAhead(count=4), retention=KeepBehind(distance=100_000)),
)
The cursor is max(msg_id); KeepBehind expires a window once the newest id is 100 000
past its upper bound. Run maintenance often enough that four windows ahead outlast the
insert rate between ticks — a row beyond the last window is rejected, not buffered.
Outbox / task table: root HASH for parallel workers¶
pg-trx-outbox and Hatchet's task tables: a fixed set of buckets that workers address
directly with FOR UPDATE SKIP LOCKED.
config = TablePartitionConfig(
table_name="pg_trx_outbox",
scheme=HashPartitioning(key="key", modulus=3, name_suffix="_{remainder}"),
)
No lifecycle: maintenance creates the missing buckets and otherwise issues zero DDL. The
(modulus, remainder) → name mapping comes from service.inspect(config).
Daily stream history next to hand-managed neighbours¶
Centrifugo's outbox tables: daily partitions, a few days ahead, dropped after a week — next to partitions an operator attached by hand.
config = TablePartitionConfig(
table_name="cf_stream_history",
scheme=RangePartitioning(key="created_at", boundaries=TimeBoundaries(granularity=PartitionGranularity.DAY)),
lifecycle=LifecyclePolicy(creation=CreateAhead(count=3), retention=KeepFor(age=timedelta(days=7))),
)
A hand-attached partition whose bounds are not a day of the grid is unmanaged_partition:
reported, never detached, never dropped.
Webhook log: partitions through next year, at start-up¶
Hookdeck Outpost's workflow:
config = TablePartitionConfig(
table_name="events",
scheme=RangePartitioning(key="time", boundaries=TimeBoundaries(granularity=PartitionGranularity.MONTH)),
lifecycle=LifecyclePolicy(
creation=CreateUntil(position=datetime(date.today().year + 2, 1, 1, tzinfo=UTC)),
retention=KeepFor(age=timedelta(days=90)),
),
)
async def on_startup() -> None:
await maintainer.run_maintenance_safe(config) # replicas that lose the lock skip
Rows already sitting in events_default move into each new monthly partition as it is
attached.
Sliding list: rotate by state, not by calendar¶
GitLab's ci_builds: LIST (partition_id), one integer value per partition, the
application writes the newest value, the next value opens once the newest partition holds
more than a day of data, and a partition is retired only when no ci_pipelines row
references it any more.
config = TablePartitionConfig(
table_name="ci_builds",
scheme=ListPartitioning(key="partition_id", sequence=IntegerSequence(start=100)),
lifecycle=LifecyclePolicy(
creation=CreateNextIf(when=SqlPredicate(
sql="SELECT min(created_at) < now() - interval '1 day' FROM {partition}"
)),
retention=ExpireIf(when=AllOf(members=(KeepNewest(count=3), Unreferenced()))),
drop=DropAfter(grace=timedelta(days=7)),
),
)
The cursor is the newest partition, so nothing is created "ahead"; the application reads
the current value from service.inspect(config) (the highest single-value member) and
writes it. Unreferenced() is the condition PostgreSQL itself imposes on the detach — a
partition whose rows are still referenced through the foreign key from ci_pipelines
cannot be detached — so the policy never plans a detach the database would refuse.
Cold tiering: detach, archive, verify, drop¶
ColdFront's detach strategy plus an archival pipeline:
class ArchiveHooks(BasePartitionLifecycleHooks):
async def after_detach(self, event: PartitionEvent) -> None:
await export_to_iceberg(event.partition.name, covering=event.window)
async def before_drop(self, event: PartitionEvent) -> None:
if not await archive_verified(event.partition.name):
raise RuntimeError("archive not verified yet") # the drop is retried next tick
config = TablePartitionConfig(
table_name="metrics",
partition_column="ts",
granularity=PartitionGranularity.MONTH,
lifecycle=LifecyclePolicy(
creation=CreateAhead(count=2),
retention=KeepFor(age=timedelta(days=90)),
detach=DetachMode.CONCURRENT,
drop=DropAfter(grace=timedelta(days=7)),
),
)
Or drop=DropNever() to hand the detached tables to another process entirely.
Cold tiering to a column store: foreign leaves¶
pg_clickhouse's shape: an index-free metrics table whose partitions are foreign tables on a ClickHouse (or any FDW) server, queried through one PostgreSQL parent.
config = TablePartitionConfig(
table_name="metrics",
scheme=RangePartitioning(key="ts", boundaries=TimeBoundaries(granularity=PartitionGranularity.MONTH)),
lifecycle=LifecyclePolicy(creation=CreateAhead(count=2), retention=KeepNewest(count=24)),
leaves=ForeignLeaves(server="clickhouse", options={"table_name": "{relname}"}),
)
The parent must have no unique index (PostgreSQL's rule; checked before any DDL). See Tier cold data to a foreign server.
Hot leaves on fast storage, with the parent's grants¶
config = TablePartitionConfig(
table_name="events",
partition_column="created_at",
granularity=PartitionGranularity.DAY,
create_ahead_count=3,
retention_count=30,
leaves=LocalLeaves(tablespace="nvme", storage_parameters={"fillfactor": 90}, inherit_privileges=True),
)
Every new day lands on the nvme tablespace with the parent's owner and grants — what
pg_partman's template table and inherit_privileges did.
Large partitions on weekends only¶
GitLab's 150 GB rule as a drop condition:
def small_or_weekend(candidate: Candidate) -> bool:
size = candidate.facts.size_bytes or 0
return size < 150 * 2**30 or candidate.now.weekday() >= 5
lifecycle = LifecyclePolicy(
retention=KeepFor(age=timedelta(days=30)),
drop=DropAfter(
grace=timedelta(days=7),
when=Callback(fn=small_or_weekend, facts=frozenset({FactKind.SIZE}), label="<150GB or weekend"),
),
)
Deferred drops appear as drop_deferred findings with the size on the plan.
From a monolithic table¶
# once, in SQL:
# ALTER TABLE events RENAME TO events_legacy;
# CREATE TABLE events (LIKE events_legacy INCLUDING ALL) PARTITION BY RANGE (created_at);
# ALTER TABLE events ATTACH PARTITION events_legacy DEFAULT;
while not (result := await service.partition_data(config, batch_rows=50_000, max_batches=100)).complete:
await asyncio.sleep(1) # let the writers breathe between rounds
See Partition an existing table for what is and is not visible while it runs.