Partition an existing table¶
You have events, a plain table with a lot of rows, and you want it partitioned by month
without stopping the application. PostgreSQL has no ALTER TABLE … PARTITION BY; the
path is the one pg_partman popularised: make the old table the DEFAULT partition of a
new parent, then drain it window by window. pg-partsmith does the draining in bounded
batches.
1. Swap the tables¶
BEGIN;
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;
COMMIT;
Every row is visible through events again as soon as this commits. Writes route to the
DEFAULT partition until monthly partitions exist.
Constraints
The new parent's primary key must contain the partition key: PRIMARY KEY (id,
created_at). If the old table's key was (id) alone, change it on events_legacy
before the swap, or ATTACH fails with unique constraint … must include all
partitioning columns. Sequences, indexes and constraints come across with LIKE …
INCLUDING ALL; foreign keys to the old table are best recreated against the new
parent after the drain: rows that are already referenced cannot be moved at all
(the movers refuse them row-safe), and a CASCADE, SET NULL or SET DEFAULT
action is refused up front because it would fire on every batch. See
Row moves and ON DELETE actions.
2. Configure, plan, run the tick¶
config = TablePartitionConfig(
schema="public",
table_name="events",
partition_column="created_at",
granularity=PartitionGranularity.MONTH,
create_ahead_count=3,
retention_count=24,
)
print((await service.plan(config)).describe())
await maintainer.run_maintenance_safe(config)
The tick creates the current month and the two after it. As each is attached, the rows
of that month move out of events_legacy into it — ordinary DEFAULT reconciliation.
The current month is the one the application is writing into, and it attaches on this
first tick like the empty future ones: the bulk of its rows move first, then the rows that
arrived while that ran move and the partition goes live in one transaction. Inserts wait
at the parent for that commit and are then routed into the new partition, their statement
unchanged. From now on new rows land in real partitions; the old ones are still in the
DEFAULT.
3. Drain the DEFAULT partition¶
while not (result := await service.partition_data(config, batch_rows=50_000, max_batches=200)).complete:
log.info("moved %d rows in %d batches; created %s", result.rows_moved, result.batches, result.partitions)
Each call:
- finds the oldest window with rows still in DEFAULT;
- creates its partition detached, subtree included, through the same path a scheduled creation takes;
- moves the window's rows into it in batches of
batch_rows— oneDELETE … RETURNING/INSERTper batch, each committing on its own, so a row is in exactly one place at every commit point; - takes whatever landed during the last batch and attaches the partition, in one transaction and under one lock — so a window still being written to is attached on the same pass as a quiet one, and what the lock covers is a tail and a scan rather than a month;
- moves on to the next window, until DEFAULT holds only rows no window can take (rows
with a NULL key), or
max_batchesis spent.
result.complete says whether DEFAULT is drained; result.partitions lists what was
created; result.issues explains anything that could not be handled. A call that runs out
of budget mid-window leaves that partition detached and filled so far; the next call
finds it, finishes it and attaches it.
What a batch cannot hide
While a window's rows are being moved, they sit in a partition that is not yet
attached and are invisible through the parent. PostgreSQL leaves no other order —
a partition cannot be attached while DEFAULT still holds rows for it. Run the drain in
a maintenance window, or with small batches during a quiet hour and readers that can
tolerate a month's rows appearing a little later. Rows already in real partitions, and
rows still in DEFAULT for other windows, stay visible throughout. The attach that ends
a window blocks writers at the parent and holds ACCESS EXCLUSIVE on the DEFAULT
partition, for the length of the last batch's tail plus one scan of DEFAULT — so
inserts stall for that long rather than fail, and readers of the partitions already
drained carry on.
partition_data takes the table's lock, so it does not race the scheduled tick. It
refuses a window it cannot create (an unmanaged partition overlaps it), any move an
incoming foreign key's ON DELETE action would corrupt, and a DEFAULT partition it could
not clear, with a move issue and complete=False rather than loop — and never by
raising at the caller. A window whose partition already exists detached with this
library's marker — retention retired it, and late rows for it landed in DEFAULT — is
filled and re-attached rather than given up on.
4. Afterwards¶
Once events_legacy is empty it is still the DEFAULT partition and still catches rows
with a NULL key. Keep it — an empty DEFAULT costs nothing and PostgreSQL will scan it on
every attach — or detach and drop it by hand if the key is NOT NULL:
Without a DEFAULT partition, DETACH … CONCURRENTLY becomes available, which is what
DetachMode.AUTO prefers.
A serial key's sequence still belongs to the old table
BIGSERIAL makes the sequence owned by the column it was declared on, so after the
swap it belongs to events_legacy — while every partition's id default draws from it.
DROP TABLE events_legacy is refused for exactly that (2BP01, other objects depend
on it, listing one default per partition). Move the ownership to the live parent first:
Then the drop goes through and the ids carry on where they were. Do not reach for
DROP TABLE … CASCADE here: measured on 17, it takes the sequence with the table and
every default that drew from it, and the next insert fails
null value in column "id" … violates not-null constraint.
The way back¶
unpartition empties every partition into one plain table, oldest first, in the same
batches, and optionally drops each emptied partition through the ordinary path — marker,
hooks, revalidation:
result = await service.unpartition(config, "public.events_flat", batch_rows=50_000, drop_emptied=True)
events_flat is created LIKE the root when it does not exist; it must be a plain
table that is not a partition of anything — the root itself, one of its partitions or
detached partitions, and a partition of any other table are all refused (rows moved
"into" the root would route straight back to where they came from). Rows the drop's own
drain moves are counted in rows_moved, and a destination with identity columns has its
sequences advanced past the moved ids, so its next ordinary insert just works. With drop_emptied a partition is detached once its last batch comes up
short, the rows that arrived in the meantime are moved, and the drop moves whatever is
left in the same transaction, under the drop's own lock — a row committed between the
last batch and the drop ends in events_flat, never in the dropped table. Detached
partitions this library owns (orphans waiting out a grace) are emptied too; under
DropNever they belong to another process and are reported instead. Foreign partitions
are skipped and reported: their rows are not this database's to move. Rows already moved
are in events_flat at every commit point, never in two places.
Nested schemes¶
Both movers work unchanged for a RANGE → HASH tree: partition_data builds each month
with its buckets before filling it, and the rows route into the buckets as they are
inserted; unpartition empties a month through its branch.