Introduction
As datasets grow past the tens of millions of rows, even well-indexed PostgreSQL tables begin to slow down. Queries that once returned in milliseconds stretch into seconds. Maintenance operations like VACUUM and REINDEX start blocking production traffic. Indexes bloat beyond practical size. At this point, the single-table approach hits a wall that no amount of hardware scaling can easily solve.
PostgreSQL table partitioning offers a structural solution to this problem. By dividing a large logical table into smaller physical pieces, you can make queries faster, maintenance cheaper, and data lifecycle management simpler. Partitioning is not a magic bullet, but when applied correctly, it is one of the most powerful tools in a PostgreSQL performance toolkit.
This guide covers every major partitioning strategy available in PostgreSQL, explains when to use each one, and provides production-ready code examples you can adapt to your own projects. Whether you are managing a growing analytics warehouse or a high-throughput transactional system, the strategies here will help you design a partitioned schema that scales.
Table of Contents
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
Partitioning in PostgreSQL means splitting a single logical table into multiple physical tables, called partitions, that share the same schema. PostgreSQL treats the partitioned table as a single entity for querying, but the query planner routes each query to only the relevant partitions when possible. This process is called partition pruning and it is the primary performance benefit of partitioning.
Before diving into strategies, understand these foundational concepts:
- Partitioned Table: The logical table definition that declares the partition key and method. It contains no data itself.
- Partition: A physical child table that stores a subset of the parent table's data. Each partition is a regular PostgreSQL table with its own indexes and storage.
- Partition Key: The column or columns that determine how rows are assigned to partitions. The choice of partition key is the most critical design decision.
- Partition Pruning: The query planner's ability to exclude irrelevant partitions from a scan, dramatically reducing I/O.
- Partition Bound: The rule that defines which rows belong to which partition. Boundaries must be non-overlapping and cover all possible values.
Architecture Overview
PostgreSQL implements partitioning through table inheritance. The parent table inherits from nothing, but child partition tables inherit from the parent. When you query the parent table, the planner examines the WHERE clause against the partition key and eliminates partitions that cannot contain matching rows.
There are three native partitioning methods introduced in PostgreSQL 10 and refined in later versions:
- Range Partitioning: Divides data by ranges of the partition key. Ideal for time-series data where each partition holds a month, quarter, or year of records.
- List Partitioning: Divides data by discrete values. Useful when rows belong to known categories like regions, status codes, or tenant IDs.
- Hash Partitioning: Distributes data evenly across partitions using a hash function. Best for load balancing when no natural range or list boundary exists.
Each method has distinct tradeoffs in query performance, maintenance complexity, and data distribution. The right choice depends on your access patterns, data shape, and operational requirements.
Step-by-Step Guide
Step 1: Identify Your Partition Key
The partition key determines how data is distributed and which queries benefit from pruning. Ask yourself these questions:
- What column do my most frequent queries filter on?
- Does my data have a natural ordering (timestamps, sequential IDs)?
- Are there discrete categories I need to isolate?
- Will new data always append to the end (append-only workloads)?
If your queries frequently filter by date, a timestamp column is the strongest candidate. If you need to isolate specific categories, a list-based key works better. For uniform distribution across an arbitrary key, hash partitioning is the answer.
Step 2: Choose the Partitioning Method
Match your access pattern to the method:
- Range: Time-series data, chronological logs, financial records by period.
- List: Multi-tenant data by tenant_id, geographic data by region, status-based segmentation.
- Hash: Even distribution for write-heavy workloads, avoiding hot partitions.
Step 3: Create the Partitioned Parent Table
Define the parent table with the PARTITION BY clause. Specify the method and the key column(s).
Step 4: Create Individual Partitions
For each partition, create a child table that inherits from the parent and define its bounds.
Step 5: Create Indexes on Each Partition
Indexes must be created on individual partitions, not on the parent table. PostgreSQL does not automatically propagate index creation to partitions.
Step 6: Verify Partition Pruning
Use EXPLAIN ANALYZE to confirm that queries are pruning partitions correctly. The execution plan should show only the relevant partitions being scanned.
Real-World Examples
Example 1: Time-Series Event Logging
A SaaS application logs user activity events. The events table grows by millions of rows per day. Queries almost always filter by event_date. Range partitioning by month keeps each partition to a manageable size and makes purging old data trivial.
Example 2: Multi-Tenant E-Commerce Platform
An e-commerce platform serves multiple merchants. Each merchant's orders should be isolated for data residency and query performance. List partitioning by tenant_id ensures that queries scoped to a single merchant only scan that merchant's partition.
Example 3: High-Write IoT Sensor Data
An IoT platform ingests sensor readings from thousands of devices. Writes are distributed across device IDs, and no single device dominates. Hash partitioning on device_id spreads writes evenly across all partitions, avoiding a single hot partition that becomes a bottleneck.
Production Code Examples
Range Partitioning by Month
CREATE TABLE events ( id BIGSERIAL, event_type VARCHAR(50) NOT NULL, event_data JSONB, occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, occurred_at)) PARTITION BY RANGE (occurred_at);CREATE TABLE events_2025_01 PARTITION OF events FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');CREATE TABLE events_2025_02 PARTITION OF events FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');CREATE TABLE events_2025_03 PARTITION OF events FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');CREATE INDEX idx_events_2025_01_type ON events_2025_01 (event_type);CREATE INDEX idx_events_2025_02_type ON events_2025_02 (event_type);CREATE INDEX idx_events_2025_03_type ON events_2025_03 (event_type);List Partitioning by Tenant
CREATE TABLE orders ( id BIGSERIAL, tenant_id INTEGER NOT NULL, order_date DATE NOT NULL, total_amount NUMERIC(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', PRIMARY KEY (id, tenant_id)) PARTITION BY LIST (tenant_id);CREATE TABLE orders_tenant_1 PARTITION OF orders FOR VALUES IN (1);CREATE TABLE orders_tenant_2 PARTITION OF orders FOR VALUES IN (2);CREATE TABLE orders_tenant_3 PARTITION OF orders FOR VALUES IN (3);CREATE INDEX idx_orders_tenant_1_date ON orders_tenant_1 (order_date);CREATE INDEX idx_orders_tenant_2_date ON orders_tenant_2 (order_date);CREATE INDEX idx_orders_tenant_3_date ON orders_tenant_3 (order_date);Hash Partitioning for Even Distribution
CREATE TABLE sensor_readings ( id BIGSERIAL, device_id INTEGER NOT NULL, reading_value DOUBLE PRECISION NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, device_id)) PARTITION BY HASH (device_id);CREATE TABLE sensor_readings_p0 PARTITION OF sensor_readings FOR VALUES WITH (MODULUS 4, REMAINDER 0);CREATE TABLE sensor_readings_p1 PARTITION OF sensor_readings FOR VALUES WITH (MODULUS 4, REMAINDER 1);CREATE TABLE sensor_readings_p2 PARTITION OF sensor_readings FOR VALUES WITH (MODULUS 4, REMAINDER 2);CREATE TABLE sensor_readings_p3 PARTITION OF sensor_readings FOR VALUES WITH (MODULUS 4, REMAINDER 3);Attaching a New Partition Dynamically
CREATE TABLE events_2025_04 PARTITION OF events FOR VALUES FROM ('2025-04-01') TO ('2025-05-01');ANALYZE events_2025_04;Detaching a Partition for Archiving
ALTER TABLE events DETACH PARTITION events_2024_01;-- Optionally rename and move to an archive schemaALTER TABLE events_2024_01 SET SCHEMA archive;Comparison Table
| Partitioning Method | Best For | Pruning Efficiency | Write Distribution | Maintenance Complexity | PostgreSQL Version |
|---|---|---|---|---|---|
| Range | Time-series, chronological data | Excellent for range queries | Good for append-only workloads | Medium — requires adding new partitions | 10+ |
| List | Categorical data, multi-tenant | Excellent for equality queries | Depends on key distribution | Low — static set of values | 10+ |
| Hash | Uniform write distribution | Poor — pruning rarely applies | Excellent — even spread | Low — fixed number of partitions | 10+ |
| Composite (Range + List) | Time-series with tenant isolation | Excellent for both dimensions | Good | High — nested partition management | 11+ |
Best Practices
Choose the partition key based on your most common query patterns. The partition key is the single most important decision in your partitioning design. If your queries filter by date 80% of the time, make the partition key a date column. Do not optimize for the 20% of queries that do not benefit from pruning.
Keep partitions at a manageable size. A partition should ideally contain between 1 million and 10 million rows, depending on row width and query patterns. Partitions that are too small create excessive overhead from planning and metadata. Partitions that are too large reduce the benefit of pruning.
Always include the partition key in the primary key. PostgreSQL requires that the primary key of a partitioned table includes the partition key. This ensures uniqueness across all partitions and allows the planner to use partition pruning with primary key lookups.
Use CREATE INDEX on each partition individually. Indexes do not propagate from the parent table. Create indexes on each partition after it is created, and maintain them as part of your partition management process.
Automate partition creation. Use pg_cron or a scheduled job to create future partitions before data arrives. A missing partition will cause INSERT failures with a clear error, but it is better to avoid that failure entirely.
Run ANALYZE after attaching new partitions. The query planner relies on statistics to make pruning decisions. A newly attached partition has no statistics until you run ANALYZE on it.
Use DEFAULT partitions sparingly. A DEFAULT partition catches rows that do not match any other partition's bound. While convenient, it defeats the purpose of pruning for those rows and can become a performance bottleneck.
Common Mistakes
Choosing a partition key that does not align with query filters. If your queries filter by customer_id but you partition by event_date, partition pruning will not help most of your queries. The partition key must match the WHERE clauses you actually use.
Creating too many small partitions. Partitioning by day for a table that receives billions of rows creates thousands of partitions. PostgreSQL's query planner must examine every partition's metadata. Beyond a certain threshold, the planning overhead itself becomes a bottleneck. Aim for a manageable number of partitions — typically dozens, not thousands.
Forgetting to include the partition key in unique constraints. If you define a UNIQUE constraint or primary key without the partition key, PostgreSQL will reject it because uniqueness cannot be guaranteed across partitions.
Neglecting partition maintenance. Partitions are not self-maintaining. You must create new partitions for incoming data ranges, detach old partitions for archiving, and rebuild indexes on partitions that experience heavy updates.
Using partitioning as a substitute for proper indexing. Partitioning reduces the data scanned per query, but it does not replace indexes. Each partition still needs appropriate indexes for the queries it handles.
Assuming hash partitioning improves read performance. Hash partitioning only improves write distribution. It provides no pruning benefit for range or equality queries on the hash key, and it makes cross-partition queries more expensive.
Performance Tips
Enable constraint exclusion. PostgreSQL's constraint_exclusion setting should be set to partition or on (the default). This tells the planner to check partition bounds and skip irrelevant partitions. Verify it is not accidentally set to off in your postgresql.conf.
Use CHECK constraints on partitions. PostgreSQL automatically adds CHECK constraints to enforce partition bounds. These constraints are what enable constraint exclusion. Do not remove or disable them.
Consider partition-level tablespaces. You can place older partitions on slower storage and recent partitions on fast SSDs. This is particularly useful for time-series data where recent data is queried frequently and historical data is accessed rarely.
Batch large DELETE operations by detaching partitions. If you need to remove old data, detaching a partition is orders of magnitude faster than DELETE FROM. The detached partition can be archived, dropped, or retained in a separate tablespace.
Monitor partition pruning with EXPLAIN ANALYZE. After running a query, check the execution plan for the actual partitions scanned. If the planner is scanning more partitions than expected, review your WHERE clauses and partition bounds.
Use pg_partman for automated partition management. pg_partman is a well-maintained extension that handles partition creation, retention, and maintenance. It supports range, list, and hash partitioning with configurable intervals and retention policies.
Security Considerations
Row-level security on partitions. PostgreSQL's Row-Level Security (RLS) policies apply to each partition individually. If you use multi-tenant partitioning with RLS, ensure your policies are defined on the parent table and propagate correctly to child partitions. Test this behavior thoroughly before deploying to production.
Access control on individual partitions. You can grant different permissions on different partitions. For example, a reporting user might have SELECT access only on historical partitions and no access on the current active partition. This adds a layer of data isolation beyond the application level.
Audit trail integrity. When detaching partitions for archiving, ensure your audit processes capture the detachment event. A detached partition is no longer part of the parent table, and queries against the parent will not see its data. Document partition lifecycle events in your change management process.
Deployment Notes
PostgreSQL version requirements. Native partitioning was introduced in PostgreSQL 10. Version 11 improved partition pruning for JOINs and subqueries. Version 12 added default partitions and improved multi-column partition support. Version 14 introduced merge joins on partitioned tables. Always use the latest stable PostgreSQL version to get the best partitioning performance.
Migration strategy for existing tables. Converting an existing large table to a partitioned table requires careful planning. The standard approach is to create the new partitioned table, create partitions, use INSERT INTO ... SELECT to migrate data in batches, and then swap the old table out. Plan for downtime or use a dual-write strategy during the migration window. Monitoring and alerting. Monitor partition sizes, the number of partitions, and the frequency of partition creation failures. Set alerts for when a partition exceeds its target size or when a scheduled partition creation job fails. Tools like pg_stat_user_tables and pg_partman's built-in monitoring can help track partition health. Backup and restore considerations. pg_dump handles partitioned tables correctly, including all child partitions. However, restoring a large partitioned table can be slow because each partition is restored independently. Consider using pg_restore with parallel jobs to speed up the process, and test your restore procedure on a staging environment before relying on it in production. Query not pruning partitions? Run EXPLAIN ANALYZE on the query and look for the actual partitions scanned. If all partitions appear, check that your WHERE clause includes the partition key in a form the planner can recognize. Functions on the partition key column (e.g., DATE_TRUNC on a timestamp) can prevent pruning because the planner cannot match the function output to partition bounds. INSERT failing with "no partition of relation"? This means the row's partition key value does not fall within any defined partition's bounds. Either add a new partition for the missing range or add a DEFAULT partition to catch unexpected values. Slow queries on a specific partition? Check that the partition has appropriate indexes. A partitioned table with indexes on the parent but not on individual partitions will perform sequential scans on each partition it touches. Statistics are stale after partition maintenance? After attaching, detaching, or bulk-loading data into partitions, run ANALYZE on the affected partitions. Stale statistics lead the planner to choose suboptimal execution plans. Lock contention on the parent table? DDL operations on partitions (like ATTACH PARTITION or DETACH PARTITION) acquire locks on the parent table. Schedule partition maintenance during low-traffic periods and use LOCK_TIMEOUT to prevent long-running maintenance from blocking production queries. PostgreSQL does not enforce a hard limit on the number of partitions. However, practical limits emerge from query planner performance and catalog overhead. Most production systems perform well with up to a few hundred partitions. Beyond that, the planner's planning time can become significant, and maintenance tasks like VACUUM and ANALYZE take longer. No. The partition key is fixed at table creation time. To change it, you must create a new partitioned table with the desired key, migrate data, and swap the tables. This is why choosing the right partition key upfront is critical. Hash partitioning can improve INSERT performance for write-heavy workloads by distributing writes across multiple partitions and reducing index contention on a single table. Range partitioning helps INSERT performance for append-only workloads by keeping indexes smaller per partition. However, partitioning adds overhead for partition routing, so the net benefit depends on your workload. Yes, but the sequence must be shared across partitions. Define the sequence independently and reference it in each partition's DEFAULT clause, or use a global sequence. PostgreSQL 13+ handles this more gracefully with the DEFAULT clause on the parent table. A foreign key can reference a partitioned table, but the referenced table must be the partitioned parent. The planner can then prune partitions on the referencing side when the join uses the partition key. Foreign keys from a partitioned table to a non-partitioned table are supported, but the reverse direction has limitations. Partition pruning is automatic in PostgreSQL 11 and later when constraint_exclusion is set to partition or on (the default). You do not need to enable it explicitly. The planner examines CHECK constraints on each partition and excludes those that cannot contain matching rows. Declarative partitioning (PostgreSQL 10+) is the modern approach where you define partitions as part of the CREATE TABLE statement using PARTITION BY. Inheritance-based partitioning is the older approach where child tables inherit from the parent using the INHERITS clause. Declarative partitioning is simpler, more robust, and supports features like DEFAULT partitions and better constraint exclusion. Probably not. The overhead of managing partitions outweighs the benefits for small tables. Partitioning is most valuable when a table has tens of millions of rows or more, or when you need data lifecycle management (archiving, purging) that is easier at the partition level. PostgreSQL partitioning is a mature, powerful feature that can dramatically improve the performance and manageability of large tables. Range partitioning excels for time-series data, list partitioning is ideal for categorical and multi-tenant workloads, and hash partitioning provides even write distribution when no natural boundary exists. The key to success is choosing the right partition key, keeping partitions at a manageable size, automating partition creation and maintenance, and verifying that partition pruning is working as expected. Combine partitioning with proper indexing, regular ANALYZE, and monitoring, and you will have a database that scales gracefully as your data grows. Start with a single partitioning strategy on your most problematic table, measure the impact, and iterate. Partitioning is not a one-time setup — it is an ongoing operational practice that evolves with your data. Implement the patterns in this guide, adapt them to your specific workload, and your PostgreSQL instance will handle growth that would otherwise be unmanageable.Debugging Tips
FAQ
What is the maximum number of partitions PostgreSQL supports?
Can I change the partition key after creating a partitioned table?
Does partitioning improve INSERT performance?
Can I use SERIAL or IDENTITY columns with partitioned tables?
How does partitioning interact with foreign keys?
Is partition pruning automatic, or do I need to configure it?
What is the difference between declarative and inheritance-based partitioning?
Should I partition a table with fewer than 1 million rows?
Conclusion