Skip to content

The command line

pip install "pg-partsmith[cli]"

pg-partsmith runs partition maintenance over a configuration document and a DSN — no Python in your application, no import, no dependency on this library at all.

pg-partsmith validate -c partitions.yaml     # does the document match the database?
pg-partsmith inspect  -c partitions.yaml     # what tree actually exists?
pg-partsmith plan     -c partitions.yaml     # what would maintenance do, and why?
pg-partsmith apply    -c partitions.yaml     # do it — creations only, by default
pg-partsmith backfill -c partitions.yaml     # move a DEFAULT partition's rows where they belong

The first three issue no DDL, take no lock and fire no hook. apply is the one that acts. backfill is the one an existing installation runs once, before the first apply has anything sensible to do: see Adopting a table full of data.

Every flag

Command Flags
all five -c/--config FILE (required), --dsn, --table NAME (repeatable), -o/--output human\|json\|metrics, --write FILE (the output, into a file, atomically), -v/--verbose
plan --check exit 2 on pending operations · --save FILE write the plan · --locks print what each operation locks
backfill --batch-rows N rows per statement (default 10000) · --max-batches N stop after N statements and exit 2 · --allow-hooks · --ok-if-locked
apply --plan FILE apply a saved plan · --allow-destructive detach and drop too · --continue-on-error isolate a failed operation · --allow-config-drift apply a plan whose document changed · --allow-hooks run the document's hooks · --ok-if-locked exit 0 rather than 6 on a held lock
schema no flags: prints the document's JSON Schema for an editor
global --version · --install-completion · --show-completion · -h/--help on anything

--help on any command is generated from the same declarations that parse it, so the table above can be stale and the help cannot.

Being stopped

Ctrl+C, or the SIGTERM a pod is sent at its deadline, cancels the run rather than killing the process. The statement in flight is cancelled by the driver and its transaction rolled back, the table's lock is released, the engine is disposed, a hook's child process is terminated, and the exit code says which signal it was: 130 or 143. One line on stderr says the same in words.

What a stopped run leaves behind is what the library promises for any cancellation: at most a detached, unattached table (attach is the last step of a creation) or a marked, half-detached partition, and the next run converges both.

A block of Python in a hook runs on a thread of its own, so a stop does not wait for it: the run is cancelled and exits, and a block still running at that point is abandoned with the process. A command's child is terminated on purpose.

Shell completion

pg-partsmith --install-completion      # bash, zsh, fish, PowerShell

--help on any command is generated from the same declarations that parse it, so it cannot drift from what the command accepts.

Where the connection comes from

--dsn, then $PG_PARTSMITH_DSN, then the file $PG_PARTSMITH_DSN_FILE points at, then the document's dsn — in that order, so a mounted ConfigMap can carry the tables while the password stays in a secret. The file form is for Docker and Swarm secrets, which arrive as files under /run/secrets.

A DSN naming no driver (postgresql://…) is driven with asyncpg, which the cli extra installs. One that names its own (postgresql+psycopg://…) is left exactly as written.

Exit codes

A CronJob and a CI step read the exit code and nothing else, so the codes are worth distinguishing:

Code Meaning
0 did what it was asked; nothing pending
1 something unexpected; the message is on stderr
2 plan --check found operations waiting to be applied
3 the planner reported something a human has to act on, or the database refused a statement: a missing grant, a constraint
4 the document does not parse, or does not match the database
5 the database could not be reached, refused the credentials, or dropped the connection
6 another maintainer holds the table's lock
64 the invocation itself was wrong: a misspelled flag, no command
130 stopped by Ctrl+C, after cleaning up
143 stopped by SIGTERM, after cleaning up

Three of those deserve their reasoning spelled out.

64 is not 2. Argument parsers exit 2 on a misspelled flag, and 2 is what this tool means "drift" by. A CronJob alerting on drift must not page over a typo, so a usage error has the code sysexits.h gave it — EX_USAGE — and never the parser's default.

3 outranks 2. Drift is what a maintenance run fixes; a finding is what it cannot — a range overlap, a hash set with a gap no repair is safe for. If both are true, the one needing a person wins.

6 is not a failure. Two runs overlapping is ordinary operation, and the second one correctly declining to proceed is the lock doing its job. Alerting on it is the first false page every deployment gets, so it is its own code rather than a generic error.

Watching for maintenance that stopped running

pg-partsmith plan -c partitions.yaml --check

Exits 2 while anything is pending. That is the check to put on a schedule: it says "partitions that should exist do not yet", without creating anything to find out.

In CI

- run: pg-partsmith validate -c partitions.yaml
  env:
    PG_PARTSMITH_DSN: ${{ secrets.DATABASE_URL }}

validate answers the question a review cannot: the document parses, the connection works, and every table it describes is partitioned the way it claims — the right method, on the right key.

JSON

--output json on any command. The payload is the library's own model dump under a versioned envelope, in the vocabulary a configuration file is written in:

{
  "version": 1,
  "command": "plan",
  "generated_at": "2026-09-01T12:00:00+00:00",
  "tables": [
    {
      "table": "public.events",
      "plan": {
        "table_name": "public.events",
        "generated_at": "2026-09-01T12:00:00+00:00",
        "config_fingerprint": "8c1d9f0a3b2e4d57",
        "operations": [
          {"kind": "create", "target": "public.events__2026_10", "reason": "create_ahead", "…": "…"}
        ],
        "findings": []
      }
    }
  ]
}

It is the dump, never a shape assembled by the CLI — a hand-rolled one drifts from the library the first time a field is added. Logs go to stderr, so stdout stays parseable.

What will it lock

pg-partsmith plan -c partitions.yaml --locks
plan for public.events at 2026-09-01T02:15:00+00:00
  CREATE public.events__2026_10 (create_ahead)
  DETACH public.events__2025_08 (retention_expired) size=1073741824 rows~4021553
  DROP public.events__2025_08 (follows_detach)
locks:
  CREATE public.events__2026_10
    ACCESS SHARE on the template during CREATE; SHARE UPDATE EXCLUSIVE on the parent and ACCESS EXCLUSIVE on the new partition (and on a DEFAULT sibling) during ATTACH; SHARE ROW EXCLUSIVE on every table referencing the parent through a foreign key
  DETACH public.events__2025_08 (outside a transaction block)
    SHARE UPDATE EXCLUSIVE on the parent (CONCURRENTLY), ACCESS EXCLUSIVE on the partition and, in the second transaction, on every table referencing the parent through a foreign key; ...
  DROP public.events__2025_08
    ACCESS EXCLUSIVE on the dropped table only

The heaviest lock each operation takes, and on what, as measured on PostgreSQL 15 and 17 — the thing a DBA reads before agreeing to a window. An operation that cannot run inside a transaction block is marked, because it is the one a crash leaves half-done and the next run completes. The JSON carries the same under each operation's capabilities, and is_destructive beside it, whether or not --locks was asked for; a saved plan is therefore reviewable for its locks without the CLI.

Why there is no --sql. Printing the statements would be the obvious next thing, and it is deliberately not offered. The DDL is built at execution time from decisions that are only made then — how rows a DEFAULT partition holds for a new window get moved, which orphan marker gets cleared — and a rendering that left those out would be read as a transcript in exactly the cases a review exists to catch. The plan is the contract: what, why, how big, what it locks.

Monitoring, for free

--output metrics renders the same run as Prometheus text exposition, for a node_exporter textfile collector:

pg-partsmith plan -c partitions.yaml --output metrics --write /var/lib/node_exporter/textfile/partsmith.prom

--write writes next to the target and renames, so the collector never reads half a file; a shell redirect would leave that to luck.

# HELP pg_partsmith_pending_operations Operations a maintenance run would carry out.
# TYPE pg_partsmith_pending_operations gauge
pg_partsmith_pending_operations{table="public.events",kind="create"} 2
pg_partsmith_pending_operations{table="public.events",kind="drop"} 0
# HELP pg_partsmith_findings What the planner saw and left alone. A warning needs a person.
# TYPE pg_partsmith_findings gauge
pg_partsmith_findings{table="public.events",severity="warning"} 0

Every command emits something:

Command Series
plan pending_operations{table,kind}, pending_relations{table}, findings{table,severity}
inspect partitioned{table}, partitions{table}, detached_partitions{table}, oldest_detached_age_seconds{table}
validate config_valid{table}
apply applied_operations{table,operation}, issues{table}

All prefixed pg_partsmith_, all gauges — a one-shot job cannot own a counter, since the next run is a new process with no memory of this one. Every run also emits pg_partsmith_run_timestamp_seconds{command}, so a textfile nothing has refreshed is visible as stale rather than as good news.

A converged table reports zeroes rather than nothing: a missing series and a zero are different alerts.

The numbers are read off the same envelope the JSON is, so a metric cannot disagree with what the same run printed.

Three alerts worth having, in the order they will save you:

  • pg_partsmith_pending_operations above zero for longer than your schedule — maintenance has stopped running, and inserts will start being rejected.
  • pg_partsmith_findings{severity="warning"} above zero — something needs a person.
  • time() - pg_partsmith_run_timestamp_seconds above a couple of intervals — the job itself is not running, which no other metric here can tell you.

One table at a time

--table events (or --table public.events), repeatable. A name that no table in the document answers to is an error naming the ones that do, rather than a silent run over nothing.

Applying

pg-partsmith apply -c partitions.yaml                        # create what is missing
pg-partsmith apply -c partitions.yaml --allow-destructive    # …and retire what expired

Destructive operations are withheld unless you ask for them. Without --allow-destructive, apply creates and re-attaches, and detaches and drops nothing. That is deliberate: the safe behaviour is the default one rather than a second mode somebody has to remember to select, and it is exactly what an init container wants — create the partitions the application is about to write into, retire nothing at startup.

With no --plan, planning and applying happen under one lock, which is also what completes an interrupted DETACH … CONCURRENTLY before the rest of the run is decided.

--continue-on-error isolates a failed operation into the run's issues instead of aborting: a failed create still lets pruning run, a failed detach still lets existing orphans be dropped. Any issue makes the command exit 3.

The plan as an artifact

The reason to trust an external tool with DROP is that you can read what it will do first:

pg-partsmith plan  -c partitions.yaml --save plan.json     # zero DDL
# read it, diff it, gate it on a human or a CI approval
pg-partsmith apply -c partitions.yaml --plan plan.json --allow-destructive

--save writes the same JSON envelope --output json prints, whatever format the terminal was given. apply --plan reads it back and applies exactly that.

Two things are checked before anything runs, by the library rather than by the CLI:

  • The plan must be for this table. A plan for public.events applied under the configuration of public.audit is refused.
  • The configuration must not have moved. The plan records a fingerprint of the table's configuration it was made under — the scheme, the policy, the leaves; not the document's hooks, runtime or dsn sections — and if that has been edited since, applying it is refused with exit 4. --allow-config-drift applies it as it stands.

That second one is not the same as the OID revalidation every destructive operation already does. Revalidation asks whether the relation is still the same relation. The fingerprint asks whether the plan is still the same intent: a plan made under retention_count: 12 names exactly the right partitions to expire, for a reason that stopped being true the moment someone wrote 120.

Adopting a table full of data

Creation walks forward from the cursor, so a table that was partitioned around data already in it — everything in one DEFAULT partition — is a table apply has nothing useful to say about: the rows it holds are behind the cursor, and no create-ahead will ever reach them.

backfill is the migration that fixes that, once:

pg-partsmith backfill -c partitions.yaml

Window by window, oldest first, it creates the partition, fills it in batches of --batch-rows and attaches it. Afterwards DEFAULT is empty and the ordinary plan/apply cycle takes over.

It is resumable, which is what makes it usable on a table too large for one maintenance window. --max-batches N stops after N statements per table and exits 2; every row already moved stays moved. So a Job can simply be run until it stops saying 2:

until pg-partsmith backfill -c partitions.yaml --max-batches 50; do sleep 60; done

Two things to know before running it on a busy table. It takes the table's maintenance lock for the duration of each call, so a scheduled apply will decline while it runs (exit 6), which is the lock doing its job. And a window's rows are invisible through the parent between the moment they leave DEFAULT and the moment the partition is attached — PostgreSQL will not attach a partition while DEFAULT still holds rows for it, so there is no order that keeps them visible throughout. --batch-rows bounds how long that is.

Commands around the lifecycle

A document can name a command, or a block of Python, to run before a drop, after a create, and at six other moments. They fire during apply and backfill — the commands that carry out DDL — and only with --allow-hooks — see Commands around the lifecycle.

In a container, on a schedule, in CI

The same commands, with nothing installed: ghcr.io/bedrock-python/pg-partsmith:latest. The container image is about the image; Ways to run it is every harness — plain Docker, Compose, Swarm, a Pod, a Job, a CronJob, an init container, CI, systemd — with a copy-pasteable shape for each.

What is not here yet

unpartition — the way back from a partitioned tree to one plain table — is library-only for now. partition_data, its counterpart, is backfill above: what a one-shot command needed first was a progress story, and --max-batches with exit 2 and the pg_partsmith_backfill_incomplete gauge is one. unpartition also has a destination table to name and a drop_emptied to decide, and neither has an obvious spelling on a command line yet.