Looking Forward to Postgres 19: Split Personality
Postgres has had native support for declarative partitions since version 10, and every release since has filed off another rough edge. We got partition-wise joins, default partitions, hash partitioning, and the ability to attach and detach partitions concurrently. By any reasonable measure, declarative partitioning is one of the great success stories of modern Postgres.
Despite the power here, it's always been a kind of one-way ratchet. Creating or dropping partitions was easy. But reorganizing existing ones was a different beast entirely. There's no syntax or tool to assist in the event we want to convert a table to a partition set, or revise an existing design. Instead, it's just a long series of manual statements to carve the table up into partitions, or perhaps leveraging pg_partman to do it instead.
Well, Postgres 19 finally has an answer to that. Let's talk about the current state of the art, and how the new SPLIT PARTITION and MERGE PARTITIONS syntax for ALTER TABLE may change the story.
The Way Things Were
Suppose we have a busy partition and want to break it into smaller pieces. The current procedure works something like this:
Create new partitions as standalone tables.
Copy the relevant rows into each one.
Detach the old partition.
Attach the new ones.
Drop the empty table husk.
It's serviceable, but also tedious, error-prone, and subject to various locks that can delay the process. Postgres 19 collapses that whole routine into one statement.
Before diving into how, let's create something realistic to play with. How about an analytics pipeline containing a stream of events partitioned by time? The initial state will be one partition per quarter for 2026:
CREATE TABLE event_log (
id BIGINT GENERATED ALWAYS AS IDENTITY,
event_time TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL,
payload JSONB,
PRIMARY KEY (id, event_time)
) PARTITION BY RANGE (event_time);
CREATE TABLE event_log_2026_q1 PARTITION OF event_log
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE event_log_2026_q2 PARTITION OF event_log
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
CREATE TABLE event_log_2026_q3 PARTITION OF event_log
FOR VALUES FROM ('2026-07-01') TO ('2026-10-01');
CREATE TABLE event_log_2026_q4 PARTITION OF event_log
FOR VALUES FROM ('2026-10-01') TO ('2027-01-01');Now let's inject a single event into every day of the year so each partition has something to hold:
INSERT INTO event_log (event_time, event_type, payload)
SELECT ts, 'click', '{"ok": true}'::jsonb
FROM generate_series('2026-01-15'::timestamptz,
'2026-12-15'::timestamptz, '1 day') ts;Let's assume that a quarter felt reasonable when we drew it up, but traffic in Q1 turned out to be heavier than expected. Now, querying three months of data to find a single morning's worth of clicks is wasteful. We'd much rather have monthly partitions there. In the past, that meant the migration shuffle.
Be Fruitful and Multiply
The new syntax reads almost exactly like what we might say out loud. "Take this partition, and split it into these smaller ones":
ALTER TABLE event_log SPLIT PARTITION event_log_2026_q1 INTO (
PARTITION event_log_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'),
PARTITION event_log_2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'),
PARTITION event_log_2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
);That's it. The original events_2026_q1 partition is gone, replaced by three monthly children, and Postgres moved every row into the partition where it now belongs. Let's verify that:
SELECT tableoid::regclass AS partition, count(*)
FROM event_log
WHERE event_time >= '2026-01-01'
AND event_time < '2026-04-01'
GROUP BY 1
ORDER BY 1;
partition | count
-------------------+-------
event_log_2026_01 | 17
event_log_2026_02 | 28
event_log_2026_03 | 31No temporary table -> copy -> detach dance. Instead, the rows land exactly where the new bounds dictate. Seventeen days of January data, a full February, and a full March. The whole operation is transactional too, so it's perfectly valid to abort in the middle and leave the original structure unchanged.
One detail worth noting: the new partitions must cover the entire range of the partition being split, with no gaps or overlaps. Postgres will point out mistakes here:
ALTER TABLE event_log SPLIT PARTITION event_log_2026_q2 INTO (
PARTITION event_log_2026_p1 FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'),
PARTITION event_log_2026_p2 FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')
-- Note the gap between 2026-05-01 and 2026-06-01
);
ERROR: cannot split to partition "event_log_2026_p2"
together with partition "event_log_2026_p1"
DETAIL: The lower bound of partition "event_log_2026_p2" is not equal
to the upper bound of partition "event_log_2026_p1".
HINT: ALTER TABLE ... SPLIT PARTITION requires the partition bounds to
be adjacent.That's a good error; it tells us precisely what's wrong and exactly how to fix it.
E Pluribus Unum
Splitting is only half the story. The reciprocal scenario is just as valid. We could just as easily have a pile of old partitions that nobody queries individually anymore and want to de-clutter. When 2026 is well behind us, three monthly partitions for Q1 become pure overhead.
Let's fold them back into a single quarter:
ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_01, event_log_2026_02, event_log_2026_03)
INTO event_log_2026_q1;The three monthly partitions collapse into one, and all 76 rows come along for the ride:
SELECT tableoid::regclass AS partition, count(*)
FROM event_log
WHERE event_time < '2026-04-01'
GROUP BY 1
ORDER BY 1;
partition | count
-------------------+-------
event_log_2026_q1 | 76Notice that we reused the name events_2026_q1 for the result. That's allowed, because the partitions feeding the merge are consumed in the process. The same trick works with SPLIT, which is genuinely handy when carving up a default partition, as we'll see in a moment.
Merging carries the same adjacency rule as splitting. The partitions being combined must sit next to each other on the number line with no gaps between them. Try to merge two partitions with a third wedged in the middle, and Postgres refuses:
ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_q1, event_log_2026_q3)
INTO event_log_2026;
ERROR: cannot merge partition "event_log_2026_q3" together with
partition "event_log_2026_q1"
DETAIL: The lower bound of partition "event_log_2026_q3" is not equal
to the upper bound of partition "event_log_2026_q1".
HINT: ALTER TABLE ... MERGE PARTITIONS requires the partition bounds
to be adjacent.On Distributing Defaults
Here's where these commands earn their keep in the real world. Imagine a table where data sometimes arrives for time ranges we have yet to allocate. That's what the default partition is for: a catch-all that scoops up anything unclaimed by existing partitions.
CREATE TABLE log_entry (d date, msg text) PARTITION BY RANGE (d);
CREATE TABLE log_entry_2026_q1 PARTITION OF log_entry
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE log_entry_default PARTITION OF log_entry DEFAULT;
INSERT INTO log_entry VALUES ('2026-05-10', 'late'), ('2026-06-20', 'later');Those two May and June rows had nowhere else to go, so they fell into log_entry_default :
SELECT tableoid::regclass, count(*)
FROM log_entry
GROUP BY 1
ORDER BY 1;
tableoid | count
-------------------+-------
log_entry_default | 2In the past, promoting that stray data into a proper Q2 partition was something of a pain. We couldn't simply attach a new partition that overlapped the default while it held matching rows. Consider:
CREATE TABLE log_entry_2026_q2 PARTITION OF log_entry
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
ERROR: updated partition constraint for default partition
"log_entry_default" would be violated by some rowNow we don't have to worry about that:
ALTER TABLE log_entry SPLIT PARTITION log_entry_default INTO (
PARTITION log_entry_2026_q2 FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'),
PARTITION log_entry_default DEFAULT
);
SELECT tableoid::regclass, count(*)
FROM log_entry
GROUP BY 1
ORDER BY 1;
tableoid | count
-------------------+-------
log_entry_2026_q2 | 2The May and June rows migrated into the brand new partition, and the default partition remains no worse for the wear. That alone is worth the price of admission.
Lost in Translation
But like any Ikea furniture, sometimes we lose a few pieces during assembly. Postgres drops any objects defined directly on the partitions during a split or merge procedure. Consider this scenario where we have a partition-local index on January and a CHECK constraint on February.
This is what happens during a merge:
CREATE INDEX idx_jan_type ON event_log_2026_01 (event_type);
ALTER TABLE event_log_2026_02
ADD CONSTRAINT chk_feb_payload
CHECK (payload IS NOT NULL);
ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_01, event_log_2026_02, event_log_2026_03)
INTO event_log_2026_q1;
\d event_log_2026_q1
SELECT tablename
FROM pg_indexes
WHERE indexname = 'idx_jan_type';
tablename
-----------
(0 rows)
SELECT conname
FROM pg_constraint
WHERE conrelid = 'event_log_2026_q1'::regclass
AND contype = 'c'; -- c for "check" constraint
conname
---------
(0 rows)Both the constraint and index are gone without a trace. Thankfully there's an important distinction between objects defined on the parent and objects defined on an individual partition.
An index created on the parent table itself is a partitioned index, and Postgres maintains a copy on every partition automatically. When a merge or split produces a new partition, that partition gets its copy of the parent index just like any freshly attached partition would. Watch what happens when we add a parent index alongside a partition-local one, then merge:
CREATE INDEX idx_event_type ON event_log (event_type); -- on the parent
CREATE INDEX idx_q2_local ON event_log_2026_q2 (event_time); -- on one partition
ALTER TABLE event_log
MERGE PARTITIONS (event_log_2026_q2, event_log_2026_q3, event_log_2026_q4)
INTO event_log_2026_rest;
\d event_log_2026_restThe merged partition comes back carrying the parent's indexes and nothing else:
Partition of: event_log FOR VALUES FROM ('2026-04-01 00:00:00+00')
TO ('2027-01-01 00:00:00+00')
Indexes:
"event_log_2026_rest_pkey" PRIMARY KEY, btree (id, event_time)
"event_log_2026_rest_event_type_idx" btree (event_type)The parent index survives, recreated automatically under a generated name. The local idx_q2_local does not. So we could look at it like this: anything inherited from the parent is rebuilt, and anything attached to a single partition is forfeit. If partitions carry their own bespoke indexes, constraints, or triggers, take note of these if any need to be recreated following a merge.
An Exclusive Club
The other major caveat is about contention. There is no CONCURRENTLY variant of these commands yet. Both SPLIT and MERGE take an ACCESS EXCLUSIVE lock, and they hold it for the entire duration of the operation.
How heavy is that lock, exactly? Let's see:
BEGIN;
ALTER TABLE event_log SPLIT PARTITION event_log_2026_q1 INTO (
PARTITION event_log_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'),
PARTITION event_log_2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'),
PARTITION event_log_2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
);
SELECT c.relname, l.mode, l.granted
FROM pg_locks l
JOIN pg_class c ON c.oid = l.relation
WHERE l.mode = 'AccessExclusiveLock'
AND c.relname LIKE '%event_log%'
ORDER BY 1;
relname | mode | granted
----------------------------------+---------------------+---------
event_log | AccessExclusiveLock | t
event_log_2026_01 | AccessExclusiveLock | t
event_log_2026_01_event_type_idx | AccessExclusiveLock | t
event_log_2026_01_pkey | AccessExclusiveLock | t
event_log_2026_02 | AccessExclusiveLock | t
event_log_2026_02_event_type_idx | AccessExclusiveLock | t
event_log_2026_02_pkey | AccessExclusiveLock | t
event_log_2026_03 | AccessExclusiveLock | t
event_log_2026_03_event_type_idx | AccessExclusiveLock | t
event_log_2026_03_pkey | AccessExclusiveLock | tLook at the top row. The lock isn't just on the partition being split. It's on the parent table itself. For as long as the operation runs, the entire partitioned table is frozen. All reads and writes will wait until the ALTER completes. That's more than a little inconvenient.
That's only a few milliseconds for a small partition, and nobody notices. Partitions containing hundreds of millions of rows could be locked for minutes or even hours during a split or merge. So treat these commands like any other ALTER TABLE statement: dangerous at peak traffic. Until a concurrent variant arrives, the heavy lock is the price of admission.
Hash Need Not Apply
Native table partitions in Postgres have come a long way, but the merge and split functionality are still early in their evolution. As a result, some omissions remain, such as hash-based partitions.
Splitting and merging both work on RANGE and LIST partitioned tables, but a hash-partitioned table will balk immediately:
CREATE TABLE h (id INT) PARTITION BY hash (id);
CREATE TABLE h0 PARTITION OF h FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE h1 PARTITION OF h FOR VALUES WITH (MODULUS 4, REMAINDER 1);
ALTER TABLE h MERGE PARTITIONS (h0, h1) INTO h0;
ERROR: partition of hash-partitioned table cannot be merged
ALTER TABLE h SPLIT PARTITION h1 INTO (
PARTITION h1_1 FOR VALUES WITH (MODULUS 8, REMAINDER 1),
PARTITION h1_2 FOR VALUES WITH (MODULUS 8, REMAINDER 2)
);
ERROR: partition of hash-partitioned table cannot be splitPerhaps this might come in the future, or perhaps not. How does one "merge" a hash, exactly? We'd have to change the base modulus, thereby affecting all child partitions rather than a distinct subset. Still, it pays to be informed.
Final Thoughts
The journey of 1000 miles begins with a single step. Declarative Postgres partitioning took that step way back in version 10, and now it continues the pilgrimage by adopting MERGE and SPLIT syntax. Due to their relative youth, they're missing some creature comforts such as concurrent operation. That's no small omission, especially given the access exclusive lock they incur.
But the foundation is there. If the history of Postgres partitioning tells us anything, the rough edges will be smoothed off release by release. A concurrent variant feels almost inevitable. Until then, maintenance windows are the only option when splitting or merging partitions. If availability is of utmost importance, the old table-swap polka is still an option.
So pull down a Postgres 19 build and experiment a bit with the new SPLIT and MERGE functionality. It's something a lot of users have requested, and now it's finally here!

