Partitioning
Declarative partitioning lets you split a large table into smaller physical pieces while your application keeps seeing a single logical table. Postgres routes inserts and queries to the right partitions for you. For databases with very large tables, it's one of the most effective scaling tools you have.
There are three partition strategies, and each fits a different data pattern.
Time Based Partitioning
Range partitioning is the most common strategy. It's a natural fit for time-series data where queries filter by date ranges.
CREATE TABLE payments ( payment_id bigint GENERATED ALWAYS AS IDENTITY, customer_id int NOT NULL, amount numeric(10,2) NOT NULL, payment_date date NOT NULL, CONSTRAINT payments_pk PRIMARY KEY (payment_id, payment_date) ) PARTITION BY RANGE (payment_date); -- Create yearly partitions CREATE TABLE payments_2023 PARTITION OF payments FOR VALUES FROM ('2023-01-01') TO ('2024-01-01'); CREATE TABLE payments_2024 PARTITION OF payments FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); CREATE TABLE payments_2025 PARTITION OF payments FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); -- Catch anything that doesn't match a defined range CREATE TABLE payments_default PARTITION OF payments DEFAULT;
Note that the partition key (payment_date) is part of the primary key. Postgres requires this: any unique constraint or primary key on a partitioned table must include the partition key columns.
List Partitioning
List partitioning works well for categorical data with a known set of values.
CREATE TABLE orders ( order_id bigint GENERATED ALWAYS AS IDENTITY, region text NOT NULL, product_id int NOT NULL, quantity int NOT NULL, order_date timestamp NOT NULL, CONSTRAINT orders_pk PRIMARY KEY (order_id, region) ) PARTITION BY LIST (region); CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('us-east', 'us-west', 'us-central'); CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('eu-west', 'eu-central', 'eu-north'); CREATE TABLE orders_apac PARTITION OF orders FOR VALUES IN ('apac-east', 'apac-south'); CREATE TABLE orders_default PARTITION OF orders DEFAULT;
Hash Partitioning
Hash partitioning spreads rows evenly when there's no natural range or list to split on. It's handy for breaking up a big table where queries always filter on one specific key.
CREATE TABLE user_sessions ( session_id uuid NOT NULL, user_id bigint NOT NULL, started_at timestamp NOT NULL, data jsonb, CONSTRAINT user_sessions_pk PRIMARY KEY (session_id, user_id) ) PARTITION BY HASH (user_id); CREATE TABLE user_sessions_p0 PARTITION OF user_sessions FOR VALUES WITH (MODULUS 4, REMAINDER 0); CREATE TABLE user_sessions_p1 PARTITION OF user_sessions FOR VALUES WITH (MODULUS 4, REMAINDER 1); CREATE TABLE user_sessions_p2 PARTITION OF user_sessions FOR VALUES WITH (MODULUS 4, REMAINDER 2); CREATE TABLE user_sessions_p3 PARTITION OF user_sessions FOR VALUES WITH (MODULUS 4, REMAINDER 3);
Quick Comparison
| Strategy | Best For | Example Use Case |
|---|---|---|
| RANGE | Time-series, sequential data | Logs, payments, IoT readings |
| LIST | Known categories | Regional data, status codes |
| HASH | Even distribution, no natural key | User sharding, session data |
Postgres routes INSERT statements to the correct partition automatically. If a row doesn't match any partition and there's no DEFAULT partition, the insert fails with an error.
Changing Partition Boundaries
Postgres doesn't have a built-in command to merge or split existing partitions. ALTER TABLE ... MERGE PARTITIONS and SPLIT PARTITION were committed during both the Postgres 17 and Postgres 19 development cycles and reverted both times, so they don't ship in either release. The earliest they could return is Postgres 20.
Today, reshaping partitions means doing it by hand: create the new partition (or partitions), move rows with INSERT ... SELECT, and use ALTER TABLE ... DETACH PARTITION and ATTACH PARTITION to swap things in. DETACH PARTITION ... CONCURRENTLY (Postgres 14+) avoids blocking queries on the parent while you do it.
Partition Pruning and Query Performance
Most of the performance win from partitioning comes from partition pruning. Postgres throws out partitions that can't match your query, so it only scans the data it needs.
Pruning in Action
When your WHERE clause filters on the partition key, Postgres skips partitions that can't contain matching rows:
EXPLAIN (COSTS OFF) SELECT * FROM payments WHERE payment_date BETWEEN '2024-06-01' AND '2024-06-30';
Append -> Seq Scan on payments_2024 Filter: (payment_date >= '2024-06-01' AND payment_date <= '2024-06-30')
Only payments_2024 gets scanned. The planner knows no rows in payments_2023 or payments_2025 can match, so those partitions are excluded entirely.
Without Pruning
If your query doesn't filter on the partition key, Postgres has to scan every partition:
EXPLAIN (COSTS OFF) SELECT * FROM payments WHERE customer_id = 42;
Append -> Seq Scan on payments_2023 Filter: (customer_id = 42) -> Seq Scan on payments_2024 Filter: (customer_id = 42) -> Seq Scan on payments_2025 Filter: (customer_id = 42) -> Seq Scan on payments_default Filter: (customer_id = 42)
Every partition gets scanned here, so partitioning isn't buying you anything for this query. If most of your queries filter by customer_id alone, an index on that column is the better fix.
Verifying Pruning Settings
Partition pruning is on by default. If you're not seeing the plans you expect, check it:
SHOW enable_partition_pruning; -- should be 'on'
Postgres 11 added pruning at execution time, which covers prepared statements and parameterized queries where the filter value isn't known when the plan is built:
PREPARE payment_lookup (date, date) AS SELECT * FROM payments WHERE payment_date BETWEEN $1 AND $2; EXPLAIN (COSTS OFF) EXECUTE payment_lookup('2024-03-01', '2024-03-31');
Append -> Seq Scan on payments_2024 Filter: (payment_date >= '2024-03-01' AND payment_date <= '2024-03-31')
Tips for Effective Pruning
- Include the partition key in
WHEREclauses whenever you can - Index the non-partition-key columns you filter on often
- Run
EXPLAINto confirm pruning is happening before you ship - For joins, make sure the partition key is part of the join condition or filter
Partition Maintenance with pg_partman
Managing partitions by hand (creating future ones, dropping expired ones) gets old fast and is easy to get wrong in production. The pg_partman extension automates it.
Initial Setup
CREATE EXTENSION pg_partman;
Configuring Automated Partitioning
Use create_parent() to hand an already-partitioned table over to pg_partman:
SELECT partman.create_parent( p_parent_table => 'public.payments', p_control => 'payment_date', p_interval => '1 month', p_premake => 3 );
This tells pg_partman to:
- Manage
public.paymentspartitioned onpayment_date - Create monthly partitions
- Pre-create 3 future partitions so inserts never fail
Setting Up Retention
Configure how long to keep old partitions:
UPDATE partman.part_config SET retention = '12 months', retention_keep_table = false WHERE parent_table = 'public.payments';
With retention_keep_table = false, expired partitions are dropped. Set it to true to detach them without dropping, which is useful if you want to archive the data first.
Dropping a partition is nearly instant compared to a DELETE FROM on millions of rows. No dead tuples, no vacuum cleanup afterward.
Running Maintenance
pg_partman needs a periodic maintenance call to create new partitions and apply retention:
SELECT partman.run_maintenance();
Schedule it with pg_cron so you don't have to think about it:
SELECT cron.schedule( 'partition-maintenance', '0 * * * *', -- every hour $$SELECT partman.run_maintenance()$$ );
Complete IoT Example
Here's a full setup for an IoT sensor readings table with daily partitions and 90-day retention:
-- Create the parent table CREATE TABLE sensor_readings ( reading_id bigint GENERATED ALWAYS AS IDENTITY, sensor_id int NOT NULL, reading_time timestamptz NOT NULL, temperature numeric(5,2), humidity numeric(5,2), CONSTRAINT sensor_readings_pk PRIMARY KEY (reading_id, reading_time) ) PARTITION BY RANGE (reading_time); -- Let pg_partman manage it SELECT partman.create_parent( p_parent_table => 'public.sensor_readings', p_control => 'reading_time', p_interval => '1 day', p_premake => 7 ); -- Configure 90-day retention UPDATE partman.part_config SET retention = '90 days', retention_keep_table = false WHERE parent_table = 'public.sensor_readings'; -- Schedule maintenance SELECT cron.schedule( 'sensor-partition-maint', '15 * * * *', $$SELECT partman.run_maintenance()$$ );
With this in place, pg_partman creates daily partitions a week ahead and drops them after 90 days. Your application inserts into sensor_readings, queries filter by reading_time, and pg_partman handles the rest.
Automatic Archiving with Partition Detach
A common pattern is archiving old data instead of deleting it. Detach expired partitions, move them to an archive schema, and optionally offload them to cheaper storage later. The active table stays fast and you still have the history for compliance or analytics.
-- Create an archive schema CREATE SCHEMA IF NOT EXISTS archive; -- Configure pg_partman to detach (not drop) expired partitions UPDATE partman.part_config SET retention = '6 months', retention_keep_table = true, -- detach, don't drop retention_schema = 'archive' -- move detached partitions here WHERE parent_table = 'public.payments';
When run_maintenance() runs, partitions older than 6 months are detached from the parent and moved to the archive schema. You can still query them as standalone tables:
-- Query archived data directly SELECT * FROM archive.payments_p2024_01 WHERE customer_id = 42;
To finish the pipeline, add a scheduled function that exports the detached partitions and drops them. Note that COPY ... TO a server file needs superuser or the pg_write_server_files role, which you may not have on a managed service.
-- Example: export archived partitions after detach CREATE OR REPLACE FUNCTION archive_and_compress() RETURNS void LANGUAGE plpgsql AS $$ DECLARE tbl text; BEGIN -- Find tables in archive schema that haven't been compressed FOR tbl IN SELECT tablename FROM pg_tables WHERE schemaname = 'archive' AND tablename LIKE 'payments_p%' AND tablename NOT IN ( SELECT tablename FROM pg_tables WHERE schemaname = 'archive_compressed' ) LOOP -- Export via COPY EXECUTE format( 'COPY (SELECT * FROM archive.%I) TO ''/archive/%s.csv'' WITH (FORMAT csv, HEADER)', tbl, tbl ); -- Drop after successful export EXECUTE format('DROP TABLE archive.%I', tbl); END LOOP; END; $$; -- Schedule weekly archival SELECT cron.schedule( 'archive-compress', '0 2 * * 0', -- Sundays at 2am $$SELECT archive_and_compress()$$ );
Archiving to Iceberg with pg_lake
For time-series data that outgrows what you need in Postgres day to day, pg_lake lets you keep the history in Iceberg tables on object storage. Postgres holds the recent partitions, and Snowflake (or any Iceberg-aware engine) can query the full history.
With pg_lake, an Iceberg table is just a table created USING iceberg. You can copy older data into it with plain SQL:
-- Create an Iceberg table for payment history CREATE TABLE payments_history ( payment_id bigint, customer_id int, amount numeric(10,2), payment_date date ) USING iceberg; -- Copy an old partition into Iceberg before dropping it INSERT INTO payments_history SELECT payment_id, customer_id, amount, payment_date FROM payments_2023;
For ongoing sync rather than one-off copies, pair pg_lake with pg_incremental and pg_cron to move new rows on a schedule, and put a BRIN index on the timestamp column you use as the watermark. On Snowflake Postgres, pg_lake needs a STANDARD or HIGH MEMORY instance (not BURSTABLE), and you can expose these Iceberg tables to Snowflake through a catalog integration.
On the Snowflake side, historical queries look like any other SQL:
-- In Snowflake: query historical data written by pg_lake SELECT customer_id, SUM(amount) as total_payments FROM payments_history WHERE payment_date BETWEEN '2020-01-01' AND '2023-12-31' GROUP BY customer_id;
You get fast OLTP queries on recent data in Postgres and scalable analytics over the full history in Snowflake.
Snowflake Postgres supports native partitioning and pg_partman. For time-series data, pg_lake can move older partitions into Iceberg so Postgres stays lean and the history stays queryable from Snowflake.
When to Partition (and When Not To)
Partitioning adds complexity. It pays off on the right problems and just gets in the way on the wrong ones.
Partition When
- Tables are in the hundreds of millions of rows or more. Index maintenance and vacuum get noticeably easier on smaller pieces.
- Data is time-series or append-only. You get natural range boundaries and easy retention.
- You have retention requirements. Dropping a partition is instant; bulk deletes are slow and bloat the table.
- Maintenance is painful. VACUUM and REINDEX run per partition, so each job is smaller.
- You ingest high volumes, like IoT data. Daily or hourly partitions keep each piece manageable.
Don't Partition When
- The table is under roughly 10M rows. The overhead usually outweighs the benefit, and indexes are enough.
- Queries usually span all partitions. If you rarely filter on the partition key, you won't get pruning.
- A simple index solves the problem. Try an index first and partition only when indexes aren't enough.
- You'd end up with lots of tiny partitions. Many small partitions add planner overhead without much payoff.
Common Mistakes
Partitioning small tables. If your table has 5M rows and a proper index, queries are already fast. Partitioning adds planning overhead and complicates schema changes for no gain.
Choosing the wrong partition key. If most queries filter by customer_id but you partition by created_at, pruning won't help your most common queries.
Too many partitions. Each partition is a separate table internally, and thousands of them slow down planning. Pick an interval that keeps each partition reasonably large and the total count manageable (a few hundred at most is a sensible ceiling for most workloads).
Forgetting to pre-create partitions. Without pg_partman or a DEFAULT partition, inserts fail when they hit a range with no matching partition. Always have a safety net.
Decision Framework
Ask yourself:
- Is the table large enough that maintenance (VACUUM, REINDEX) is painful?
- Do queries naturally filter on a column that makes a good partition key?
- Do you need to drop or archive old data efficiently?
If you answer yes to two or more, partitioning is probably worth it.
Conclusion
Use range partitioning for time-series workloads, list for categorical splits, and hash for even distribution. Let pg_partman handle the routine partition creation and retention, and check with EXPLAIN that pruning is actually happening.
Related Resources
Complete guide to native partitioning with automated management.
Combine partitioning with Iceberg for scalable time-series workloads.
Official docs on declarative partitioning, pruning, and constraints.
How to use and manage the DEFAULT partition effectively.
This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances