Commands around the lifecycle¶
Hooks are where the destructive half of this becomes usable: an operator who cannot run
anything before a DROP will not point a scheduled job at production. From
a document, a hook is a command:
hooks:
timeout_seconds: 600
before_drop: ["/usr/local/bin/archive-partition"]
after_create: ["/usr/local/bin/notify", "--channel", "#dba"]
This is deliberately language-neutral. A Go or Ruby team already owns an archiver; what they need is for it to be invoked at the right moment with the right metadata, not a Python snippet.
What the command is handed¶
The whole event, as JSON, on stdin — the same object a Python hook is given:
{
"phase": "before_drop",
"config": { "schema": "public", "table_name": "events" },
"partition": { "name": "public.events__2025_08", "oid": 40321, "bounds": { "kind": "range" } },
"window": { "start": "2025-08-01T00:00:00+00:00", "end": "2025-09-01T00:00:00+00:00" },
"operation": { "kind": "drop", "reason": "grace_elapsed", "size_bytes": 1073741824 }
}
Plus, for shell scripts that would rather not parse it: PG_PARTSMITH_PHASE,
PG_PARTSMITH_TABLE, PG_PARTSMITH_PARTITION, and PG_PARTSMITH_WINDOW_START /
PG_PARTSMITH_WINDOW_END when the partition covers a period. The rest of the environment
is inherited, because a hook needs its own credentials and its PATH.
before_drop is told the window and the size even though DETACH has already cleared the
catalog's record of the bounds: what it gets is the window the planner decided the drop on.
Writing the script¶
The shipped examples/hooks/
are the templates: a pg_dump before a drop, a webhook after a create, a COPY out
before a detach. The pattern is the same in any language: read stdin if you want the whole
event, read the environment if the name is enough, do the thing, exit non-zero to refuse.
Bash, with the environment and nothing else:
#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null # the event; not needed here
partition="${PG_PARTSMITH_PARTITION:?}"
pg_dump --format=custom --table="$partition" --file="/archive/${partition//./_}.dump.part"
mv "/archive/${partition//./_}.dump.part" "/archive/${partition//./_}.dump"
Bash with jq, when the size or the window matters:
event=$(cat)
size=$(jq -r '.operation.size_bytes // 0' <<<"$event")
window_end=$(jq -r '.window.end' <<<"$event")
[ "$size" -gt 10737418240 ] && echo "too big to archive tonight" >&2 && exit 1
Go, for a team that already has one:
var event struct {
Phase string `json:"phase"`
Partition struct{ Name string `json:"name"` } `json:"partition"`
Operation struct {
Reason string `json:"reason"`
SizeBytes *int64 `json:"size_bytes"`
} `json:"operation"`
}
if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { log.Fatal(err) }
if err := archive(event.Partition.Name); err != nil { os.Exit(1) } // refuses the drop
Python, as a file (python_file: hooks/export_partition.py): no stdin, no JSON —
event is the object itself, log is a logger, and raise refuses:
if shutil.which("psql") is None:
raise RuntimeError("psql is not on PATH; refusing to detach without an export")
log.info("exporting %s", event.partition.name)
subprocess.run(["psql", "-c", f"COPY {event.partition.name} TO STDOUT WITH (FORMAT csv)"], stdout=out, check=True)
Three things every script should get right:
- Write to a temporary name and rename. A run can be stopped mid-hook; the child is terminated, and a half-written dump under the final name looks finished.
- Refuse loudly. Whatever went wrong belongs on stderr: it is carried in the error the run reports, and it is the only place anyone will look at 03:00.
- Do not talk to the table being dropped through the same connection pool the run
uses. The hook is a separate process with its own credentials;
PGHOST,PGUSERand friends are inherited from the run's environment, sopg_dumpandpsqljust work.
The phases¶
before_create, after_create, before_attach, after_attach, before_detach,
after_detach, before_drop, after_drop. Each takes one command.
Hooks fire once per lifecycle unit — the partition directly under the root — never
once per leaf. For a RANGE → HASH table, before_drop runs once for the week, not once
per bucket.
A non-zero exit is a refusal¶
Exactly as a raised exception is for a Python hook: the operation is abandoned and planned
again on the next run. A before_drop that exits non-zero leaves the partition detached
and undropped, which is what an archiver saying "not yet" should mean.
By default one refusal aborts the whole run. --continue-on-error isolates it to its own
partition and lets the rest of the table be maintained; the failure comes back in the
run's issues, and the command exits 3.
A command that outlives timeout_seconds (default 300) is killed, and that counts as a
refusal. There is no "wait forever": a hook that hangs is holding the table's maintenance
lock. A command is also stopped when the run is — Ctrl+C, a pod's SIGTERM — with
terminate first and kill a few seconds later, so nothing it started keeps running
unwatched.
Hooks never fire during plan¶
plan issues no DDL and runs no hook. That is what makes a plan safe to compute anywhere,
and it is why the plan/apply split is worth having.
One honest caveat in the other direction: plan does run SQL you configured. A
SqlPredicate in a retention rule is executed while planning. It is read-only, but it is
your statement, running with the credentials the run holds.
Why --allow-hooks exists¶
Running a document's commands is arbitrary code execution in a process holding DDL credentials. The honest framing:
- The document already authorises dropping tables. A command in the same file is not a new privilege boundary, as long as the document comes from the same trust domain as the DSN.
- That stops being true the moment the document is assembled from somewhere less trusted — a ConfigMap another team can edit, a templated file, anything user-supplied.
So commands run only when the run is told to run them, and a document declaring hooks is
refused rather than quietly stripped when it is not: silently skipping a configured
before_drop would let an operator read the file, believe their archiver ran, and be
wrong. A hardened deployment can leave --allow-hooks off and prove nothing was executed.
There is no sandbox, and none is claimed. If you need real isolation, the command is the answer rather than a restricted namespace: put it in a sidecar or a mounted binary with its own credentials, and let the container boundary be the boundary.
A block of Python instead of a command¶
For the Python-adjacent audience, a phase can take a block instead of a binary — the authentik model: a snippet in the document, evaluated with a prepared namespace when the moment comes.
hooks:
before_drop:
python: |
if event.operation.size_bytes and event.operation.size_bytes > 10 * 2**30:
log.warning("exporting %s before drop", event.partition.name)
export_to_s3(event.partition.name, event.window)
after_create:
python_file: hooks/notify.py
The block sees two names: event, the same PartitionEvent a hook class is handed, and
log, a logger named for the phase. Nothing else is injected — a block that needs
datetime imports it, the way any Python does. Raising is how a block refuses the
operation, exactly as a non-zero exit is for a command.
Inline blocks get unreadable past a few lines and lose editor support, so python_file
points at a file instead, resolved relative to the document. A file can be tested on its
own.
Every block is compiled by validate, and inline ones the moment the document is
read: a SyntaxError is a validation error with a line number in it, not something a
CronJob discovers at 03:00 after some of the run's DDL has committed. A missing or broken
python_file fails validate by name.
The block runs in the process, on a thread of its own, so the loop that fires it stays free
to take a stop: SIGTERM cancels the run, which cleans up and exits, and a block still
running at that point is abandoned with the process. The responsibility is the one a hook
written as a class carries: a block that blocks, blocks maintenance until it returns. Work
that can outlast a grace period is better as a command hook, a child the stop terminates.
What this is not¶
A sandbox. exec with a filtered __builtins__ is not a security boundary and is
trivially escaped; documenting one would be worse than not having one. The block runs as
the process runs, with every credential the process holds — which is why it is behind
the same --allow-hooks as a command, and why the trust argument above applies to it word
for word. If isolation is what you need, a command hook in a container of its own is the
answer, not a restricted namespace.
From Python¶
The same class, without a document:
from pg_partsmith import HookPhase
from pg_partsmith.aio import CommandHooks, PartitionToolkit
kit = PartitionToolkit.from_engine(
engine,
hooks=[CommandHooks({HookPhase.BEFORE_DROP: ["/usr/local/bin/archive-partition"]})],
)
pg_partsmith.sync.CommandHooks is the same thing on a sync engine. Both run the command
the same way, which does not depend on the event loop being able to spawn processes — so a
hook that works in production works on a developer's machine too.
PythonHooks is the block form, and takes the source per phase:
from pg_partsmith.aio import PythonHooks
hooks = PythonHooks({HookPhase.BEFORE_DROP: "log.warning('dropping %s', event.partition.name)"})
Every block is compiled when the object is built, so a typo is a SyntaxError at wiring
time rather than at the first drop.
For anything that wants to stay inside Python, write an ordinary hook class instead — see Extend the library.