Automating Partition Rollover and Retention

Create partitions ahead of the data and expire them with DETACH CONCURRENTLY — plus the monitoring that catches a rollover job that stopped running weeks ago.

← Back to Table Partitioning for Large Spatial Datasets

This page covers the scheduled job that keeps a partitioned spatial table alive: creating next month’s partition before anything needs it, expiring the oldest without locking the hierarchy, and alerting when the job stops.

Context & When to Use

A partitioned table has a moving edge. On the first day of a month with no partition covering it, every insert fails with no partition of relation found for row — a total write outage caused by a job that stopped running weeks earlier. On the other end, partitions that are never expired accumulate until the retention policy is a fiction and the disk fills.

Both problems are solved by one scheduled function and one alert. The function is idempotent, creates several months ahead, and detaches anything past the retention horizon. The alert watches the runway — how many days of future partitions exist — because that is the only signal that degrades gradually before the outage. Row counts, disk usage and query latency all look perfectly healthy right up to the moment the first insert fails.

This applies to any table using the design from Table Partitioning for Large Spatial Datasets, and doubly so to an audit trail, where retention is a policy commitment rather than a disk-space convenience.

Runnable Implementation

CREATE OR REPLACE FUNCTION maintain_positions_partitions(
    months_ahead   int DEFAULT 3,
    retain_months  int DEFAULT 24
) RETURNS TABLE (action text, partition_name text)
LANGUAGE plpgsql AS $$
DECLARE
    m      date;
    i      int;
    horizon date := date_trunc('month', now())::date
                    - (retain_months || ' month')::interval;
    part   record;
BEGIN
    -- 1. Create the runway. IF NOT EXISTS makes the whole function idempotent.
    FOR i IN 0..months_ahead LOOP
        m := (date_trunc('month', now()) + (i || ' month')::interval)::date;
        EXECUTE format(
            'CREATE TABLE IF NOT EXISTS %I PARTITION OF positions
                 FOR VALUES FROM (%L) TO (%L)',
            'positions_' || to_char(m, 'YYYY_MM'), m, m + interval '1 month');
        action := 'ensured'; partition_name := 'positions_' || to_char(m, 'YYYY_MM');
        RETURN NEXT;
    END LOOP;

    -- 2. Expire anything entirely older than the retention horizon
    FOR part IN
        SELECT c.relname,
               (regexp_replace(c.relname, '^positions_', '') || '_01')::date AS starts
        FROM   pg_class c
        JOIN   pg_inherits inh ON inh.inhrelid = c.oid
        WHERE  inh.inhparent = 'positions'::regclass
          AND  c.relname ~ '^positions_\d{4}_\d{2}$'
    LOOP
        CONTINUE WHEN part.starts >= horizon;
        -- CONCURRENTLY: no ACCESS EXCLUSIVE lock on the parent hierarchy
        EXECUTE format('ALTER TABLE positions DETACH PARTITION %I CONCURRENTLY',
                       part.relname);
        EXECUTE format('DROP TABLE %I', part.relname);
        action := 'expired'; partition_name := part.relname;
        RETURN NEXT;
    END LOOP;
END $$;

-- Run it daily; the function is safe to run any number of times
SELECT cron.schedule('positions-partitions', '17 3 * * *',
                     $$SELECT maintain_positions_partitions()$$);

Note the regexp_replace reconstruction of the start date from the partition name. Reading the bound from pg_get_expr(c.relpartbound, c.oid) is more correct but far harder to parse reliably; naming partitions after their range and deriving the date from the name is the pragmatic choice, provided the naming convention is enforced by this same function.

The rolling window the job maintainsA horizontal timeline of monthly partitions. To the left, partitions older than the 24-month retention horizon are marked for detach and drop. In the middle, the retained range holds 24 months of queryable data, with the current month highlighted. To the right, three empty partitions have been created ahead of any data, forming the runway. An annotation notes that the alert fires when the runway falls below 30 days, which is roughly six weeks before an insert would fail.One job maintains both ends of the windowexpireddetach + dropretained — 24 months, queryablepruning applies herenowrunway — created ahead3 empty partitions−25 m−12 m0+3 malert if the runway ever falls below 30 daysretention horizonA stalled job is invisible from row counts, disk usage or latency — only the runway shrinks, and it shrinksone day per day. That predictability is what makes it a good alert.

Key Parameters & Options

ParameterRecommendedReasoning
months_ahead3Survives a six-week outage of the scheduler unnoticed
retain_monthspolicy-drivenShould mirror the documented commitment, not disk capacity
Scheduledaily, off-peakIdempotent, so daily costs nothing and recovers from any miss
DETACH … CONCURRENTLYalways (PG 14+)Plain DETACH locks the whole hierarchy
DROP timingafter detachGives a window to archive; also keeps the lock scope small
Default partitionpresent but alerted onCatches stray rows without hiding a rollover failure

Archiving before the drop

