Skip to content

Lifecycle policies

A scheme says what partitions exist; a LifecyclePolicy says when the partitions of a progression level are created, detached and dropped. It has four parts:

from datetime import timedelta

from pg_partsmith import CreateAhead, DetachMode, DropAfter, KeepNewest, LifecyclePolicy

LifecyclePolicy(
    creation=CreateAhead(count=3),               # which windows must exist ahead of the cursor
    retention=KeepNewest(count=12),              # when a window behind the cursor has expired
    detach=DetachMode.AUTO,                      # how an expired partition is detached
    drop=DropAfter(grace=timedelta(days=7)),     # what happens to it afterwards
)

The flat fields create_ahead_count / retention_count are this policy with CreateAhead and KeepNewest; spell lifecycle= out for anything else.

A rule with one defining value takes it positionally as well: CreateAhead(3), KeepNewest(12), KeepFor(timedelta(days=90)), DropAfter(timedelta(days=7)).

The timeline of one partition

                cursor (now)
   ──┬─────────┬────┼────┬─────────┬─────────┬──►  time
     │ expired │kept│ current   │ ahead   │ ahead │
     │         │    │           │         │       │
     ▼         ▼    ▼           ▼         ▼       ▼
  detach     keep  never      create   create  (not yet)
  → grace          expired    ahead    ahead
  → drop

Ahead of the cursor, the creation rule decides which windows must exist. Behind it, the retention rule decides which have expired. The cursor's own window and everything ahead of it receive rows and are never expired, whatever the rule says. An expired partition is detached — it leaves the parent but keeps its data — and then, once the drop rule allows, dropped.

Creation

Rule Windows that must exist
CreateAhead(count) the cursor's window and the count − 1 after it. CreateAhead(count=3) in June: June, July, August
CreateUntil(position) every window from the cursor's up to the one holding position — a datetime on a time axis, an int on an integer one. "Partitions through the end of next year" is CreateUntil(position=datetime(2028, 1, 1, tzinfo=UTC))
CreateNextIf(when) the cursor's window always; the window after the newest existing partition only once when holds for that partition — rotation by application state, not by the calendar

CreateNextIf is what a sliding list uses ("open the next value once the newest holds a day of data"), and what an id-partitioned queue can use to size windows by volume. A sliding list refuses CreateAhead: its cursor is the newest partition, so "ahead" would never converge.

Retention

Rule A window is expired when
KeepNewest(count) it ends at or before the start of the window count − 1 steps behind the cursor's. A count of windows, current one included — the retention_count semantics
KeepFor(age) it ended at least age ago (time axis only)
KeepBehind(distance) the cursor is distance or more past its end (integer axis only; pg_partman's rule for id sets)
ExpireIf(predicate) the predicate holds
AllOf(…), AnyOf(…), Not(…) combinations

The retention rules are predicates too, so they combine with everything else. "Older than twelve months and nothing pending in it":

from pg_partsmith import AllOf, ExpireIf, KeepNewest, SqlPredicate

retention=ExpireIf(when=AllOf(members=(
    KeepNewest(count=12),
    SqlPredicate(sql="SELECT NOT EXISTS (SELECT 1 FROM {partition} WHERE status = 'pending')"),
)))

Count, not distance

Hand-rolled pruners usually say "drop everything older than N months", which keeps N + 1 partitions on disk. KeepNewest(count=N) keeps exactly N. Coming from a distance, pass N + 1, or express the age directly with KeepFor.

Predicates and facts

Every rule is a pure function of a candidate: the partition's window, its node in the tree, the cursor, and its facts. Facts are what the introspector measured because a rule asked for it — nothing is measured for a policy that does not ask, so a monthly table with KeepNewest never pays for pg_total_relation_size.

Predicate Needs True when
SizeAbove(bytes) size the partition and its subtree exceed bytes on disk
RowsAbove(rows) rows the planner's row estimate exceeds rows — never COUNT(*); a fresh partition reads as empty until statistics catch up
WindowAgeAbove(age) the window ended at least age ago
Unreferenced() references no row of another table references a row of the partition through a foreign key — the condition PostgreSQL itself imposes on DETACH. An unmeasured partition reads as referenced, so it is kept
SqlPredicate(sql) one query per candidate the statement yields true. {partition} is replaced with the quoted name; nothing else is interpolated. A partition that does not exist yet reads as false
Callback(fn, facts=…, label=…) what it declares fn(candidate) returns true — plain Python over the gathered facts, usable from both mirrors
AllOf, AnyOf, Not the union of their members' needs

Facts are gathered only for the partitions a policy can decide over — the members of progression levels and their detached orphans — in one query for sizes and rows, one EXISTS per incoming foreign key for references, one query per SqlPredicate and candidate. The numbers appear on the plan (size_bytes, row_estimate).

A policy decides eligibility; it never executes DDL. Ownership, safety and locking stay with the core, which is what keeps a user predicate from turning into an accidental DROP TABLE.

Detach

DetachMode Statement
AUTO (default) DETACH … CONCURRENTLY, falling back to the blocking form when PostgreSQL refuses it
CONCURRENT the concurrent form only; the refusal propagates
BLOCKING plain DETACH: ACCESS EXCLUSIVE on the parent for the duration

The concurrent form takes SHARE UPDATE EXCLUSIVE on the parent and lets readers and writers through; it cannot run inside a transaction block, so it goes out on an autocommit connection. A detach interrupted mid-way is finished with DETACH … FINALIZE on the next attempt.

PostgreSQL refuses the concurrent form outright while the parent holds a DEFAULT partition. The planner reads that from the catalog, so an AUTO detach of a parent with a DEFAULT partition is planned as the blocking form and says so — plan --locks names the ACCESS EXCLUSIVE it will take, and no statement is issued that could only fail. CONCURRENT is left as written: asking for the concurrent form and being refused is the answer that mode exists to give.

Either form takes ACCESS EXCLUSIVE on every table that references the parent through a foreign key, and neither can detach a partition whose rows such a table still references: PostgreSQL refuses with 23503. The executor records that as an issue and goes on; Unreferenced() in the retention rule keeps those partitions out of the plan until the referencing rows are gone. See Handle foreign keys.

Drop

Rule Effect
DropAfter() (default: no grace) dropped in the same run as the detach
DropAfter(grace=timedelta(days=7)) kept detached for a week, then dropped. The detach instant is recorded on the table's marker; an orphan marked by an older version, or adopted, has no instant and is treated as past its grace
DropAfter(grace=…, when=predicate) dropped only once the grace has passed and the predicate holds — "not while bigger than 150 GB on a weekday" is a Callback over the size fact
DropNever() detached partitions are left alone; something else owns the drop — an archive pipeline, a DBA. They are never re-attached, and a wanted window whose name such a table holds is reported (name_unusable), not recreated over it

While a detached partition waits it is an orphan: still in the database, no longer reachable through the parent, carrying the marker that makes it the library's to drop. If retention grows again before the grace runs out and the orphan's window is wanted, it is re-attached — the data comes back rather than being recreated empty — and the marker comes off in the attach's transaction, so a later detach starts a fresh grace period. Under DropNever orphans belong to whatever process the policy hands them to and are never brought back.

One policy, every progression level

A configuration has one LifecyclePolicy, applied at every progression level of its scheme. A scheme with no progression level — a root HASH, a LIST of fixed groups — has a fixed partition set; its policy is ignored.

Every rule is a Pydantic model discriminated on kind, so config.model_dump(mode="json") round-trips — except Callback, whose function cannot be serialized.