9. Partitioning and large tables
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
The lesson’s script is sql/09-partitioning.sql, and the exercises are in sql/09-exercises.sql. check.sh compares their output with expected/09-partitioning.txt and expected/09-exercises.txt. Both load ci.step_history, lesson 5’s table of 440,800 steps over 200 days.
| SQL Server | PostgreSQL |
|---|---|
| partition function and partition scheme | PARTITION BY RANGE, LIST or HASH on the table, one table per partition |
RANGE LEFT or RANGE RIGHT boundary values |
FOR VALUES FROM (…) TO (…): the lower bound included, the upper excluded |
| filegroups | tablespaces, rarely needed; each partition is its own set of files |
| aligned indexes | an index on the partitioned table, created on every partition |
ALTER TABLE … SWITCH PARTITION |
ALTER TABLE … ATTACH PARTITION and DETACH PARTITION |
$PARTITION.function(value) |
tableoid::regclass, the partition a row was read from |
| up to 15,000 partitions | no fixed limit; planning time and memory grow with the partitions a query keeps |
A partitioned table
Section titled “A partitioned table”SQL Server partitions a table through two separate objects, a partition function and a scheme. PostgreSQL’s declarative partitioning puts the method and the key on the table, and each partition is a table of its own, with its bounds. The first attempt, with the same primary key as ci.step_history (lines 8-18):
-- A partitioned table has no rows of its own: each row goes to the partition whose bounds contain its keyCREATE TABLE ci.step_log ( id bigint NOT NULL, workflow_name text NOT NULL, job_name text NOT NULL, step_name text NOT NULL, conclusion ci.conclusion, started_at timestamptz NOT NULL, duration interval NOT NULL, PRIMARY KEY (id)) PARTITION BY RANGE (started_at);ERROR: unique constraint on partitioned table must include all partitioning columnsDETAIL: PRIMARY KEY constraint on table "step_log" lacks column "started_at" which is part of the partition key.There is no index across all partitions: each partition has its own, which can only check uniqueness among its own rows. The documentation’s rule is that “the constraint’s columns must include all of the partition key columns”. Two rows with the same id in two different months would both be accepted, so PostgreSQL refuses the key. With started_at in it, the key is checked partition by partition and still holds for the whole table (lines 20-46):
-- A primary key must include the partition key: each partition checks uniqueness on its own rowsCREATE TABLE ci.step_log ( id bigint NOT NULL, workflow_name text NOT NULL, job_name text NOT NULL, step_name text NOT NULL, conclusion ci.conclusion, started_at timestamptz NOT NULL, duration interval NOT NULL, PRIMARY KEY (id, started_at)) PARTITION BY RANGE (started_at);
-- One partition per month, February to September 2026, written by a query and run by \gexecSELECT format('CREATE TABLE ci.step_log_%s PARTITION OF ci.step_log FOR VALUES FROM (%L) TO (%L)', to_char(m, 'YYYY_MM'), m, m + interval '1 month')FROM generate_series(timestamptz '2026-02-01', '2026-09-01', interval '1 month') AS mORDER BY m\gexec
INSERT INTO ci.step_logSELECT id, workflow_name, job_name, step_name, conclusion, started_at, duration FROM ci.step_history;VACUUM ANALYZE ci.step_log;
SELECT tableoid::regclass AS partition, count(*) AS rows, min(started_at)::date AS first_day, max(started_at)::date AS last_dayFROM ci.step_logGROUP BY tableoidORDER BY first_day;CREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLEINSERT 0 440800VACUUM partition | rows | first_day | last_day---------------------+-------+------------+------------ ci.step_log_2026_02 | 5079 | 2026-02-26 | 2026-02-28 ci.step_log_2026_03 | 68324 | 2026-03-01 | 2026-03-31 ci.step_log_2026_04 | 66120 | 2026-04-01 | 2026-04-30 ci.step_log_2026_05 | 68324 | 2026-05-01 | 2026-05-31 ci.step_log_2026_06 | 66120 | 2026-06-01 | 2026-06-30 ci.step_log_2026_07 | 68324 | 2026-07-01 | 2026-07-31 ci.step_log_2026_08 | 68324 | 2026-08-01 | 2026-08-31 ci.step_log_2026_09 | 30185 | 2026-09-01 | 2026-09-14(8 rows)- The query writes eight
CREATE TABLE … PARTITION OFstatements, one per month, andpsql’s\gexecruns each row of the result as a statement: the nineCREATE TABLElines are the table and its eight partitions. FROMincludes its bound andTOexcludes it, so2026-03-01 00:00belongs to March only.tableoid::regclassnames the partition each row is stored in.- February has only three days, since the history starts on February 26.
A row that falls outside every partition is refused (lines 48-49):
-- A row outside every partition's bounds has nowhere to goINSERT INTO ci.step_log VALUES (0, 'Deploy to GitHub Pages', 'deploy', 'Checkout', 'success', '2026-10-01 12:00+00', '2 seconds');ERROR: no partition of relation "step_log" found for rowDETAIL: Partition key of the failing row contains (started_at) = (2026-10-01 12:00:00+00).Partition pruning
Section titled “Partition pruning”With a condition on the partition key, the planner leaves out the partitions whose bounds can’t match. pg_temp.plan is lesson 5’s EXPLAIN (ANALYZE, BUFFERS) without costs and timings (lines 51-58):
-- Partition pruning: a condition on the partition key leaves out the partitions that can't matchSELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.step_log WHERE started_at >= '2026-09-14'$$);-- Without a condition on the key, every partition is readSELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.step_log WHERE step_name = 'Compile-fail doctests'$$); QUERY PLAN-------------------------------------------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=121 -> Index Only Scan using step_log_2026_09_pkey on step_log_2026_09 step_log (actual rows=1533.00 loops=1) Index Cond: (started_at >= '2026-09-14 00:00:00+00'::timestamp with time zone) Heap Fetches: 0 Index Searches: 1 Buffers: shared hit+read=121(7 rows)
QUERY PLAN----------------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=7453 -> Append (actual rows=9200.00 loops=1) Buffers: shared hit+read=7453 -> Seq Scan on step_log_2026_02 step_log_1 (actual rows=114.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 4965 Buffers: shared hit+read=86 -> Seq Scan on step_log_2026_03 step_log_2 (actual rows=1426.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 66898 Buffers: shared hit+read=1155 -> Seq Scan on step_log_2026_04 step_log_3 (actual rows=1380.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 64740 Buffers: shared hit+read=1118 -> Seq Scan on step_log_2026_05 step_log_4 (actual rows=1426.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 66898 Buffers: shared hit+read=1155 -> Seq Scan on step_log_2026_06 step_log_5 (actual rows=1380.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 64740 Buffers: shared hit+read=1118 -> Seq Scan on step_log_2026_07 step_log_6 (actual rows=1426.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 66898 Buffers: shared hit+read=1155 -> Seq Scan on step_log_2026_08 step_log_7 (actual rows=1426.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 66898 Buffers: shared hit+read=1155 -> Seq Scan on step_log_2026_09 step_log_8 (actual rows=622.00 loops=1) Filter: (step_name = 'Compile-fail doctests'::text) Rows Removed by Filter: 29563 Buffers: shared hit+read=511(36 rows)- The first query reads one partition, September’s, through its primary key index, whose second column is
started_at: 121 pages. - The second has no condition on
started_at: anAppendnode reads all eight partitions, 7,453 pages. Partitioning doesn’t speed up a query that doesn’t filter on the key; an index onstep_namewould.
With a parameter, the value is unknown when the plan is built. A generic plan keeps every partition, and the executor removes the ones the value excludes when the query runs (lines 60-65):
-- A parameter is only known at execution: the generic plan prunes then, and says how many partitions it skippedPREPARE failures_since (timestamptz) AS SELECT count(*) FROM ci.step_log WHERE started_at >= $1 AND conclusion = 'failure';SET plan_cache_mode = force_generic_plan;EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF) EXECUTE failures_since('2026-09-01');RESET plan_cache_mode;PREPARESET QUERY PLAN--------------------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) -> Append (actual rows=301.00 loops=1) Subplans Removed: 7 -> Seq Scan on step_log_2026_09 step_log_1 (actual rows=301.00 loops=1) Filter: ((started_at >= $1) AND ((conclusion)::text = 'failure'::text)) Rows Removed by Filter: 29884(6 rows)
RESETSubplans Removed: 7 is that pruning at execution. plan_cache_mode forces the generic plan, which a prepared statement may switch to after its first five executions, whether it was prepared in SQL or by Npgsql and pgjdbc (lesson 4). enable_partition_pruning, on by default, controls both kinds of pruning.
Indexes and moving rows
Section titled “Indexes and moving rows”An index created on the partitioned table is a partitioned index: PostgreSQL creates one index on each partition and attaches it to the parent (lines 67-72):
-- An index created on the partitioned table is created on every partition, and on the partitions added laterCREATE INDEX step_log_step_name ON ci.step_log (step_name);SELECT c.relname AS index, i.inhparent::regclass AS parentFROM pg_inherits AS i JOIN pg_class AS c ON c.oid = i.inhrelidWHERE i.inhparent = 'ci.step_log_step_name'::regclassORDER BY c.relname;CREATE INDEX index | parent--------------------------------+----------------------- step_log_2026_02_step_name_idx | ci.step_log_step_name step_log_2026_03_step_name_idx | ci.step_log_step_name step_log_2026_04_step_name_idx | ci.step_log_step_name step_log_2026_05_step_name_idx | ci.step_log_step_name step_log_2026_06_step_name_idx | ci.step_log_step_name step_log_2026_07_step_name_idx | ci.step_log_step_name step_log_2026_08_step_name_idx | ci.step_log_step_name step_log_2026_09_step_name_idx | ci.step_log_step_name(8 rows)A partition created later gets its own copy of the index. Creating it on a large table locks the table; the documentation describes how to build each partition’s index with CONCURRENTLY first, then create the parent’s index ON ONLY the parent and attach them.
An UPDATE that changes the partition key moves the row: PostgreSQL deletes it from one partition and inserts it into the other (lines 74-77):
-- An UPDATE of the partition key moves the row to another partitionUPDATE ci.step_log SET started_at = started_at - interval '1 month'WHERE id = (SELECT min(id) FROM ci.step_log WHERE started_at >= '2026-09-01')RETURNING tableoid::regclass AS now_in, started_at; now_in | started_at---------------------+------------------------ ci.step_log_2026_08 | 2026-08-01 01:37:05+00(1 row)
UPDATE 1Retention: detach, then drop
Section titled “Retention: detach, then drop”The usual reason for monthly partitions is to remove a month at once. A DELETE of February leaves as many dead row versions to vacuum as it removed rows, as in lesson 6. Removing the partition takes a lock and deletes files (lines 79-87):
-- Retention: removing a month with DELETE leaves dead rows to vacuum; dropping its partition removes a fileCREATE EXTENSION pgstattuple;BEGIN;DELETE FROM ci.step_log WHERE started_at < '2026-03-01';SELECT pg_size_pretty(table_len) AS size, dead_tuple_count FROM pgstattuple('ci.step_log_2026_02');ROLLBACK;ALTER TABLE ci.step_log DETACH PARTITION ci.step_log_2026_02;SELECT count(*) AS rows_left FROM ci.step_log;DROP TABLE ci.step_log_2026_02;CREATE EXTENSIONBEGINDELETE 5079 size | dead_tuple_count--------+------------------ 688 kB | 5079(1 row)
ROLLBACKALTER TABLE rows_left----------- 435721(1 row)
DROP TABLEpgstattuplecounts 5,079 dead tuples in February’s 688 kB file, rolled back here.DETACH PARTITIONturns the partition into an ordinary table, which can be archived withpg_dump(lesson 11) beforeDROP TABLE.DETACH PARTITION … CONCURRENTLYtakes a weaker lock on the parent, but “cannot be run in a transaction block and is not allowed if the partitioned table contains a default partition”.
Default partitions, and attaching a loaded table
Section titled “Default partitions, and attaching a loaded table”A default partition receives the rows no other partition accepts: the October row refused above goes in. It then prevents creating the partition that row belongs to (lines 89-93):
-- A default partition takes the rows no other partition acceptsCREATE TABLE ci.step_log_default PARTITION OF ci.step_log DEFAULT;INSERT INTO ci.step_log VALUES (0, 'Deploy to GitHub Pages', 'deploy', 'Checkout', 'success', '2026-10-01 12:00+00', '2 seconds');-- ... and then blocks a new partition that its rows belong toCREATE TABLE ci.step_log_2026_10 PARTITION OF ci.step_log FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');CREATE TABLEINSERT 0 1ERROR: updated partition constraint for default partition "step_log_default" would be violated by some rowAdding a partition also scans the default partition, to check that none of its rows belong to the new one. A default partition is a safety net, which should stay empty.
The SQL Server habit of loading a staging table, then SWITCHing it in, becomes ATTACH PARTITION (lines 95-107):
-- Loading a month apart, then attaching it: a CHECK constraint with the same bounds saves the scan that validates themDELETE FROM ci.step_log_default;CREATE TABLE ci.step_log_2026_10 (LIKE ci.step_log INCLUDING DEFAULTS INCLUDING CONSTRAINTS);INSERT INTO ci.step_log_2026_10SELECT id + 1000000, workflow_name, job_name, step_name, conclusion, started_at + interval '1 month', durationFROM ci.step_log_2026_09;ALTER TABLE ci.step_log_2026_10 ADD CONSTRAINT in_october CHECK (started_at >= '2026-10-01' AND started_at < '2026-11-01');ALTER TABLE ci.step_log ATTACH PARTITION ci.step_log_2026_10 FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');SELECT relname AS partition, pg_get_expr(relpartbound, oid) AS boundsFROM pg_classWHERE oid IN (SELECT inhrelid FROM pg_inherits WHERE inhparent = 'ci.step_log'::regclass)ORDER BY relname;DELETE 1CREATE TABLEINSERT 0 30184ALTER TABLEALTER TABLE partition | bounds------------------+-------------------------------------------------------------------------- step_log_2026_03 | FOR VALUES FROM ('2026-03-01 00:00:00+00') TO ('2026-04-01 00:00:00+00') step_log_2026_04 | FOR VALUES FROM ('2026-04-01 00:00:00+00') TO ('2026-05-01 00:00:00+00') step_log_2026_05 | FOR VALUES FROM ('2026-05-01 00:00:00+00') TO ('2026-06-01 00:00:00+00') step_log_2026_06 | FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00') step_log_2026_07 | FOR VALUES FROM ('2026-07-01 00:00:00+00') TO ('2026-08-01 00:00:00+00') step_log_2026_08 | FOR VALUES FROM ('2026-08-01 00:00:00+00') TO ('2026-09-01 00:00:00+00') step_log_2026_09 | FOR VALUES FROM ('2026-09-01 00:00:00+00') TO ('2026-10-01 00:00:00+00') step_log_2026_10 | FOR VALUES FROM ('2026-10-01 00:00:00+00') TO ('2026-11-01 00:00:00+00') step_log_default | DEFAULT(9 rows)- October is loaded in a table outside the partitioned table, a copy of September a month later, where it can be checked before anyone sees it.
ATTACH PARTITIONscans the table to check its bounds, holding anACCESS EXCLUSIVElock on it. The documentation recommends “creating aCHECKconstraint matching the expected partition constraint on the table prior to attaching it”: PostgreSQL then trusts the constraint and skips the scan. The constraint can be dropped afterwards.- On the parent,
ATTACH PARTITIONtakes aSHARE UPDATE EXCLUSIVElock, weaker than theACCESS EXCLUSIVElock ofCREATE TABLE … PARTITION OF. pg_get_expr(relpartbound, oid)prints each partition’s bounds, in UTC in this session.
List and hash
Section titled “List and hash”PARTITION BY LIST lists the values of each partition; PARTITION BY HASH spreads rows over a fixed number of partitions by the hash of the key (lines 109-125):
-- List and hash partitioningCREATE TABLE ci.step_outcomes (LIKE ci.step_log) PARTITION BY LIST (conclusion);CREATE TABLE ci.step_outcomes_success PARTITION OF ci.step_outcomes FOR VALUES IN ('success');CREATE TABLE ci.step_outcomes_other PARTITION OF ci.step_outcomes DEFAULT;CREATE TABLE ci.step_buckets (LIKE ci.step_log) PARTITION BY HASH (job_name);SELECT format('CREATE TABLE ci.step_buckets_%s PARTITION OF ci.step_buckets FOR VALUES WITH (MODULUS 4, REMAINDER %s)', r, r)FROM generate_series(0, 3) AS rORDER BY r\gexecINSERT INTO ci.step_outcomes SELECT * FROM ci.step_log;INSERT INTO ci.step_buckets SELECT * FROM ci.step_log;SELECT tableoid::regclass AS partition, count(*) AS rows, count(DISTINCT job_name) AS jobsFROM ci.step_outcomes GROUP BY tableoidUNION ALLSELECT tableoid::regclass, count(*), count(DISTINCT job_name)FROM ci.step_buckets GROUP BY tableoidORDER BY 1;CREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLEINSERT 0 465905INSERT 0 465905 partition | rows | jobs--------------------------+--------+------ ci.step_outcomes_success | 441875 | 64 ci.step_outcomes_other | 24030 | 17 ci.step_buckets_0 | 83288 | 18 ci.step_buckets_1 | 113779 | 15 ci.step_buckets_2 | 175176 | 13 ci.step_buckets_3 | 93662 | 18(6 rows)- The
successpartition holds 95% of the rows: list partitioning follows the data, balanced or not. - Hash partitioning of 64 distinct job names leaves one partition with twice the rows of another. A hash spreads keys, not rows: a few busy jobs weigh more than many rare ones. Hash partitions don’t help pruning with ranges or retention; they split a table that has no natural range, for maintenance or parallel work.
On Aurora
Section titled “On Aurora”To verify: nothing in this section ran on AWS. Declarative partitioning is PostgreSQL’s, and Aurora doesn’t change it. What changes is the tooling around it.
- pg_partman and pg_cron. Partitions for next month have to exist before next month. pg_partman creates them ahead and drops old ones, and pg_cron runs its maintenance on a schedule. The extension table for Aurora PostgreSQL 18 lists pg_partman 5.4.3 on 18.4 (5.2.4 on 18.3) and pg_cron 1.6.7; AWS’s pg_partman page says it “is supported on Aurora PostgreSQL versions 12.6 and higher”.
- Limits. I found no partition or table-count quota in Aurora’s quotas and limits. The same page gives a maximum table size of 32 TiB for Aurora PostgreSQL, and recommends “table design best practices, such as partitioning of large tables”: each partition is a table, so the limit applies per partition.
- Blue/green deployments. They replicate with logical replication (lesson 11), and AWS’s considerations state that “creating new partitions on partitioned tables isn’t supported during blue/green deployments for Aurora”, and that “the pg_partman extension must be disabled in the blue environment”.
- Zero-ETL to Redshift. For zero-ETL integrations, “the table partitions will be replicated to Amazon Redshift. However, the partitioned table itself isn’t replicated”.
Key takeaways
Section titled “Key takeaways”- A partitioned table is a parent with no rows and one table per partition;
FROMis inclusive,TOexclusive. - Primary keys and unique constraints must include the partition key.
- Pruning needs a condition on the key; with parameters, it happens at execution (
Subplans Removed). - Indexes created on the parent are created on every partition, present and future.
- Retention is
DETACHandDROP, notDELETE: no dead rows, no vacuum. - A default partition catches strays, and makes adding partitions scan it.
- Load apart, add a matching
CHECKconstraint, thenATTACH: the SQL ServerSWITCHpattern. - Hash partitions spread keys, not rows.
Exercises
Section titled “Exercises”The solutions are in sql/09-exercises.sql.
- Partition a copy of
ci.runsby workflow: one partition forDeploy to GitHub Pages, one for the three course example workflows, and a default partition. How many runs does each hold, and which partitions does a query forRust course examplesread?
Solution
-- Exercise 1: runs partitioned by workflow, with its own partition for deployments and a default for the rest,-- and a plan that reads only the partition for 'Rust course examples'CREATE TABLE ci.runs_by_workflow (LIKE ci.runs) PARTITION BY LIST (workflow_name);CREATE TABLE ci.runs_deploy PARTITION OF ci.runs_by_workflow FOR VALUES IN ('Deploy to GitHub Pages');CREATE TABLE ci.runs_examples PARTITION OF ci.runs_by_workflow FOR VALUES IN ('Rust course examples', 'Java course examples', 'WSL containers examples');CREATE TABLE ci.runs_other PARTITION OF ci.runs_by_workflow DEFAULT;INSERT INTO ci.runs_by_workflow SELECT * FROM ci.runs;SELECT tableoid::regclass AS partition, count(*) AS runs FROM ci.runs_by_workflow GROUP BY tableoid ORDER BY tableoid::regclass::text;SELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.runs_by_workflow WHERE workflow_name = 'Rust course examples'$$);CREATE TABLECREATE TABLECREATE TABLECREATE TABLEINSERT 0 125 partition | runs------------------+------ ci.runs_deploy | 65 ci.runs_examples | 27 ci.runs_other | 33(3 rows)
QUERY PLAN------------------------------------------------------------------------------ Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=1 -> Seq Scan on runs_examples runs_by_workflow (actual rows=18.00 loops=1) Filter: (workflow_name = 'Rust course examples'::text) Rows Removed by Filter: 9 Buffers: shared hit+read=1(6 rows)LIKE ci.runs copies the columns and their NOT NULL constraints, not the primary key. Pruning keeps runs_examples, the partition whose list contains the value; the default partition is left out too, since the value is in another partition’s list.
- Partition September’s steps in two levels: by month, then, inside the month, by conclusion. Show the tree with
pg_partition_tree, and the plan of a query for the failures since September 14.
Solution
-- Exercise 2: two levels, months then conclusion, and the tree that pg_partition_tree returnsCREATE TABLE ci.step_months (LIKE ci.step_history) PARTITION BY RANGE (started_at);CREATE TABLE ci.step_months_2026_09 PARTITION OF ci.step_months FOR VALUES FROM ('2026-09-01') TO ('2026-10-01') PARTITION BY LIST (conclusion);CREATE TABLE ci.step_months_2026_09_success PARTITION OF ci.step_months_2026_09 FOR VALUES IN ('success');CREATE TABLE ci.step_months_2026_09_other PARTITION OF ci.step_months_2026_09 DEFAULT;INSERT INTO ci.step_months SELECT * FROM ci.step_history WHERE started_at >= '2026-09-01';SELECT relid, parentrelid, isleaf, level FROM pg_partition_tree('ci.step_months') ORDER BY level, relid::text;SELECT * FROM pg_temp.plan($$ SELECT count(*) FROM ci.step_months WHERE started_at >= '2026-09-14' AND conclusion = 'failure'$$);CREATE TABLECREATE TABLECREATE TABLECREATE TABLEINSERT 0 30185 relid | parentrelid | isleaf | level--------------------------------+------------------------+--------+------- ci.step_months | | f | 0 ci.step_months_2026_09 | ci.step_months | f | 1 ci.step_months_2026_09_other | ci.step_months_2026_09 | t | 2 ci.step_months_2026_09_success | ci.step_months_2026_09 | t | 2(4 rows)
QUERY PLAN--------------------------------------------------------------------------------------------------------------------------------- Aggregate (actual rows=1.00 loops=1) Buffers: shared hit+read=36 -> Seq Scan on step_months_2026_09_other step_months (actual rows=15.00 loops=1) Filter: ((started_at >= '2026-09-14 00:00:00+00'::timestamp with time zone) AND ((conclusion)::text = 'failure'::text)) Rows Removed by Filter: 1512 Buffers: shared hit+read=36(6 rows)A partition declared with its own PARTITION BY is itself partitioned: level 1 has no rows either. Both conditions prune, one per level, and the query reads only the other partition of September.
- Write a procedure that detaches and drops the monthly partitions of a table that end on or before a given date, and use it to keep June to September.
Solution
-- Exercise 3: a procedure that detaches and drops the monthly partitions that end before a dateCREATE TABLE ci.step_log (LIKE ci.step_history) PARTITION BY RANGE (started_at);SELECT format('CREATE TABLE ci.step_log_%s PARTITION OF ci.step_log FOR VALUES FROM (%L) TO (%L)', to_char(m, 'YYYY_MM'), m, m + interval '1 month')FROM generate_series(timestamptz '2026-02-01', '2026-09-01', interval '1 month') AS mORDER BY m\gexec
CREATE PROCEDURE ci.drop_partitions_before(parent regclass, cutoff timestamptz)LANGUAGE plpgsql AS $$DECLARE part regclass;BEGIN FOR part IN SELECT c.oid::regclass FROM pg_inherits AS i JOIN pg_class AS c ON c.oid = i.inhrelid -- the upper bound, read back from the partition's definition: FOR VALUES FROM ('…') TO ('…') WHERE i.inhparent = parent AND substring(pg_get_expr(c.relpartbound, c.oid) FROM $re$TO \('([^']+)'\)$re$)::timestamptz <= cutoff ORDER BY c.relname LOOP -- the name first: once the table is dropped, its regclass prints as a bare OID RAISE NOTICE 'dropping %', part; EXECUTE format('ALTER TABLE %s DETACH PARTITION %s', parent, part); EXECUTE format('DROP TABLE %s', part); END LOOP;END$$;
CALL ci.drop_partitions_before('ci.step_log', '2026-06-01');SELECT relid FROM pg_partition_tree('ci.step_log') WHERE isleaf ORDER BY relid::text;CREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE TABLECREATE PROCEDURENOTICE: dropping ci.step_log_2026_02NOTICE: dropping ci.step_log_2026_03NOTICE: dropping ci.step_log_2026_04NOTICE: dropping ci.step_log_2026_05CALL relid--------------------- ci.step_log_2026_06 ci.step_log_2026_07 ci.step_log_2026_08 ci.step_log_2026_09(4 rows)The upper bound comes back from the catalog as text, read with a regular expression and cast to timestamptz. A regclass prints as a name while the table exists; after DROP TABLE, the same value prints as a number, which is why the notice comes first. This is the job pg_partman’s retention settings do on a schedule.
Sources
Section titled “Sources”- PostgreSQL 18 documentation: table partitioning,
CREATE TABLE,ALTER TABLE, query planning settings,psql,pgstattuple, system information functions, partitioning information functions, PostgreSQL 18 release notes - SQL Server: partitioned tables and indexes,
CREATE PARTITION FUNCTION,ALTER TABLE … SWITCH - AWS: extension versions, pg_partman on Aurora, quotas and limits, blue/green considerations, zero-ETL integrations, all read on 2026-09-16
- pg_partman, pg_cron