When Partition Management Doesn’t Need a Third-Party Tool

Declarative table partitioning has been part of PostgreSQL since v10, and it’s a well-understood technique for keeping large tables responsive and manageable. What remains fuzzy for many teams is the operational layer: how to actually create and drop partitions over time. PostgreSQL offers no built-in scheduler or manager for this, and the community hasn’t settled on a single standard approach either.

That gap often sends users searching for a tool. But for straightforward time-based partitioning—the most common pattern—the “tool” you need may just be a couple of SQL statements and a cron job. Before adopting a third-party extension, it’s worth weighing the trade-offs.

The Hidden Cost of Partitioning Tools

Tools are only useful when they solve a problem you actually have. For isolated, well-scoped tasks, adding a dependency can create more trouble than it removes. Many PostgreSQL-related tools are open-source repositories without an associated support model or SLA. If something breaks in production, you’re left to reverse-engineer the tool’s internals under pressure—often while the database is struggling—and the tool may not even be covered by your PostgreSQL support contract.

The most commonly cited tools for partition management are pg_partman and its similarly named sibling pg_pathman. Both can automate routine partition tasks, but they come with their own learning curves, configuration complexity, and potential lag in supporting new PostgreSQL releases. If your requirement is simply “create next month’s partition and drop anything older than six months,” you can implement that logic in a few lines of SQL and stay in full control.

A Minimal Schema for Event Logging

Consider a typical use case: capturing millions of user interaction events per day, with a retention window of six months. A partitioned table is the obvious fit. The business schema can start simple:

1

2

3

4

5

6

7

8

9

CREATE TABLE event (

    created_on      timestamptz NOT NULL DEFAULT now(),

    user_id         int8 NOT NULL,

    data            jsonb NOT NULL

) PARTITION BY RANGE (created_on);

CREATE INDEX ON event USING brin (created_on);

CREATE EXTENSION IF NOT EXISTS btree_gin;

CREATE INDEX ON event USING gin (user_id);

It’s a good practice to keep sub-partitions in their own schema. Once the number of partitions grows past a dozen or so, this keeps your query tools and schema listings uncluttered. Applications continue to access data through the parent table, so the partitions themselves remain an implementation detail.

For the demo, the first partitions can be created manually to get the application into QA while you finalize the automation strategy.

1

2

3

-- partitions for the current and next month

CREATE SCHEMA subpartitions;

CREATE TABLE event_y2020m05 PARTITION OF event FOR VALUES FROM ('2020-05-01') TO ('2020-06-01');

The Two SQL Statements That Do the Work

The entire maintenance job boils down to two queries. The first generates a command to pre-create a partition for the upcoming month. Run weekly from cron, it stays far enough ahead of the insert workload.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

WITH q_last_part AS (

select /* extract partition boundaries and take the last one */

*,

((regexp_match(part_expr, $ TO ('(.*)')$))[1])::timestamptz as last_part_end

from (

select /* get all current subpartitions of the 'event' table */

format('%I.%I', n.nspname, c.relname) as part_name,

pg_catalog.pg_get_expr(c.relpartbound, c.oid) as part_expr

from pg_class p

join pg_inherits i ON i.inhparent = p.oid

join pg_class c on c.oid = i.inhrelid

join pg_namespace n on n.oid = c.relnamespace

where p.relname = 'event' and p.relkind = 'p'

) x

order by last_part_end desc limit 1

)

SELECT

format($CREATE TABLE IF NOT EXISTS subpartitions.event_y%sm%s PARTITION OF event FOR

VALUES FROM ('%s') TO ('%s')$,

extract(year from last_part_end),

lpad((extract(month from last_part_end))::text, 2, '0'),

last_part_end,

last_part_end + '1month'::interval)

AS sql_to_exec

FROM

q_last_part; -- gexec

The second generates the drop command for partitions older than the retention period.

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

SELECT

format('DROP TABLE IF EXISTS %s', subpartition_name) as sql_to_exec

FROM (

SELECT

format('%I.%I', n.nspname, c.relname) AS subpartition_name,

((regexp_match(pg_catalog.pg_get_expr(c.relpartbound, c.oid), $ TO ('(.*)')$))[1])::timestamptz

AS part_end

FROM

pg_class p

JOIN pg_inherits i ON i.inhparent = p.oid

JOIN pg_class c ON c.oid = i.inhrelid

JOIN pg_namespace n ON n.oid = c.relnamespace

WHERE

p.relname = 'event'

AND p.relkind = 'p'

AND n.nspname = 'subpartitions'

) x

WHERE

part_end < current_date - '6 months'::interval

ORDER BY

part_end;

In a production environment, wrap these in descriptively named stored procedures. Call them from cron, or from a scheduler like pg_cron or pg_timetable if you already have one in place. Plan for failures: add alerting and retry logic, since lock contention during attach/drop operations or an accidentally terminated session will eventually cause a hiccup. For multi-role setups, set default privileges on the involved schemas so that new sub-partition tables automatically inherit the correct access rights.

On-the-Fly Partition Creation for Dynamic Environments

Periodic scheduling doesn’t fit every architecture. In highly dynamic environments where database and scheduler nodes move around and connectivity is unreliable, you can shift partition management into the data path itself.

Trigger-Based Checks on Every Insert

For moderate insert volumes, a trigger can transparently verify that the next partition exists and create it if needed, using dynamic SQL to look ahead. This approach predates native partitioning: for inheritance-based setups you could inspect the actual row being inserted and create only the required partition. The declarative syntax in PostgreSQL v10+ does not support BEFORE triggers, so the trigger must instead look ahead to the next time range.

Sampling Inserts for Overhead Control

Trigger overhead is usually minor—catalog lookups are cached, and partition creation is rare—but under heavy insert volume even the per-row check adds up. In that case, run the full management procedure for only a fraction of rows. Filtering with random() (a 0.1% sample is a reasonable starting point) belongs in the trigger’s WHEN clause, not in the trigger function, to save CPU cycles on every invocation.

The Takeaway

Partition lifecycle management requires exactly two SQL statements at its core, before you add validation and retry layers. Third-party projects can bring convenience, but they also bring extensions to install, configurations to learn, and release cycles to wait on. For a simple, well-understood need, writing the management logic yourself keeps you in control, makes the behavior transparent, and lets you adapt quickly when requirements change. Sometimes the right tool is the one you already know how to write.