Skip to main content

Partition Lifecycle & Retention Management

A partition has a life: it is created before it is needed, absorbs writes while it is current, is queried less as it ages, and eventually costs more to keep than it earns. Managing that arc is what turns partitioning from a query optimisation into a storage strategy. This topic sits under Partitioning Implementation Patterns & Routing and picks up where automated partition creation workflows ends — creation is one edge of the lifecycle, and everything after it is here.

Problem Framing

A metrics table has been partitioned daily for three years. Nobody has ever deleted anything, because the retention policy says “keep at least 13 months” and no one was asked to decide the maximum. The table now carries 1,095 partitions, the nightly backup takes eleven hours, planning time on the hot query has tripled, and the storage bill is four times what the data younger than a year would cost.

None of this was a decision. Each individual month looked affordable, the partitions were created automatically, and no job existed to remove them. That is the default outcome of a partitioning project that automates creation and stops there: growth is automatic, removal is manual, and manual work that has no deadline never happens.

The Four Stages

Every partition passes through four stages, and each has a different cost profile, access pattern and correct storage medium.

Pre-created, hot, warm, archived: the four stages and their transitions A pre-created partition holds no rows and exists only so inserts never fail. It becomes hot when the calendar reaches it, taking all writes and most reads on fast storage. It cools to warm when the next partition opens, becoming read-mostly and a candidate for compression. Finally it is detached, archived to object storage and dropped. Each transition is a scheduled job rather than a manual action. 1 · pre-created 2 · hot 3 · warm 4 · archived empty, awaiting rows costs nothing but a catalog row created N months ahead all writes, most reads local NVMe, aggressive autovacuum indexes fully cached read-mostly, occasional backfill network storage, compressible vacuum freeze once, then stable detached and dumped object storage, restore on demand dropped after verification calendarreaches it next partitionopens retentioncutoff passes Every arrow is a scheduled job, and every job needs its own alert. The three that matter: — newest partition bound is less than N days ahead of now → creation has stopped — oldest partition is older than the retention window → retention has stopped — a detached table has existed for more than 7 days → archival stalled and is leaking disk All three are silent failures: nothing errors, queries keep working, and the cost appears weeks later on a bill or a p99 chart.

The stages are useful because they turn vague questions (“how long should we keep data?”) into concrete, separately answerable ones: how far ahead do we create, when does a partition stop being hot, when is it compressed, and when is it removed. Each has a different owner — the last one is usually legal rather than engineering.

Retention Policy as Configuration

Retention rules belong in a table, not in a script. Encoding them as data makes them auditable, lets different tables have different windows, and allows the maintenance job to be generic:

CREATE TABLE partition_policy (
    table_name        text PRIMARY KEY,
    interval_unit     text        NOT NULL CHECK (interval_unit IN ('day','week','month')),
    premake           int         NOT NULL DEFAULT 3,
    hot_window        interval    NOT NULL,
    warm_window       interval    NOT NULL,
    retention         interval    NOT NULL,
    archive_target    text,
    updated_at        timestamptz NOT NULL DEFAULT now(),
    updated_by        text        NOT NULL DEFAULT current_user
);

INSERT INTO partition_policy
  (table_name, interval_unit, premake, hot_window, warm_window, retention, archive_target)
VALUES
  ('events',   'month', 3, '60 days',  '365 days', '25 months', 's3://archive/events/'),
  ('metrics',  'day',  14, '7 days',   '90 days',  '13 months', 's3://archive/metrics/'),
  ('audit_log','month', 6, '90 days',  '730 days', '7 years',   's3://archive/audit/');

The updated_by and updated_at columns are not decoration. When an auditor asks why a table holds thirteen months of data, the answer needs a date and a name attached to it, and a row in this table is a far better answer than a commit message in a repository that has since been reorganised.

The Retention Job

One job, driven by the policy table, handles every table. It runs the same four steps in the same order every time, and every step is safe to repeat:

-- 1. ensure future partitions exist up to the premake horizon
-- 2. detach partitions past the retention cutoff
-- 3. archive detached tables, verify, then drop
-- 4. publish metrics: newest bound, oldest bound, detached count
# retention.py — invoked hourly; every step is idempotent
def run(conn):
    for policy in fetch_policies(conn):
        ensure_future_partitions(conn, policy)
        for part in partitions_past_cutoff(conn, policy):
            detach(conn, part)                  # catalog operation, seconds
        for part in detached_tables(conn, policy):
            if not archived(part):
                archive(part, policy.archive_target)
            if verify_archive(part, policy.archive_target):
                drop_table(conn, part)          # space returned here
        publish_metrics(conn, policy)