Retention rarely means “delete and forget” — it usually means the data leaves the hot database and lives somewhere cheaper. The detached partition is a plain table, so archiving is an ordinary export with no coordination required.

# The partition is standalone after DETACH: dump it without touching the parent
pg_dump --table=positions_2024_08 --format=custom --compress=9 \
        --file=/archive/positions_2024_08.dump "$DATABASE_URL"

# Or export geometry to a portable format for a data lake
ogr2ogr -f Parquet /archive/positions_2024_08.parquet \
        PG:"$DATABASE_URL" positions_2024_08

Exporting to GeoParquet keeps the geometry queryable outside PostGIS and compresses far better than a custom dump — the format trade-offs are covered in GeoJSON vs GeoParquet Serialization. Whichever route, verify the archive before the drop, and record the archive location in the same job so the audit trail of what was expired is not folklore.

The expiry path, and what each step locksFour sequential steps. Detach concurrently takes a share update exclusive lock on the parent for about 30 milliseconds and leaves the partition as a standalone table. Verification counts rows and checks the archive is readable, taking no lock on the parent. Archiving exports the standalone table, taking an access share lock on that table only. Dropping the table takes an access exclusive lock on the standalone table alone, which nothing is querying. The parent hierarchy is only briefly touched in step one.Expiry path — the parent is touched once, briefly1 · DETACHCONCURRENTLYparent: SHARE UPDATE≈30 ms2 · verifyrow count · extentparent: no lockseconds3 · archivepg_dump or GeoParquetchild: ACCESS SHAREminutes4 · DROPstandalone tablenothing queries itmillisecondsPlainDROPon an attached partition instead:parent lockedEvery query against the parent waits behind an ACCESS EXCLUSIVE lock for the duration of the drop — on a largepartition with many indexes that is seconds, not milliseconds, and it happens during whatever traffic is live.Detaching first reduces the blast radius to one short catalogue update.

What the runway alert catches that nothing else does

The value of alerting on the runway is that it degrades linearly and predictably, while every other signal stays flat until the moment of failure. A stalled scheduler produces no errors, no latency change and no disk anomaly — inserts keep landing in the partition that already exists, right up to the last day it covers.

Eleven weeks after the rollover job stoppedFour signals tracked from the week the scheduler stalled. Insert error rate stays at zero until week eleven, when it jumps to one hundred percent. Query latency stays flat throughout. Disk growth continues its normal slope with no anomaly. The runway metric falls in a straight line from 120 days to zero, crossing the 30-day alert threshold in week seven, four weeks before the outage.Signals after the scheduler silently stoppedrunway (days remaining)alert at 30 days — week 7insert errors: flat zero……then 100 %query latency — no signaldisk growth — normal slopew1w6w11Only one of these four lines moves before the outage. Alert on the outcome — days of runway — not on whetherthe job reported success, because a job that never ran reports nothing at all.

Gotchas & Failure Modes

  • ERROR: no partition of relation "positions" found for row. The runway ran out. Create the missing partition immediately, then fix the scheduler and add the runway alert — the outage is a symptom, not the bug.
  • DETACH … CONCURRENTLY cannot run inside a transaction block. It errors with cannot run inside a transaction block, which surprises anyone wrapping maintenance in BEGIN. Run it as its own statement, and note that a failed detach can leave the partition in a pending state that needs ALTER TABLE … DETACH PARTITION … FINALIZE.
  • A non-empty default partition. Rows there are invisible to pruning, and creating a partition whose range overlaps them requires a full scan of the default. Alert on count(*) > 0 for the default rather than treating it as a harmless net.
  • Retention measured from insert time, not observation time. If the partition key is observed_at but the policy is about when data was received, late-arriving data can be expired the day it lands. Make the key and the policy agree.
  • pg_cron running on a replica after failover. The job silently stops when the primary changes if cron.database_name is not configured for the new primary. Alerting on the runway covers this too, which is the point of alerting on the outcome rather than the job.
  • Archive verified after the drop. Verify first. A corrupt dump discovered after DROP TABLE is unrecoverable.

Verification Snippet

-- Runway in days: the single number to alert on
SELECT max(upper(pg_get_expr(c.relpartbound, c.oid)::text::daterange))::date
         - current_date AS runway_days
FROM   pg_class c
JOIN   pg_inherits i ON i.inhrelid = c.oid
WHERE  i.inhparent = 'positions'::regclass
  AND  c.relname ~ '^positions_\d{4}_\d{2}$';
--  runway_days
-- -------------
--          122      → healthy; alert below 30

-- Confirm the oldest partition matches the retention policy
SELECT c.relname
FROM   pg_class c JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE  i.inhparent = 'positions'::regclass
ORDER  BY c.relname LIMIT 1;
# Dry run in staging: the function must be safe to run twice in a row
psql -c "SELECT * FROM maintain_positions_partitions()"
psql -c "SELECT * FROM maintain_positions_partitions()"   # identical output, no errors

← Back to Table Partitioning for Large Spatial Datasets