OSS research: how production systems manage PostgreSQL partitions¶
Companion to RFC 0001. Ten open-source systems were read
at source level on 2026-08-28 (branch master/main of each, commits noted where they
matter). For each: what is partitioned, who creates and removes partitions, how failures are
handled, what is application-specific, and which generic primitive replaces the custom code.
Every claim below was read in the cited file; things that could not be verified are marked.
1. GlitchTip (glitchtip/glitchtip-backend, Python/Django)¶
Sources: glitchtip/partition_manager.py (UUID7Helper, PartitionManager),
glitchtip/management/commands/maintain_partitions.py, glitchtip/tasks.py
(perform_maintenance), glitchtip/cold_storage.py, glitchtip/settings.py,
apps/issue_events/migrations/sql/create_events_v2.sql, apps/logs/migrations/sql/create_logs_v1.sql,
apps/uptime/migrations/sql/create_uptime_v2.sql, glitchtip/tests.py; blog post
"GlitchTip 6 released" (2026-02-03). Manager history: 18 commits 2026-01-15 → 2026-08-04.
- What is partitioned.
issue_events_issueevent,logs_logeventand the span staging table arePARTITION BY RANGE (id)on a UUIDv7id, daily, each dayPARTITION BY HASH (organization_id);uptime_monitorcheckisRANGE (id)weekly → HASH; six aggregate tables areRANGE (date)weekly → HASH;issue_events_issueindexandperformance_transactiongroupare plainHASH (organization_id)roots created once by a migration. Every table has a composite primary key(id|…, organization_id). - Bounds.
UUID7Helper._uuid7_for_timestamp(dt, min_random=True): 48-bit ms timestamp, version 7, variant 2, all random bits zero; both bounds use the minimum UUID of their instant so adjacent days are contiguous; the timestamp is clamped to[0, 2^48-1]. - Creation. Per period:
CREATE TABLE IF NOT EXISTS p_YYYYMMDD PARTITION OF parent FOR VALUES FROM (...) TO (...) PARTITION BY HASH (organization_id)then one… FOR VALUES WITH (MODULUS n, REMAINDER i)per bucket,nfromsettings.PARTITION_HASH_BUCKETS(default 2 since 2026-08-04; was 4, originally 16;0yields a plain leaf). Lookahead: 7 daily, 3–4 weekly. Triggered by theperform-maintenancescheduled task at 05:00, at startup in all-in-one mode, and inside migrations — never on insert (a missing partition surfaces as anIntegrityError). - Reconciliation.
execute_partition_creationreadspg_class/pg_inherits/pg_get_expr(relpartbound), regexesmodulus N, and: same modulus → create the missing_h{i}by name; complete set at another modulus → leave (info); incomplete set at another modulus → fill at the historical modulus (warning); plain leaf, mixed moduli or non-hash children → skip with a warning. Commit 7beeca02 explains why the catalog check comes first:CREATE TABLE IF NOT EXISTS … PARTITION OFtakesAccessExclusiveLockon the root and siblings before checking existence and deadlocked with inserts. - Retention. Two paths.
drop_old_partitionsparsesYYYYMMDDfrom the name (catalog bounds are fetched but unused) and issuesDROP TABLE IF EXISTS … CASCADEdirectly.cold_storage.py(events/logs) exports per-org Parquet, thenALTER TABLE … DETACH PARTITION … CONCURRENTLYin autocommit with three deadlock retries and a plain-detach fallback, thenDROP TABLE IF EXISTS.cleanup_old_issueswaits retention + 7 days. No advisory lock: single-runner semantics come from the task scheduler. - Lock budget is a first-class concern:
max_locks_per_transaction=512in compose, the default bucket count halved, uptime moved from daily to weekly ("1,946 → 118 locks per page load"), no DEFAULT partitions, no DB-level foreign keys toIssue. - App-specific: the
organization_idhash column, the hard-coded table list and cadences, theGLITCHTIP_*_RETENTION_DAYSsettings, the DuckDB export coupling,import_legacy_eventscreating plain leaves. - Unverified: which
psql_partitionpackage is imported at runtime; no tests fordrop_old_partitionswere found; catalog queries filter byrelnameonly (no schema).
pg-partsmith 1.0 verdict (the question the RFC asks): can GlitchTip replace
PartitionManager?
| Concern | Verdict | With |
|---|---|---|
| Daily/weekly creation ahead | YES | RangePartitioning(key="id", boundaries=TimeBoundaries(granularity=DAY, codec="uuidv7")), CreateAhead(7) |
| Nested HASH per period | YES | child=HashPartitioning(key="organization_id", modulus=2, name_suffix="_h{remainder}") |
| UUIDv7 bounds | YES | UUIDv7BoundaryCodec — bit-identical layout (min UUID both ends, 48-bit clamp) |
| Missing-bucket repair, historical modulus, legacy leaves, mixed moduli | YES | planner rules HASH_GAP, HASH_GAP_HISTORICAL_MODULUS, MODULUS_PRESERVED, LEGACY_LEAF, NON_UNIFORM_* |
| Retention by bounds instead of names; detach-concurrently-then-drop | YES | KeepNewest/KeepFor, DetachMode.AUTO, DropAfter |
Custom names (p_YYYYMMDD, _h{i}) |
YES | a PeriodCalculator subclass in TimeBoundaries(calculator=…) + name_suffix |
| Cold storage export before drop | YES | before_detach/before_drop hooks; raising aborts and retries next tick |
Query-layer routing (UUIDv7 range predicates, organization_id filters) |
NO (by design) | stays in the application; UUIDv7BoundaryCodec.min_uuid_for is exposed for it |
Lock-budget tuning, max_locks_per_transaction |
NO (documentation) | operator concern; the planner's zero-DDL steady state helps |
2. GitLab (gitlab-org/gitlab, Ruby)¶
Sources: lib/gitlab/database/partitioning/partition_manager.rb,
time/{base,daily,weekly,monthly}_strategy.rb, time_partition.rb, int_range_strategy.rb,
sliding_list_strategy.rb, ci_sliding_list_strategy.rb, single_numeric_list_partition.rb,
detach_eligibility.rb, detached_partition_dropper.rb, with_partitioning_lock_retries.rb,
partition_monitoring.rb, list/convert_table.rb, postgres_partition.rb,
app/models/postgresql/detached_partition.rb, app/models/concerns/partitioned_table.rb,
doc/development/database/partitioning/*.md; MRs !65093, !67056, !69850, !160342, !162590,
!184045, !184290, !184596, !190634, !231242, !244534, !251847.
- Strategies:
daily/weekly/monthly(date range),sliding_list,ci_sliding_list,int_range. Hash and static list exist only as migration helpers. - Planner shape:
currentfrompg_get_expr(relpartbound)(viewspostgres_partitions),desiredcomputed purely,missing = desired − current,extra = current − desired; identity is(table, name, bounds), never a parsed name. - Time strategies: desired = a
MINVALUEcatch-all<table>_000000(until retention starts) plus one partition per period up to a headroom (monthly 6 months, daily 28 days, weekly 4 weeks);retain_for:is required (a Duration or:ever);retain_non_empty_partitionsskips partitions that hold data. - Sliding list: callbacks
next_partition_if(active)/detach_partition_if(partition)receive aSingleNumericListPartition(.value,.partition_name,.data_size); the active partition is the highest LIST value; extras are all-but-newest taken oldest-first whiledetach_partition_ifholds, never the partition equal to the column DEFAULT; routing is via the column DEFAULT (set inafter_adding_partitions, repaired invalidate_and_fixunderLOCK TABLE … ACCESS EXCLUSIVE). Typical callbacks: rotate when the oldest row in the active partition is older than a day; detach when nostatus_pendingrows remain. sync_partitions: skip if not partitioned → Redis lease per table (1 h, non-blocking) →validate_and_fix→ plan both lists → create → detach →ANALYZE (SKIP_LOCKED)whenanalyze_intervalelapsed;rescue StandardErrorper table.- Create:
CREATE TABLE IF NOT EXISTS gitlab_partitions_dynamic.x (LIKE parent INCLUDING ALL)thenATTACH PARTITION(SHARE UPDATE EXCLUSIVE — MR !190634), inside a lock_timeout ladder (0.1–1 s, 20 attempts, raise on exhaustion). - Detach:
DetachEligibilitydefers a partition while any foreign key references the parent (MR !251847, 2026-08-27); thedetached_partitions(table_name, drop_after)row is written before the plainDETACH PARTITION. - Drop:
RETAIN_DETACHED_PARTITIONS_FOR = 1.week, per-modelretain_detached_partitions_for:(!244534);MAX_PARTITION_SIZE = 150.gigabytes— a larger partition getsdrop_aftermoved to the next Saturday (!184045/!184596);DetachedPartitionDropper(cron 03:20) refuses partitions still inpg_inherits, locks the row, drops FKs one per short transaction,DROP TABLE IF EXISTS, sleeps a minute between items. - Int range:
partition_sizefrom the sequence'smin_value, "current max" is the last partition's upper bound (nevermax(id)), six empty trailing partitions kept, no retention; deprecated ("incompatible with cells"). - Monitoring: Prometheus gauges
db_partitions_present/missing/extra{table}by re-running the planner; no dry-run in the manager. - App-specific: fixed schemas
gitlab_partitions_dynamic/_static,ci_partitionsand the application-writtenpartition_id, multi-database plumbing, loose-foreign-key triggers on new partitions, ops feature flags.
pg-partsmith 1.0 verdict, feature by feature:
| GitLab feature | Verdict | Notes |
|---|---|---|
date RANGE with headroom and retain_for |
YES | CreateAhead/CreateUntil, KeepFor |
MINVALUE catch-all partition |
PARTIAL | inspected and preserved (UNBOUNDED_PARTITION), never created by the planner |
retain_non_empty_partitions |
YES | ExpireIf(AllOf((KeepFor(...), Not(RowsAbove(0))))) (estimate-based) or an SqlPredicate |
| int RANGE | YES | NumericBoundaries(step) with max(key)/sequence cursor; GitLab's "six empty trailing partitions" rule is CreateAhead(6) |
| HASH / static LIST | YES | HashPartitioning / ListPartitioning roots and levels |
sliding LIST (next_partition_if / detach_partition_if) |
YES | ListPartitioning(sequence=IntegerSequence(...)) with CreateNextIf / ExpireIf; the cursor is the newest partition; the column-DEFAULT routing invariant stays application-side |
| state-dependent detach | YES | ExpireIf(SqlPredicate(...)), Callback |
| detached grace period, per-table | YES | DropAfter(grace=…) per config; detach instant recorded in the marker |
| large-partition drop scheduling | YES | DropAfter(when=Callback(...)) with SizeAbove facts (the Saturday rule is a two-line callback) |
| FK-based detach eligibility | YES | Unreferenced() — the condition PostgreSQL enforces on DETACH (23503); a refused detach is recorded as an issue |
| lock_timeout ladder | PARTIAL | drop_lock_timeout_ms + retries on drop; detach has a DDL timeout, no ladder |
| Redis lease | YES | RedisDistributedLockManager / advisory locks |
| ANALYZE after create | NO (hook) | after_create hook |
| gauges | YES (data) | plan() is the gauge source: len(plan.creates), len(plan.detaches), findings |
3. Centrifugo (centrifugal/centrifugo, Go, internal/pgoutbox/partitioner.go @ ec700c1d)¶
Partitioner{Pool, ParentTable, CleanupInterval, LookaheadDays, RetentionDays, ErrorFn}maintains an existingPARTITION BY RANGE (created_at)parent withPRIMARY KEY (id, created_at)and no DEFAULT partition, forcf_stream_history,cf_map_stream,cf_controller_messages.- Create: for each of
LookaheadDaysdays,CREATE TABLE IF NOT EXISTS {parent}_{YYYY}_{MM}_{DD} PARTITION OF {parent} FOR VALUES FROM ('YYYY-MM-DD 00:00:00+00') TO (…)— UTC-explicit bounds to dodge session-timezone drift. - Drop:
RetentionDays <= 0= never; list children viapg_inheritsfiltered tocurrent_schema(); parse the date from the last three_components of the name;DROP TABLE IF EXISTSdirectly (no DETACH); per-partition errors go toErrorFn. Names that do not parse are "user-managed" and skipped — the naming convention is the only ownership marker. Run= ticker doing ensure + drop; callers also ensure at startup; no cross-node lock (every node runs the worker; idempotent DDL only).- Unverified: docs say PostgreSQL 16+, a code comment says 13+.
Verdict: YES — daily RangePartitioning + CreateAhead(lookahead + 1) + KeepFor,
and the hand-attached partitions Centrifugo protects by name are protected in 1.0 by
bounds: an attached partition whose bounds are not a window of the daily grid is
UNMANAGED_PARTITION and never touched (verified by an integration test).
4. PGMQ (pgmq/pgmq, SQL extension, pgmq.sql v1.12.0 lines 1313–1509)¶
create_partitioned(queue_name, partition_interval='10000', retention_interval='100000'): an integer interval text →PARTITION BY RANGE (msg_id), otherwise →RANGE (enqueued_at); requirespg_partman(_ensure_pg_partman_installed).- pg_partman wiring:
create_parent(p_control := msg_id|enqueued_at, p_interval := <text>), defaultpremake4, thenUPDATE part_config SET retention = <retention_interval>, retention_keep_table = false, retention_keep_index = true, automatic_maintenance = 'on'. Semantics: interval = per-partition width; retention = age (time) ormax(msg_id) − N(numeric). PGMQ never callsrun_maintenance; it relies on thepg_partman_bgwbackground worker.
Verdict: YES — RangePartitioning(key="msg_id", boundaries=NumericBoundaries(step=10_000))
with CreateAhead(4) and KeepBehind(100_000) is the same policy without the extension,
the BGW, or shared_preload_libraries; the queue code itself is untouched.
5. Hatchet (hatchet-dev/hatchet, Go + SQL)¶
Sources: sql/schema/v1-core.sql (9 daily RANGE tables), sql/schema/v1-olap.sql
(11 daily/weekly RANGE tables plus v1_task_events_olap_tmp HASH(task_id) and
v1_task_status_updates_tmp HASH(dag_id)), SQL helpers create_v1_range_partition,
create_v1_weekly_range_partition, get_v1_partitions_before_date,
create_v1_hash_partitions, swap_v1_payload(s)_olap_partition_with_temp; Go
UpdateTablePartitions (task.go / olap.go), partition_lease.go, loader.go,
pkg/config/limits/limits.go; issue #3424.
- Create:
CREATE TABLE (LIKE parent INCLUDING INDEXES …)+ autovacuum/fillfactor reloptions +ATTACH PARTITION, named<table>_<YYYYMMDD>; horizon today + tomorrow, every 15 minutes and at startup; replicas excluded by a 15-minute lease row. - Retention:
SERVER_LIMITS_DEFAULT_TENANT_RETENTION_PERIOD(720h) with core/OLAP overrides; partitions older thannow − retentionare listed by the date in their name, then on a dedicated non-PgBouncer connection:SET lock_timeout='1min'→DETACH … CONCURRENTLY→DETACH … FINALIZEon "already pending detach" →DROP TABLE; 55P03 becomesErrPartitionLockConflict. - HASH: per-child readers with
FOR UPDATE SKIP LOCKEDonv1_task_events_olap_tmp_<n>— "process batches of events in parallel without needing to place conflicting locks on tasks";create_v1_hash_partitionsrefuses to shrink the modulus. - Issue #3424 (closed stale, no maintainer reply): no visibility into sizes or the next
cleanup, no purge endpoint, manual
pg_total_relation_size+DETACH … CONCURRENTLY; DROP TABLEworkaround that bypasses application callbacks; asks for a dry run with row and disk estimates.
Verdict: YES for the lifecycle (daily RangePartitioning, CreateAhead(2), KeepFor(720h),
DetachMode.AUTO with pending-detach finalization, advisory lock instead of the lease
row), YES for the root HashPartitioning(key="task_id", modulus=4), and the operator
gap is exactly plan() with SizeAbove/RowsAbove facts and plan.describe().
Reloptions/fillfactor per partition stay an after_create hook.
6. pg-trx-outbox (darky/pg-trx-outbox, TypeScript, @ f5f30c1e)¶
- README-only DDL:
PARTITION BY HASH (key),PRIMARY KEY (id, key), childrenpg_trx_outbox_{0,1,2} … WITH (MODULUS 3, REMAINDER n)created by the user; the library creates nothing and has no maintenance.outboxOptions.partition?: numbermakestransfer.tsread the childpg_trx_outbox_<n>directly withFOR UPDATE SKIP LOCKED, so the suffix must equal the remainder by convention.
Verdict: YES — HashPartitioning(key="key", modulus=3, name_suffix="_{remainder}") creates
and repairs the set idempotently; get_actual_tree gives consumers the
(modulus, remainder) → name mapping from the catalog.
7. Hookdeck Outpost (hookdeck/outpost, Go, issue #249)¶
- Migrations declare
eventsandattemptsasPARTITION BY RANGE (time)withPRIMARY KEY (time, id)and only*_defaultDEFAULT partitions;pglogstore.gohas no partition logic; the README recommends monthly partitions. - Issue #249 (open since 2025-02-25) proposes: at
apistartup ensure partitions through the end of next year, then drop stale partitions per configured retention; pg_partman vs in-app undecided. Nothing implemented since; issue #1027 (2026-08-10) re-raised it and the maintainer answered "not on the roadmap", recommending pg_partman/pg_cron or batchedDELETEs; docs PR #1032 (merged 2026-08-19) states "Outpost has no built-in retention for PostgreSQL".
Verdict: YES — the config that replaces the planned custom workflow:
TablePartitionConfig(
table_name="events",
scheme=RangePartitioning(key="time", boundaries=TimeBoundaries(granularity=PartitionGranularity.MONTH)),
lifecycle=LifecyclePolicy(
creation=CreateUntil(datetime(next_year + 1, 1, 1, tzinfo=UTC)),
retention=KeepFor(timedelta(days=retention_days)),
),
)
run at startup under the advisory lock (replicas that lose the lock skip the tick). Rows
already in events_default are moved into each new monthly partition by DEFAULT
reconciliation as it is attached.
8. ColdFront (pgEdge/coldfront, Go)¶
Sources: docs/architecture.md, internal/partition/{reconcile,boundary,catalog}.go
and the archiver/cutover code (names as in the report).
Spec{Parent, Schema, Column, Period, Premake, RetentionInterval, Boundary, LeafPrefix, Strategy}+RunReconcile= premake → ensure-current → expire.Boundaryinterface:Literal(t)/Parse(lit)for timestamp, UUIDv7 and snowflake keys; bounds parsed frompg_get_expr(relpartbound)withMINVALUE/MAXVALUE/infinitysentinels; cutoff computed in the database ($1::timestamptz - $2::interval).- Expiry:
DETACH … CONCURRENTLYon an autocommit*pgx.Conn(RunReconcilerequires a non-transactional handle), thenDROP TABLE IF EXISTS, orexpiration_strategy='detach'keeps the table; the tiered archiver exports to Iceberg and then detaches inside a transaction underlock_timeout=100ms, retrying only 55P03. - Multi-node: Spock replicates
CREATE … PARTITION OFandDROP TABLEbut skipsCONCURRENTLY, sodetachOnPeersre-runs the concurrent detach on every peer. - Registration refuses DEFAULT partitions, UNLOGGED relations, case-only name clashes,
_prefixes and over-long names; 2-level LIST→RANGE provisioning from avalues_sourcequery.
Verdict: YES for the lifecycle design — DetachMode, DropNever
(expiration_strategy='detach'), codecs (UUIDv7BoundaryCodec; a snowflake codec is a
ten-line RangeBoundaryCodec), before_drop hooks for "export, verify, then drop", and
LIST → RANGE nesting; the peer fan-out of non-transactional DDL stays outside the library.
9. pg_partman (pgpartman/pg_partman @ development c737cc1d, extension 5.5.0)¶
Used as an edge-case catalogue. What it needed to configure (sql/tables/tables.sql
part_config): control column, interval, type, premake, retention,
retention_keep_table, retention_keep_index, retention_schema, infinite_time_partitions,
datetime_string, automatic_maintenance, sub_partition_set_full, inherit_privileges,
constraint_cols/optimize_constraint, epoch, template_table, date_trunc_interval,
ignore_default_data, time_encoder/time_decoder, detach_before_drop, maintenance_role,
maintenance_last_run.
Lessons carried into 1.0:
- Encoder/decoder are SQL function names (
timestamptz → key type,text → timestamptz); bounds are attached as encoded literals, names stay calendar-based — the same split asRangeBoundaryCodec. 5.5.0 had to quote those names (CVE-2026-61781/61817/61818); Python codecs avoid the class entirely. - Integer sets are data-driven: start =
max(control)of the parent, maintenance reads the highest non-empty child, retention drops a child whenretention <= max − upper_bound—NumericBoundarieswithCursorSource.MAX_KEYandKeepBehind(distance). - DEFAULT handling: pg_partman never pre-checks; since 5.5.0 a failing set is skipped with a warning (before, the whole run aborted). pg-partsmith keeps its stronger reconciliation (move rows, retry, restore on failure).
- Template tables carry what PostgreSQL does not propagate: non-key unique indexes and
their tablespaces, UNLOGGED, reloptions, toast reloptions; pg_partman also copies
per-column statistics targets and REPLICA IDENTITY.
LocalLeaves(tablespace, storage_parameters, inherit_privileges)is the declarative form of the first three; statistics targets and replica identity stay hook material. - Retention flow: ascending scan, expire when
upper_bound < reference − retention(from the catalog, never the name), never the last child, plain DETACH → optional index / publication drop → DROP orSET SCHEMA. - Maintenance: global transaction advisory lock, no-op on replicas
(
pg_is_in_recovery()), per-set exception isolation,infinite_time_partitionssubstitutesnowwhen data stops (moot for a clock-driven planner). - Caveats worth repeating in docs: session-timezone-dependent names and bounds (run in UTC; DST breaks hourly), identity columns only via parent inserts, NULL keys go to DEFAULT, FK-referenced tables need detach-before-drop.
Capability comparison (core / extension / out of scope / future):
| pg_partman | pg-partsmith 1.0 |
|---|---|
| premake, retention (time and id), detach-only, DEFAULT reconciliation, encoded keys, subpartitioning, advisory lock, per-set error isolation, timezone discipline, adoption of an existing set | core |
| privileges, tablespace, reloptions (template properties) | core (LocalLeaves) |
retention_schema, index/publication drop on detach, p_analyze, statistics/replica identity propagation |
extension (hooks) |
partition_data_proc / partition_data_async batch movers, undo_partition, conversion of a plain table |
core (partition_data, unpartition) |
BGW scheduler and GUCs, maintenance_order, automatic_maintenance, jobmon |
out of scope (the caller's scheduler) |
pg_is_in_recovery() guard, check_default() monitoring helper |
future |
10. pg_clickhouse (ClickHouse/pg_clickhouse, doc/pg_clickhouse.md, doc/offload-partition.sql)¶
CREATE FOREIGN TABLE events_2023 PARTITION OF events FOR VALUES FROM … TO … SERVER ch_svr OPTIONS (table_name 'events')beside a localCREATE TABLE events_2024 PARTITION OF events …; the offload helper merges contiguous single-column RANGE locals into one foreign table (inlineCHECKsoATTACHskips the scan), copies, drops the locals, attaches.- PostgreSQL restrictions: a foreign partition is allowed only on an index-free parent
(verified:
42809 cannot create foreign partition of partitioned tablewhen a PK exists);DROP TABLEonrelkind='f'fails withis not a table; DETACH (also CONCURRENTLY) and ATTACH work; a DEFAULT partition blocks concurrent detach.
Verdict: introspection is exact (RelationKind.FOREIGN on the node,
FOREIGN_PARTITION finding under a local-leaves configuration, never dropped, never
detached) and the tree with a foreign leaf plans and maintains normally around it;
ForeignLeaves(server, options) creates every leaf as a foreign table and manages it
through the whole lifecycle (COMMENT ON FOREIGN TABLE marker, DROP FOREIGN TABLE) —
both verified by integration tests with a postgres_fdw loopback. The offload of an
existing local partition (copy, drop, attach foreign) stays a hook-driven workflow.
Cross-cutting patterns¶
- Nobody trusts names for truth except when they have nothing else. GitLab, ColdFront,
pg_partman and GlitchTip's reconciliation read
pg_get_expr(relpartbound); Centrifugo, Hatchet and GlitchTip'sdrop_old_partitionsparse names for retention. 1.0 reads bounds everywhere and parses names only to recognise a detached orphan. - Every serious system separates "what should exist" from "what to do now": a
catalog snapshot, a pure desired set, a diff, then DDL (GitLab explicitly; GlitchTip and
ColdFront implicitly). That is the
plan()/apply()split. DETACH CONCURRENTLYforces a non-transactional executor (Hatchet, ColdFront, GlitchTip cold storage) and a story for the pending-detach state (HatchetFINALIZE).- Detach and drop are different events (GitLab's
detached_partitionswithdrop_after, ColdFront'sdetachstrategy, GlitchTip's export-then-drop). - Ownership is the unsolved problem everywhere: naming conventions (Centrifugo, GlitchTip), a registry (pg_partman), or nothing (Outpost). Alignment with the scheme's grid, derived from the catalog, needs no table and protects the cases the conventions were written for.
- Operators want to see the plan (Hatchet #3424; GitLab's gauges; ColdFront's
--dry-run/--print-sql).