The ordering matters more than the implementation. Archive before drop, verify before drop, and never combine the detach and the drop into one step — the gap between them is the window in which a mistake is recoverable.

Storage Tiering by Age

Because a range partition’s age is encoded in its bounds, tiering is mechanical rather than heuristic. PostgreSQL exposes it through tablespaces:

-- warm partitions move to slower, cheaper storage
ALTER TABLE events_2026_02 SET TABLESPACE slow_storage;

-- new partitions are created directly on fast storage
CREATE TABLE events_2026_09 PARTITION OF events
  FOR VALUES FROM ('2026-09-01') TO ('2026-10-01')
  TABLESPACE fast_storage;

ALTER TABLE ... SET TABLESPACE rewrites the table and holds an ACCESS EXCLUSIVE lock for the duration, which is acceptable on a warm partition nobody is writing and unacceptable on a hot one. That constraint is exactly why tiering is a lifecycle operation: it is cheap at the hot-to-warm transition and expensive at any other moment.

What age-based tiering saves across a 25-month retention window Twenty-five monthly partitions of about 400 gigabytes each. Keeping all of them on fast storage costs about 2,400 dollars a month. Keeping two months hot, ten warm and thirteen on compressed cold storage costs about 690 dollars for the same data and the same query latency on the 96 percent of traffic that reads the newest two months. 25 monthly partitions × ~400 GB — same data, two layouts flat: 10 TB on NVMe at $0.24/GB — $2,400 per month hot ×2 warm ×10 — network storage $0.08/GB compressed 2.4:1 cold ×13 — object storage $0.02/GB restored on demand in minutes tiered: $192 + $333 + $166 = $691 per month 96% share of query volume served by the two hot partitions The saving is 71% and the latency change is nil for the traffic that matters, because the access pattern the range key already encodes is the same pattern the tiering follows. That alignment is what partitioning buys you here.

Monitoring the Lifecycle

Four metrics, published by the maintenance job itself, cover every failure mode of the pipeline:

Three silent failures, three metrics, three warning windows Creation stopping is detected by the days-ahead gauge and gives weeks of warning. Retention stopping is detected by the age of the oldest partition and gives months. Archival stalling is detected by the count of detached-but-not-dropped tables and gives days before disk pressure appears. None of the three produces an error message. Silent failureDetected byWarning windowFirst real symptom creation stoppeddays to newest boundweeksinserts fail at midnight retention stoppedage of oldest partitionmonthsthe storage bill archival stalleddetached, not droppeddaysdisk pressure unknown key arrivingrows in DEFAULTimmediateunprunable scans Every row is measured from the data rather than from the job, because a job that was disabled reports nothing at all.
-- newest bound: how much headroom before inserts have nowhere to go
SELECT max(to_date(right(relname, 7), 'YYYY_MM')) AS newest_bound
FROM   pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE  i.inhparent = 'events'::regclass;

-- oldest bound: is retention actually running?
-- default partition rows: is an unexpected value arriving?
SELECT count(*) FROM events_default;

-- detached but not dropped: is archival leaking disk?
SELECT c.relname, pg_size_pretty(pg_total_relation_size(c.oid))
FROM   pg_class c
WHERE  c.relkind = 'r'
  AND  c.relname ~ '^events_\d{4}_\d{2}$'
  AND  NOT EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = c.oid);
Metric Healthy Alert when What it means
days to newest bound ≥ 60 < 21 partition creation has stopped
age of oldest partition ≤ retention + 1 interval > retention + 2 retention has stopped
rows in DEFAULT 0 > 1,000 an unexpected key value is arriving
detached tables 0 any older than 7 days archival stalled; disk is leaking

Who Owns Each Decision

The lifecycle spans three groups whose incentives differ, and most retention failures are ownership failures rather than technical ones. Naming the owner of each number, in writing, removes the ambiguity that otherwise resolves itself as “keep everything forever”.

Engineering owns the mechanism and the horizon. How partitions are created, how far ahead, how they are archived, and what the alerts are. These are implementation choices with no external constraints, and they should be changeable without consulting anyone.

The data owner owns the retention window. Usually a product manager or a compliance function, occasionally legal. The number is a business commitment — to customers, to regulators, or to an internal analytics roadmap — and engineering’s role is to implement it and to make its cost visible, not to choose it.

Finance owns the tiering thresholds, indirectly. The decision to move partitions to cheaper storage after ninety days is a cost decision informed by an access-pattern measurement. Engineering supplies the measurement; the threshold follows from what the organisation is willing to spend.

