pg-partsmith for AI agents¶
One page holding everything a coding assistant needs to configure and drive pg-partsmith correctly, plus a map of where the rest of the documentation keeps the details it leaves out. Give an agent this page rather than the whole site.
| Package | pg-partsmith on PyPI, import root pg_partsmith |
| Requires | Python 3.11+, PostgreSQL 15+ (tested on 15 through 18), SQLAlchemy 2 |
| Install | pip install pg-partsmith · extras: cli, redis-locks, pydantic-settings |
| Async | pg_partsmith.aio, on an AsyncEngine |
| Sync | pg_partsmith.sync — same class names, same arguments, no await |
| Source | https://github.com/bedrock-python/pg-partsmith |
How to read this page¶
Every page of this site is also served as raw Markdown at its own URL with .md in
place of the trailing slash — this page is /agents.md, the foreign-key guide is
/guide/foreign-keys.md — so anything the map below points at can be fetched as plain
text rather than scraped out of HTML. The Copy page control at the top of a page does
the same thing for a human with a chat window open. The one exception is the API
reference: its Markdown is a list of instructions to a docstring renderer rather than the
API, so it carries neither the control nor a .md twin — read it as HTML, or read the
docstrings in the source.
Top to bottom before writing code. Rules that hold or break the code is the section correctness lives in — those are the things the library will not save you from. Every name used below is in the public API; if you need something not listed here, fetch the page the documentation map points at rather than guessing a method that sounds plausible.
Scope¶
It does keep the partitions of a partitioned table in the shape you declare: create
ahead of the cursor, fill gaps in a set, detach what expired, drop after a grace period.
It reads the tree from the catalog, produces a plan you can inspect before applying it,
and moves rows in batches into partitions (partition_data) or back out into one table
(unpartition).
It does not create the parent table, its columns, its indexes or its constraints —
that DDL is yours; it runs no scheduler of its own; it needs no extension and no
superuser; it never issues CASCADE; it never touches a relation it does not own.
Mental model¶
A configuration is a scheme — the shape of the tree, level by level — and a lifecycle policy — when the partitions of the progression level appear and go.
plan()reads the catalog, compares it with the scheme and returns typed operations, each with the reason it is there, plus findings: what the planner saw and deliberately did not touch.apply()runs a plan under the table's lock, revalidating every destructive operation against the catalog first, and refusing a plan this configuration did not produce.PartitionToolkit.from_engine(engine, ...)builds the repository, the metadata provider, the locks, the service and the maintainer around one engine, giving each setting that belongs to two of them (marker_prefix,ddl_timezone,boundary_codec) exactly once.pg-partsmith(extracli) runsinspect/plan/validate/apply/backfillover a document and a DSN. The first three issue no DDL;applywithholds detaches and drops unless--allow-destructive.backfillispartition_datafrom the command line — the one-off migration of a DEFAULT partition's rows into the partitions they belong in,--batch-rows/--max-batches, exiting 2 while anything is left to move.plan --save FILEwrites the artifactapply --plan FILEreads back, and applying it is refused if it was made for another table or under a configuration that has since changed (--allow-config-driftoverrides). Exit codes: 0 nothing pending, 2 drift underplan --check, 3 findings or run issues, 4 configuration, 5 the database not reached, 6 lock held, 64 usage (never the parser's 2, which means drift here), 130 / 143 stopped by SIGINT / SIGTERM after cleaning up, 1 unexpected.--output jsonis the model dump under a versioned envelope;--output metricsis Prometheus text exposition for a node_exporter textfile, all gauges, prefixedpg_partsmith_;--write FILEputs any output in a file atomically;plan --locksprints the heaviest lock each operation takes;apply --ok-if-lockedexits 0 on a held lock. There is deliberately no--sql. Seeguide/cli.md.CommandHooks(both mirrors) runs a configured command per phase with thePartitionEventas JSON on stdin; a non-zero exit refuses the operation.PythonHooksruns a block of Python per phase witheventandlogin scope; raising refuses. In a document both are thehookssection (a command list, or{python: ...}/{python_file: ...}), honoured only underapply --allow-hooksorbackfill --allow-hooks; every block is compiled byvalidate. No sandbox is claimed. Hooks never fire duringplan. Seeguide/hooks-in-config.md.- The same CLI ships as
ghcr.io/bedrock-python/pg-partsmith:<version>with the command as its entrypoint: distroless, Python 3.14, no shell, no pip, UID 65532, signed, with an SBOM and provenance; tags are the exact version, a moving minor andlatestfor the newest release. Seeguide/container.mdfor the image andguide/running.mdfor every harness: plain Docker, Compose (docker compose run,service_completed_successfully, a cron sidecar), Swarm (swarm-cronjob), Kubernetes Pod / Job / CronJob / init container, the plan-then-apply Jobs, node_exporter textfile, CI, systemd. The DSN can also come from a secret file viaPG_PARTSMITH_DSN_FILE.pg-partsmith schemaprints the document's JSON Schema; validated examples of every document shape and of hook scripts live underexamples/(seeguide/examples.md). PartitionsDocumentis several tables and their wiring as one validated model — what a YAML or JSON file parses into.document.configs()gives everyTablePartitionConfig,document.config_for(name)one of them, andPartitionToolkit.from_options(engine, document.runtime)the wiring. The library parses no files: hand ityaml.safe_load(...)/json.loads(...)output.maintain()is both under one lock, and is what a scheduled tick calls.
Two kinds of level:
- progression level — RANGE windows, or a LIST over an
IntegerSequence. Open-ended, has a cursor, obeys the lifecycle policy. - set level — HASH buckets, LIST groups. Fixed membership: reconciled when incomplete, never expired.
The lifecycle unit is the partition directly under the root's progression level. It is what gets created, counted, hooked and expired as one — its whole subtree included.
A converged tree costs zero DDL. Running maintenance twice in a row is a no-op the second time; that is an integration test, not a promise.
Wiring¶
from sqlalchemy.ext.asyncio import create_async_engine
from pg_partsmith import PartitionGranularity, TablePartitionConfig
from pg_partsmith.aio import (
PartitionLifecycleService,
PartitionMaintainer,
PostgresAdvisoryLockManager,
PostgresMetadataProvider,
PostgresPartitionRepository,
)
engine = create_async_engine("postgresql+asyncpg://user:pass@host/db")
service = PartitionLifecycleService(
repo=PostgresPartitionRepository(engine),
metadata=PostgresMetadataProvider(engine),
locks=PostgresAdvisoryLockManager(engine),
hooks=None, # optional list of BasePartitionLifecycleHooks
)
config = TablePartitionConfig(
schema="public",
table_name="events",
partition_column="created_at",
granularity=PartitionGranularity.MONTH,
create_ahead_count=3,
retention_count=12,
)
plan = await service.plan(config) # no lock, no DDL
print(plan.describe())
result = await PartitionMaintainer(service).run_maintenance_safe(config)
The sync twin is the same text with sqlalchemy.create_engine, pg_partsmith.sync and no
await. Both modules export the same names.
Constructor options worth knowing:
PostgresPartitionRepository(engine, *, ddl_timezone="UTC", ddl_timeout_seconds=30.0, marker_prefix=None, drop_allow_unmanaged=False, drop_lock_timeout_ms=3000, drop_max_retries=3, drop_retry_delay=0.5, drop_max_backoff=300.0)PostgresMetadataProvider(engine, *, marker_prefix=None, boundary_codec=None, ddl_timezone=None)PostgresAdvisoryLockManager(engine, prefix="partitioner", acquire_min_interval_seconds=0.0)RedisDistributedLockManager(redis_client, prefix=…, ttl_seconds=300, acquire_min_interval_seconds=0.0)— needs theredis-locksextra; renews itself while long DDL runs.
marker_prefix must be the same on the repository and the provider — the first writes the
ownership marker, the second finds it — and PartitionLifecycleService refuses a pair that
disagrees. The provider's boundary_codec and ddl_timezone are used by
is_partition_closed alone, which also takes boundaries=config.time_boundaries and reads
both from there instead.
Configuration¶
TablePartitionConfig is a frozen Pydantic model with extra="forbid": an unknown keyword
is an error, not a hint. Its real fields are schema (alias — read it back as
config.db_schema), table_name, scheme, lifecycle and leaves.
Flat form¶
Sugar, accepted only for a RANGE root over a time axis:
TablePartitionConfig(
schema="public", # optional; qualifies every statement
table_name="events",
partition_column="created_at",
trailing_partition_columns=["tenant_id"], # composite key, optional
granularity=PartitionGranularity.MONTH, # HOUR DAY WEEK MONTH QUARTER YEAR
tz="UTC",
boundary_codec=UUIDv7BoundaryCodec(), # when the key encodes the instant
subpartition=HashPartitioning(key="tenant_id", modulus=4),
create_ahead_count=3, # default 6, current period included
retention_count=12, # default 12, current period included
)
Passing a flat field and scheme (or lifecycle) raises ValueError. Any other
topology must be spelled out.
Composed form¶
TablePartitionConfig(
table_name="issue_events",
scheme=RangePartitioning(
key="id",
boundaries=TimeBoundaries(granularity=PartitionGranularity.WEEK, codec=UUIDv7BoundaryCodec()),
child=HashPartitioning(key="organization_id", modulus=4),
),
lifecycle=LifecyclePolicy(
creation=CreateAhead(count=3),
retention=KeepNewest(count=12),
detach=DetachMode.AUTO,
drop=DropAfter(grace=timedelta(days=7)),
),
leaves=LocalLeaves(tablespace="fast", storage_parameters={"fillfactor": 90}),
)
LifecyclePolicy() with no arguments is CreateAhead(6), KeepNewest(12),
DetachMode.AUTO, DropAfter(grace=timedelta(0)) — it detaches and drops in the same
run. Pass drop=DropNever() if detached tables should be kept.
Levels¶
| Level | Constructor | Kind |
|---|---|---|
| RANGE | RangePartitioning(key, boundaries, child=None) |
progression |
| HASH | HashPartitioning(key, modulus, name_suffix="__h{remainder}", child=None) |
set |
| LIST, groups | ListPartitioning(key, groups=(ListGroup(name, values), …), include_default=False, default_name="other", name_suffix="__{name}", child=None) |
set |
| LIST, sliding | ListPartitioning(key, sequence=IntegerSequence(start=1), …) |
progression |
Nesting is child=, up to five levels including the root.
Boundaries¶
| Axis | Constructor |
|---|---|
| Calendar over a timestamp | TimeBoundaries(granularity=… | calculator=…, tz="UTC", codec=None) |
| Calendar over an encoded key | the same, with codec=UUIDv7BoundaryCodec() or EpochBoundaryCodec(unit) |
| Fixed-width integer windows | NumericBoundaries(step, origin=0, name_suffix="__{start}", cursor_source=…) |
| One value per partition | IntegerSequence(start=1, name_suffix="__{value}", cursor_source=…) |
Lifecycle vocabulary¶
| Slot | Options |
|---|---|
creation |
CreateAhead(count), CreateUntil(position), CreateNextIf(when) |
retention |
KeepNewest(count), KeepFor(age), KeepBehind(distance), ExpireIf(when) |
detach |
DetachMode.AUTO (concurrent where PostgreSQL allows), .CONCURRENT, .BLOCKING |
drop |
DropAfter(grace, when=None), DropNever() |
| predicates | SizeAbove(bytes), RowsAbove(rows), WindowAgeAbove(age), Unreferenced(), SqlPredicate(sql), Callback(fn, facts, label) |
| combinators | AllOf(...), AnyOf(...), Not(...) |
Rules take positional arguments too: CreateAhead(3), KeepNewest(12). A SqlPredicate
gets {partition} substituted with the qualified partition name. Unreferenced() expires
a partition only when no foreign key still points into it.
Generated names¶
The parent's name plus a suffix, and the whole thing must fit 63 bytes.
| Level | Suffix | Example |
|---|---|---|
| year / quarter / month | __2026, __2026_q3, __2026_08 |
events__2026_08 |
| week / day / hour | __2026_w35, __2026_08_28, __2026_08_28_14 |
events__2026_w35 |
| numeric window | __{start} |
queue__400000 |
| hash bucket | __h{remainder} |
events__2026_08__h3 |
| list group | __{name} |
regions__eu |
Service API¶
Every method takes the TablePartitionConfig as its first argument.
| Method | Lock | DDL | Returns |
|---|---|---|---|
inspect(config) |
no | no | ActualTree | None |
plan(config, *, mode=PlanMode.MAINTAIN, now=None, windows=None) |
no | no | MaintenancePlan |
apply(config, plan, *, continue_on_error=False, allow_config_drift=False) |
yes | yes | MaintenanceResult |
maintain(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False) |
yes | yes | MaintenanceResult |
reconcile(config) |
no | yes | MaintenanceResult |
ensure_partition(config, period_or_window_or_position) |
no | yes | PartitionInfo | None |
ensure_partitions(config, periods) |
no | yes | list[PartitionInfo] |
partition_data(config, *, batch_rows=10_000, max_batches=None) |
yes | yes | MigrationResult |
unpartition(config, into, *, batch_rows=10_000, max_batches=None, drop_emptied=False) |
yes | yes | MigrationResult |
create_future_partitions(config) |
no | yes | list[PartitionInfo] |
get_partitions_for_pruning(config) |
no | no | list[PartitionInfo] |
detach_old_partitions(config, partitions) |
no | yes | list[str] |
drop_detached_partitions(config, partition_names) |
no | yes | int |
maintain_lifecycle is the same method as maintain. PartitionMaintainer(service) adds
run_maintenance(...) and run_maintenance_safe(...) — same keywords; the safe one never
raises and reports the failure on result.error instead, which is what a scheduler wants.
PlanMode is MAINTAIN (the tick), RECONCILE (converge only, create nothing ahead) or
EXPLICIT (only the windows named in windows=).
On a MaintenancePlan: .operations, .findings, .cursors, .generated_at,
.config_fingerprint, .creates / .attaches / .detaches / .drops, .is_noop,
.only(*kinds), .without(*kinds), .describe(). Filtering a plan and applying the rest
is supported and is how you split creation from pruning across schedules.
Every operation dumps its capabilities (the heaviest lock it takes, and whether it can
run in a transaction block) and is_destructive beside its fields; both are computed on the
way out and ignored on the way in, so the round trip holds.
Serialize a plan with model_dump_json(by_alias=True) — the same vocabulary a
configuration uses (kind, method, schema) — and read it back with
MaintenancePlan.model_validate_json. apply() refuses a plan made for another table, or
one whose config_fingerprint no longer matches config.fingerprint, with
PlanConfigMismatchError; pass allow_config_drift=True to apply it anyway. OID
revalidation asks whether this is still the same relation, the fingerprint whether it is
still the same intent.
Results:
MaintenanceResult—created_count,repaired_count,attached_count,detached_count,dropped_count,duration_ms,error,issues,.success,.maintenance_plan.MigrationResult—rows_moved,batches,partitions,complete,issues. Call again whilecompleteisFalse.MaintenanceIssue—step(createreconcileattachdetachdropmove),error("TypeName: message"),partition_name.
Hooks¶
class ColdStorageHooks(BasePartitionLifecycleHooks):
async def before_drop(self, event: PartitionEvent) -> None:
await export(event.partition.name) # raising aborts this drop; it is retried next tick
Nine methods, each taking one PartitionEvent(phase, config, partition, window, operation):
before_create / after_create, before_attach / after_attach (a detached partition
coming back), before_detach / after_detach, before_drop / after_drop, and
on_event, which fires for every phase in addition to the named method.
event.table_name is the root; event.operation.reason says why the operation is in the
plan; event.window is the period, or None for a member of a root HASH or LIST.
before_* exceptions abort that operation; after_* exceptions are logged. Hooks fire for
lifecycle units — partitions directly under the root — never once per leaf of a subtree.
Rules that hold or break the code¶
- Give it an
Engine/AsyncEngine, never aSession/AsyncSession. DDL runs on its own connection and commits immediately; a session's transaction is the wrong shape for it. - There is no scheduler inside. Call
maintain()from cron, APScheduler, Celery beat, a Kubernetes CronJob — whatever you already run. Once per period is not enough; run it often enough that a missed tick is harmless. - The parent table is yours to create. pg-partsmith attaches partitions to a table
that already exists and is already
PARTITION BY …; it does notCREATE TABLEthe root and does not manage its indexes or constraints. - Ownership is decided by bounds, not by name. An attached partition whose bounds sit
on the scheme's grid is a lifecycle partition; one whose bounds do not is reported as
unmanaged_partitionand is never detached, dropped or counted — however old it looks. - Only marker-tagged tables are dropped. The marker is a
COMMENTwritten before theDETACH, and it records when the detach happened, which is what a grace period counts from. A table detached by someone else is adopted deliberately, withrepo.adopt_partition(...). - A plan goes stale. Destructive operations revalidate the relation by OID at the
moment they run; a table recreated between plan and apply raises
PlanStaleError(recorded as an issue undercontinue_on_error=True). create_ahead_countandretention_countinclude the current period.create_ahead_count=3means this month and the next two.- The default lifecycle drops.
DropAfter()has a zero grace, so an expired partition is detached and dropped in the same run. ChooseDropAfter(grace=…)orDropNever()consciously. lifecycleis meaningless for a scheme with no progression level — a rootHashPartitioningor a staticListPartitioninghas a fixed set of partitions, and the policy is ignored rather than obeyed.- During
partition_data, a window's rows are invisible through the parent between the first batch and the attach: PostgreSQL will not attach a partition while DEFAULT still holds rows for it, so no ordering keeps them visible throughout. Rows are never in two places, and never lost. The attach takes the rows that arrived during the last batch and goes live in one transaction, so a window the application is writing into is attached like any other; writers wait at the parent for that commit and are then routed into the new partition. A window that still cannot be attached comes back as amoveissue withcomplete=False—partition_datadoes not raise for it. - Flat and composed spellings do not mix, and
extra="forbid"means a misspelled keyword raises rather than being ignored. schema=goes in,config.db_schemacomes out — the field is aliased to avoid shadowing Pydantic's ownschema().- Names must fit 63 bytes with the suffix, and the scheme may be five levels deep at most, root included.
- Two maintainers are safe.
apply,maintain,partition_dataandunpartitiontake the table's lock; the granular methods do not, and lost races are recognised and reported rather than retried into a failure.
Common mistakes¶
# WRONG — a session, and a config field that does not exist
service = PartitionLifecycleService(repo=PostgresPartitionRepository(async_session), ...)
config = TablePartitionConfig(table_name="events", retention_days=90)
# RIGHT
service = PartitionLifecycleService(repo=PostgresPartitionRepository(engine), ...)
config = TablePartitionConfig(
table_name="events",
scheme=RangePartitioning(key="created_at", boundaries=TimeBoundaries(granularity=PartitionGranularity.DAY)),
lifecycle=LifecyclePolicy(retention=KeepFor(timedelta(days=90))),
)
# WRONG — flat sugar for a topology it does not cover
TablePartitionConfig(table_name="tasks", partition_column="task_id", granularity=..., partition_type="hash")
# RIGHT
TablePartitionConfig(table_name="tasks", scheme=HashPartitioning(key="task_id", modulus=8))
# WRONG — one call and done
await service.partition_data(config)
# RIGHT — batched on purpose; loop until it says it is finished
while not (await service.partition_data(config, batch_rows=10_000)).complete:
...
# WRONG — assuming a plan applies itself, or that a result with issues is a failure
plan = await service.plan(config) # this issued no DDL at all
# RIGHT
result = await service.apply(config, plan)
if not result.success: # result.error is the fatal case
alert(result.error)
for issue in result.issues: # non-fatal, but they are why nothing happened
log.warning("%s %s: %s", issue.step, issue.partition_name, issue.error)
Errors¶
InvalidPartitionConfigError (the config does not match the table),
LockAcquisitionError (another maintainer holds the lock), PlanStaleError,
PlanConfigMismatchError (the plan was not made from this configuration),
PartitionTopologyError, PartitionReferencedError (a detach PostgreSQL refused because
rows still reference the partition), RowMoveRefusedError (a row move an incoming foreign
key's ON DELETE action would corrupt), PartitionAlreadyExistsError,
PartitionNotFoundError, PartitionAttachedError, PartitionDetachInProgressError,
UnmanagedPartitionDropError, DropRetryExhaustedError — all deriving from
PartitionError. The middle group is normally recorded on result.issues rather than
raised; see findings and issues for the full table of what each
one means and what to do about it.
Documentation map¶
Fetch a page when the task is the one named beside it.
| Page | Read it when |
|---|---|
| Installation | choosing extras, drivers, a PostgreSQL version |
| Your first partitioned table | writing the very first integration end to end |
| Running it in production | scheduling, locks, alerting, rollout |
| A multi-tenant event store | a worked nested scheme with a real shape |
| How it works | the flow from catalog to plan to DDL |
| Partition schemes | nesting, composite keys, sliding lists, introspection |
| Boundaries, cursors, calendars | the grid, cursor sources, codecs, custom calendars |
| Lifecycle policies | predicates, facts, combinators, what a policy can see |
| The maintenance plan | operation and finding types, filtering, serializing |
| Ownership and safety | the marker, adoption, what is never touched |
| Executing DDL | transactions, timeouts, attach-last, concurrency |
| Leaf backends | tablespaces, storage parameters, foreign tables |
| Configure a table | picking between the flat and composed forms |
| Schedule maintenance | cron, APScheduler, Celery, Kubernetes |
| Monitor and alert | turning results and issues into metrics |
| Query a partitioned table | what prunes, what does not, encoded keys |
| Backfill partitions | giving existing data its windows |
| Partition an existing table | the DEFAULT-attach migration, partition_data |
| Migrate from pg_partman | adopting a tree another tool built |
| Change a scheme safely | granularity, modulus or key changes |
| Archive before dropping | before_drop, exports, cold copies |
| Handle foreign keys | Unreferenced(), refused detaches, refused row moves |
| Tier cold data | ForeignLeaves, postgres_fdw, ClickHouse |
| Custom calendars and codecs | your own period calculator, names or key encoding |
| Extend the library | replacing a repository, provider, lock or executor |
| Troubleshoot | a specific error message or a run that did nothing |
| Recipes | the shape of a real system close to yours |
| API reference | an exact signature, field or docstring — HTML only, see above |
| Configuration fields | every field, type and default |
| Findings and issues | what a reason code means and what to do |
| Environment settings | configuring a table from env vars or JSON |
| Glossary | a term used here without explanation |
| PostgreSQL semantics | why a refusal exists — verified server behaviour |
| RFC 0001 | the design of schemes and lifecycle policies |
| Final report | what 1.0 changed, the migration matrix per project, what is not covered |
| Changelog | what changed between versions |