The reason to write this down is that the failure mode is silence. Nobody objects to keeping more data, the cost is diffuse, and the person who would have to approve deletion is rarely asked. A policy table with an updated_by column and a quarterly review turns a decision nobody is making into a decision someone declined to change — which is a materially different position when an auditor or a bill arrives.

The conversation that actually reduces cost

The highest-leverage question is not “can we compress this?” but “what is the maximum we are allowed to keep?”. Compliance documents almost always specify a minimum retention and are silent on the maximum, and systems default to infinity in the absence of an instruction. Turning “at least twelve months” into “exactly thirteen months” typically removes more storage than any amount of compression work, costs one meeting, and requires no maintenance window at all.

Bring three numbers to that meeting: the current storage cost of data older than the minimum, the query volume touching it (usually under one percent), and the restore time from archive if it is ever needed. With those three, the conversation takes ten minutes. Without them it takes a quarter and ends in a decision to keep everything.

Late-arriving data is the exception that shapes the schedule

Every retention design assumes a partition stops changing once the calendar moves past it, and every real system has some traffic that violates that assumption: a mobile client that was offline for a week, a batch import replaying a failed day, a correction posted against last quarter. The volume is small and the consequences are not, because a partition that is detached while writes are still arriving for it produces insert failures rather than a graceful fallback.

Two settings absorb this. The first is a lag between the partition’s close and its eligibility for archival — typically one full interval, so a monthly partition is only archived once the month after it has also closed. The second is a DEFAULT partition that catches anything arriving for a range that no longer exists, paired with an alert on its row count so those rows are noticed rather than silently accumulating in an unprunable table.

Measure the real tail before choosing the lag. A single query against the current partition — the maximum difference between a row’s business timestamp and its insertion timestamp — gives the answer directly, and it is almost always longer than the team’s intuition. Ninety-ninth-percentile lateness of a few hours with a maximum of eleven days is a common shape, and it means an archival job running one day after the month closes will hit rows that were still arriving.

Failure Modes

Failure mode Root cause Detection Mitigation
Inserts start failing at midnight pre-creation stopped weeks earlier; the horizon was reached days-to-newest-bound metric crossed the threshold and nobody was paged create the missing partitions immediately, then fix the scheduler and raise premake
Disk full despite retention running detached tables were never dropped because archival failed silently detached-table count above zero for days drop after verifying the archive; alert on detached age
Retention deleted data still under legal hold the policy table was updated without review audit trail in partition_policy shows who and when restore from archive; require review on policy changes, not just on code
ALTER TABLE SET TABLESPACE blocks production tiering ran against a partition still receiving writes lock waits during the tiering window tier only at the hot-to-warm transition, and check pg_stat_user_tables for recent writes first

Common Mistakes

  • Automating creation without automating removal. Growth becomes autonomous, cleanup stays manual, and manual work with no deadline never happens.
  • Deleting rows instead of dropping partitions. It converts a metadata operation into hours of WAL, bloat and vacuum work — see the comparison in detaching and archiving old partitions.
  • Dropping before verifying the archive. The archive is only a backup once something has proven it can be read.
  • Treating retention as an engineering decision. The maximum is a legal and product decision; engineering owns the mechanism, not the number.
  • Alerting on job exit codes. A cron that was disabled reports no failures. Alert on the state of the data — bounds and ages — not on the health of the process.

FAQ

Should retention drop partitions or delete rows?

Drop partitions whenever the retention boundary aligns with a partition boundary. DROP TABLE on a detached partition is a catalog operation that returns the space immediately, writes almost no WAL and creates no bloat. A DELETE of the same rows marks tens of millions of row versions dead, generates WAL proportional to the data, and leaves vacuum work that competes with live traffic for hours.

How far ahead should partitions be pre-created?

At least three times the longest plausible outage of whatever creates them. If a nightly job creates monthly partitions, three months of headroom means a broken scheduler has a quarter before inserts start failing or landing in DEFAULT. Alert on the distance from today to the newest partition bound rather than on the job’s exit code, because a disabled cron reports no failures at all.

Is DETACH CONCURRENTLY always the right choice?

It is the right default from PostgreSQL 14 onward because it avoids the ACCESS EXCLUSIVE lock on the parent that plain DETACH takes. It cannot run inside a transaction block, and it needs a brief window with no conflicting long-running transactions to finish. If it is interrupted, the partition is left in a transitional state that must be cleaned up with FINALIZE before anything else touches the table.

Where should archived partitions live?

Anywhere that is cheap, durable and independently restorable — object storage holding a compressed pg_dump of the single table is the common answer. The important properties are that the archive is verified against the source before the table is dropped, that its retention is tracked separately from the database’s, and that a restore has been tested at least once, because an unverified archive is a backup nobody has proven exists.

Articles in This Section