SQL Table Partitioning — Function on Key Disables Pruning
A function on the partition key column in WHERE disables pruning, scanning all 800M rows instead of expected 2M.
20+ years shipping high-throughput database systems. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Partitioning splits one table into physical segments based on a rule — date, region, category, or hash
- Partition pruning is the core benefit: the planner skips partitions that cannot contain matching rows
- Four strategies: range (dates), list (categories), hash (even distribution), composite (range + sub-hash or list)
- Dropping old data becomes a metadata operation (DROP PARTITION) instead of a row-by-row DELETE
- Partition key must appear in WHERE clause for pruning — wrapping it in a function kills pruning silently
- Biggest mistake: partitioning on a column not used in queries — adds overhead with zero pruning benefit
SQL table partitioning is a database design technique that physically splits a large table into smaller, more manageable segments called partitions, based on a defined key column (e.g., date, region, or ID). The primary reason to partition is performance: queries that filter on the partition key can skip irrelevant partitions entirely—a process called partition pruning—dramatically reducing I/O and scan times.
It also simplifies data lifecycle management: you can drop or archive entire partitions in milliseconds rather than running costly DELETE operations on millions of rows. Major databases like PostgreSQL (declarative partitioning since v10), MySQL (InnoDB), and SQL Server all support it, but the implementation details and pruning behavior vary significantly.
However, partitioning is not a silver bullet. The most common pitfall—and the focus of this article—is that wrapping the partition key in a function (e.g., WHERE DATE(created_at) = '2024-01-01' instead of WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02') disables partition pruning entirely.
The database cannot statically determine which partitions to scan because the function obscures the underlying key value. This turns a targeted query into a full table scan across all partitions, silently destroying performance at scale. You should avoid partitioning on columns that are frequently transformed in WHERE clauses, or use generated columns or expression-based partitioning (where supported) to preserve pruning.
Alternatives to partitioning include indexing (B-tree, BRIN, or GiST) for smaller tables, or sharding across separate database instances for extreme write throughput. Partitioning is best suited for append-heavy workloads (e.g., time-series logs, event streams) where you query recent data and archive old data.
It is overkill for tables under 10 million rows or for OLTP systems with frequent cross-partition updates, where the overhead of partition routing and metadata management can outweigh the benefits. Always verify pruning behavior using EXPLAIN plans—never assume it works.
Imagine a massive filing cabinet with 10 million folders — finding one folder means searching every drawer. Now imagine splitting those folders into 12 labelled drawers, one per month. You instantly know which drawer to open. SQL table partitioning does exactly that: it physically divides one enormous table into smaller, manageable chunks (called partitions) based on a rule you define — like date, region, or category. The table still looks like one table to your application, but the database engine is quietly routing queries to only the relevant chunk.
At some point in every database engineer's career, a query that used to run in 200ms starts taking 45 seconds. The table hasn't changed structurally — it's just grown from 2 million rows to 800 million. Indexes help, but even a perfectly tuned B-tree index on 800 million rows requires significant I/O just to traverse the tree and resolve heap pages. You start questioning your index design, your query shape, your statistics configuration — and most of the time the real answer is that you've hit the ceiling of what single-segment storage can do efficiently.
This is where table partitioning stops being an academic concept and becomes an operational lifeline.
The core mechanism partitioning exploits is called partition pruning: instead of scanning or even index-seeking across the full dataset, the query planner eliminates entire physical segments it knows cannot contain rows matching your WHERE clause. A query filtering on the last 30 days never touches the 7 years of historical data sitting in other partitions — those segments are excluded at plan time, before a single page is read from disk. Beyond query performance, partitioning unlocks fast bulk operations. Dropping a year's worth of old data becomes a near-instant metadata operation (DROP PARTITION) instead of a DELETE that locks the table for hours, generates hundreds of gigabytes of WAL, and leaves you running VACUUM for days.
I've seen teams partition a table and wonder why nothing improved — usually because they partitioned on the wrong column, or because a single function call in the WHERE clause was silently disabling pruning on every query. I've also seen partitioning done right turn a 45-second dashboard query into a 180ms one without touching a single index or rewriting application logic.
By the end of this article you'll understand how each partitioning strategy works at the storage level, write production-ready DDL for range, list, hash, and composite schemes, diagnose when partition pruning is silently failing, manage partition maintenance without downtime, and avoid the three mistakes that turn partitioning from a performance win into a support nightmare.
Why Partition Pruning Fails When the Partition Key Is Wrapped in a Function
SQL table partitioning splits a large table into smaller physical segments (partitions) based on a partition key — typically a date column or an ID range. The core mechanic is partition pruning: the query planner eliminates irrelevant partitions at plan time, scanning only the subset that could contain matching rows. This is not an optimization hint; it's a structural property of the storage layout.
Pruning works because the planner compares query predicates directly against the partition boundary metadata. When you write WHERE created_at >= '2024-01-01', the planner knows which partitions hold January data. But if you wrap the partition key in a function — WHERE DATE(created_at) >= '2024-01-01' — the planner sees an opaque expression, not a direct column comparison. It cannot infer the partition range, so it falls back to a full scan of all partitions. This is not a planner bug; it's a fundamental limitation of how partition metadata is stored and matched.
Use partitioning when you have time-series data exceeding 100M rows, or when you need to drop old data via DROP PARTITION instead of slow DELETE. But never assume the planner will be smart about function-wrapped keys. The performance cost of a missed prune is linear in the number of partitions — if you have 365 daily partitions, a missed prune means scanning 365× more data than necessary.
DATE(ts) disables pruning in PostgreSQL, MySQL, and most engines. Only direct column comparisons use partition metadata.event_date but wrote queries with WHERE DATE(event_date) = CURRENT_DATE. The planner scanned all 365 partitions every night, causing 40-minute query times and I/O saturation on the SAN. Rule: always compare the raw partition column — use event_date = CURRENT_DATE or a range event_date >= '2024-01-01' AND event_date < '2024-01-02'.Partitioning Strategies — Range, List, Hash, and Composite
SQL offers four partitioning strategies, each designed for a different access pattern. Choosing the wrong strategy is the most common partitioning mistake — it adds storage and planning overhead with zero pruning benefit on your actual queries.
Range partitioning splits data by a continuous value — typically a date or timestamp. Each partition covers a defined interval (e.g., January 2026, February 2026). This is the most common strategy for time-series data, event logs, and financial transactions. Queries filtering on date ranges prune efficiently because the planner can compare your WHERE clause bounds against partition boundaries directly at plan time — no row evaluation needed.
List partitioning splits data by discrete values — a region code, status, or category. Each partition contains rows matching a specific set of known values (e.g., region IN ('US', 'CA')). This works well when your queries filter on a fixed enumeration of categories and you want each category group to live in its own physical segment for both pruning and operational isolation.
Hash partitioning distributes rows evenly across N partitions using a hash function applied to the partition key. There is no semantic meaning to which partition a row lands in — the goal is purely even distribution. Hash partitioning is the right choice when you have no natural range or list dimension but need to reduce per-partition size for maintenance operations like index rebuilds and vacuum. It provides no pruning benefit for range queries.
Composite partitioning combines two strategies — typically range at the first level and hash (or list) at the second level. For example: partition by month (range), then sub-partition each month into 4 hash buckets. This gives you time-based pruning at the top level and even distribution within each time window at the second level. It's the right call when pure range partitioning creates hot partitions because recent months concentrate all writes.
-- ============================================================ -- RANGE PARTITIONING: split by date — most common for time-series -- Each partition holds one month of data -- ============================================================ CREATE TABLE orders ( order_id BIGINT NOT NULL, customer_id INT NOT NULL, total_amount DECIMAL(10, 2), created_at TIMESTAMP NOT NULL, status VARCHAR(20) ) PARTITION BY RANGE (created_at); -- Create monthly partitions CREATE TABLE orders_2026_01 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'); CREATE TABLE orders_2026_02 PARTITION OF orders FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'); CREATE TABLE orders_2026_03 PARTITION OF orders FOR VALUES FROM ('2026-03-01') TO ('2026-04-01'); -- ... continue for each month -- Catch-all partition for values outside defined ranges -- Without this, out-of-range inserts fail with a hard error CREATE TABLE orders_default PARTITION OF orders DEFAULT; -- ============================================================ -- LIST PARTITIONING: split by discrete category values -- Each partition holds a specific region's data -- ============================================================ CREATE TABLE transactions ( txn_id BIGINT NOT NULL, amount DECIMAL(12, 2), region VARCHAR(10) NOT NULL, txn_date DATE ) PARTITION BY LIST (region); CREATE TABLE txn_north_america PARTITION OF transactions FOR VALUES IN ('US', 'CA', 'MX'); CREATE TABLE txn_europe PARTITION OF transactions FOR VALUES IN ('UK', 'DE', 'FR', 'ES', 'IT'); CREATE TABLE txn_asia_pacific PARTITION OF transactions FOR VALUES IN ('JP', 'KR', 'AU', 'IN', 'SG'); CREATE TABLE txn_default PARTITION OF transactions DEFAULT; -- ============================================================ -- HASH PARTITIONING: even distribution — no semantic meaning -- Use when you have no natural range or list dimension -- ============================================================ CREATE TABLE user_events ( event_id BIGINT NOT NULL, user_id INT NOT NULL, event_type VARCHAR(50), event_time TIMESTAMP ) PARTITION BY HASH (user_id); CREATE TABLE user_events_p0 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 0); CREATE TABLE user_events_p1 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 1); CREATE TABLE user_events_p2 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 2); CREATE TABLE user_events_p3 PARTITION OF user_events FOR VALUES WITH (MODULUS 4, REMAINDER 3); -- ============================================================ -- COMPOSITE PARTITIONING: range (monthly) + hash sub-partitions -- Time-based pruning at level 1, even distribution at level 2 -- Solves hot-partition problem when recent months are write-heavy -- ============================================================ CREATE TABLE audit_log ( log_id BIGINT NOT NULL, actor_id INT NOT NULL, action VARCHAR(100), log_time TIMESTAMP NOT NULL ) PARTITION BY RANGE (log_time); -- Monthly partition, itself partitioned by hash on actor_id CREATE TABLE audit_log_2026_01 PARTITION OF audit_log FOR VALUES FROM ('2026-01-01') TO ('2026-02-01') PARTITION BY HASH (actor_id); CREATE TABLE audit_log_2026_01_p0 PARTITION OF audit_log_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 0); CREATE TABLE audit_log_2026_01_p1 PARTITION OF audit_log_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 1); CREATE TABLE audit_log_2026_01_p2 PARTITION OF audit_log_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 2); CREATE TABLE audit_log_2026_01_p3 PARTITION OF audit_log_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 3);
- Range: WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01' prunes to exactly one monthly partition
- List: WHERE region = 'US' prunes to the North America partition group — all other regions are excluded
- Hash: WHERE user_id = 12345 prunes to exactly one hash bucket — the planner computes hash(12345) % modulus at plan time
- Pruning happens at plan time — zero I/O for skipped partitions, not just filtered rows
- Any function applied to the partition key column in WHERE prevents the planner from comparing against boundaries — pruning is disabled entirely
Partition Pruning — The Silent Performance Killer When It Fails
Partition pruning is the entire reason partitioning exists. When it works, queries touch only the relevant physical segment — 1 month out of 48, 1 region out of 10. When it fails silently, every query scans all partitions, and partitioning actually degrades performance compared to the unpartitioned table because of the per-partition planning overhead.
The most common pruning killers, in order of how often I see them in the wild:
- Function calls on the partition key: WHERE EXTRACT(month FROM created_at) = 1 or WHERE DATE_TRUNC('month', created_at) = '2026-01-01'. The planner cannot compare the function's output against partition boundaries without evaluating the function for every possible input value — which it cannot do at plan time. So it gives up and scans everything.
- Implicit type casting: WHERE created_at = '2026-01-15' where created_at is a TIMESTAMP and the literal is a VARCHAR. Some databases resolve this gracefully; others disable pruning rather than risk a type mismatch. Always cast explicitly: WHERE created_at = TIMESTAMP '2026-01-15 00:00:00'.
- OR conditions that include a non-partition-key column: WHERE created_at >= '2026-01-01' OR updated_at >= '2026-01-01' when the table is partitioned by created_at. The OR with updated_at forces the planner to consider every partition because it cannot prune based on a column that isn't the partition key.
- Predicates buried inside subqueries or CTEs: Some planners cannot push WHERE clause predicates down through a CTE boundary, which means the partition scan at the inner level sees no filter and scans everything. Materializing CTEs in older PostgreSQL versions (pre-12) is a common cause of this.
The diagnostic command is always the same: EXPLAIN (ANALYZE, BUFFERS). Look for 'Partitions scanned: N' in the output. If N equals your total partition count, pruning is not working and you need to find why before the query touches production at scale.
-- ============================================================ -- DIAGNOSIS: verify partition pruning is active -- Run EXPLAIN before and after any WHERE clause change -- ============================================================ -- GOOD: bare column with direct range comparison — pruning fires EXPLAIN (ANALYZE, BUFFERS) SELECT order_id, total_amount FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; -- Expected output: Partitions scanned: 1 -- Execution time: ~200ms on 48-partition table -- BAD: function call on partition key — pruning disabled EXPLAIN (ANALYZE, BUFFERS) SELECT order_id, total_amount FROM orders WHERE DATE_TRUNC('month', created_at) = '2026-01-01'; -- Expected output: Partitions scanned: 48 -- Execution time: ~45s — 225x regression -- BAD: EXTRACT on partition key — same problem, different syntax EXPLAIN (ANALYZE, BUFFERS) SELECT order_id, total_amount FROM orders WHERE EXTRACT(year FROM created_at) = 2026 AND EXTRACT(month FROM created_at) = 1; -- Expected output: Partitions scanned: 48 -- BAD: OR with non-partition-key column — forces full scan EXPLAIN (ANALYZE, BUFFERS) SELECT order_id, total_amount FROM orders WHERE created_at >= '2026-01-01' OR updated_at >= '2026-01-01'; -- Expected output: Partitions scanned: 48 -- Fix: split into two queries with UNION ALL if the OR is unavoidable -- ============================================================ -- PARTITION SIZE AUDIT: identify hot or imbalanced partitions -- Run monthly to catch retention and growth anomalies -- ============================================================ SELECT c.relname AS partition_name, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size, pg_stat_user_tables.n_live_tup AS live_rows FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_class p ON p.oid = i.inhparent JOIN pg_stat_user_tables ON pg_stat_user_tables.relname = c.relname WHERE p.relname = 'orders' ORDER BY pg_total_relation_size(c.oid) DESC;
Partition Maintenance — Adding, Dropping, and Managing at Scale
Partitioning shifts database maintenance from row-level operations to metadata-level operations. This is the operational payoff that makes partitioning worth the schema complexity — but only if you manage it correctly. Get maintenance wrong and you end up with the worst of both worlds: the overhead of a partitioned schema and the pain of row-level operations on large datasets.
Dropping old data: Without partitioning, deleting 1 billion rows requires a DELETE statement that generates one WAL entry per deleted row, bloats the table with dead tuples, holds row-level locks during execution, and leaves VACUUM with an enormous cleanup job that takes hours and competes with production reads. With partitioning, ALTER TABLE ... DETACH PARTITION followed by DROP TABLE is a catalog metadata operation — it removes the partition's file pointers from the system catalog in milliseconds. No row-level locks on the parent table, no WAL bloat, no vacuum pressure afterward.
Adding new partitions: For range-partitioned tables, partitions must exist before data arrives. If a row's partition key value falls outside all defined partition ranges and no DEFAULT partition exists, the INSERT fails with a hard error and the transaction rolls back. The standard pattern is a scheduled job (cron or an internal scheduler) that creates partitions 3–6 months ahead using CREATE TABLE IF NOT EXISTS so the job is idempotent.
Sliding window pattern: The production standard for time-series retention is a sliding window: in one monthly maintenance job, create the partition for the month 3 months out and drop the partition from 13 months ago. The table stays at a constant size, the job is fast, and there's no manual intervention needed as long as the job runs.
Partition count limits: Each partition adds overhead to the planner's boundary evaluation step. PostgreSQL handles up to ~500 partitions well; beyond that, query planning time becomes measurable and can exceed execution time for fast queries. If you need more coverage than 500 monthly partitions (roughly 40 years), use composite partitioning — range at the top level with hash sub-partitions — instead of pushing the partition count higher.
-- ============================================================ -- SLIDING WINDOW: automated partition management -- Create future partition, drop expired one — zero downtime -- Run monthly via cron or internal scheduler -- ============================================================ -- Step 1: Create next month's partition before data arrives CREATE TABLE IF NOT EXISTS orders_2026_04 PARTITION OF orders FOR VALUES FROM ('2026-04-01') TO ('2026-05-01'); -- Step 2: Detach the partition from 13 months ago from the parent -- DETACH removes catalog entry without locking the parent table ALTER TABLE orders DETACH PARTITION orders_2025_03; -- Step 3: Drop the physical files — instant once detached DROP TABLE orders_2025_03; -- PostgreSQL 14+: use CONCURRENTLY to avoid any parent table lock during detach -- ALTER TABLE orders DETACH PARTITION orders_2025_03 CONCURRENTLY; -- ============================================================ -- AUTOMATED PARTITION CREATION: stored procedure -- Call monthly to stay 6 months ahead of data arrival -- Schema: io.thecodeforge.maint for operational procedures -- ============================================================ CREATE OR REPLACE PROCEDURE io.thecodeforge.maint.create_monthly_partitions( p_table_name TEXT, p_months_ahead INT DEFAULT 6 ) LANGUAGE plpgsql AS $$ DECLARE v_start_date DATE; v_end_date DATE; v_partition_name TEXT; v_current DATE := DATE_TRUNC('month', CURRENT_DATE); BEGIN FOR i IN 0..p_months_ahead LOOP v_start_date := v_current + (i || ' months')::INTERVAL; v_end_date := v_start_date + INTERVAL '1 month'; v_partition_name := p_table_name || '_' || TO_CHAR(v_start_date, 'YYYY_MM'); EXECUTE format( 'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)', v_partition_name, p_table_name, v_start_date, v_end_date ); RAISE NOTICE 'Ensured partition exists: % (% to %)', v_partition_name, v_start_date, v_end_date; END LOOP; END; $$; -- Usage: call at the start of each month to stay ahead CALL io.thecodeforge.maint.create_monthly_partitions('orders', 6); -- ============================================================ -- PARTITION HEALTH CHECK: detect gaps and size anomalies -- Run weekly — catches maintenance job failures before inserts fail -- ============================================================ SELECT c.relname AS partition_name, pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size, pg_stat_user_tables.n_live_tup AS live_rows, pg_stat_user_tables.last_autoanalyze AS last_analyzed FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_class p ON p.oid = i.inhparent JOIN pg_stat_user_tables ON pg_stat_user_tables.relname = c.relname WHERE p.relname = 'orders' ORDER BY c.relname; -- Monitor DEFAULT partition for unexpected row accumulation -- Rows landing here mean a future partition is missing SELECT COUNT(*) AS default_partition_rows FROM orders_default;
Composite Partitioning and Cross-Partition Query Patterns
When a single partitioning dimension doesn't fully address your access pattern or your write distribution, composite partitioning (also called sub-partitioning) combines two strategies. The most common production pattern is range at the first level for time-based pruning and hash at the second level for even distribution within each time window.
The problem composite partitioning solves: pure range partitioning on a timestamp creates structurally uneven partitions as traffic grows. Your January 2023 partition has 2M rows. Your January 2026 partition has 40M rows because traffic has grown 20x over three years. Writes concentrate on the current month's partition — that single segment becomes a hotspot for concurrent inserts, autovacuum, and index updates. Adding hash sub-partitions distributes writes across N buckets per time period, keeping per-leaf-partition size roughly constant regardless of when the data was written.
The trade-off is total partition count. Partitioning by month over 4 years gives you 48 partitions. Adding 4 hash sub-partitions per month gives you 192. Adding 8 hash sub-partitions gives you 384. That's still under the 500-partition threshold where planner overhead becomes noticeable. But if you go to 16 sub-partitions over 4 years, you're at 768 — and you'll start seeing planning time increase on fast point-lookup queries.
Cross-partition queries deserve specific attention. When a query cannot prune at either level — because it doesn't filter on the partition key or because the predicate is in a form the planner can't evaluate at plan time — the database must probe every leaf partition. On a composite-partitioned table with 192 partitions, that's 192 separate segment scans. That is materially slower than scanning one unpartitioned table with a good index because of the per-partition coordination overhead. Analytics workloads that routinely scan the entire dataset are not good candidates for row-level partitioning — columnar storage or pre-aggregated materialized views are better tools for that access pattern.
-- ============================================================ -- COMPOSITE: range (monthly) + hash (4 buckets per month) -- Prevents hot-partition problem when traffic grows over time -- Total partitions: months × hash_modulus (e.g., 48 × 4 = 192) -- ============================================================ CREATE TABLE event_stream ( event_id BIGINT NOT NULL, user_id INT NOT NULL, event_type VARCHAR(50), event_time TIMESTAMP NOT NULL, payload JSONB ) PARTITION BY RANGE (event_time); -- Monthly range partition, itself partitioned by hash on user_id CREATE TABLE event_stream_2026_01 PARTITION OF event_stream FOR VALUES FROM ('2026-01-01') TO ('2026-02-01') PARTITION BY HASH (user_id); CREATE TABLE event_stream_2026_01_p0 PARTITION OF event_stream_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 0); CREATE TABLE event_stream_2026_01_p1 PARTITION OF event_stream_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 1); CREATE TABLE event_stream_2026_01_p2 PARTITION OF event_stream_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 2); CREATE TABLE event_stream_2026_01_p3 PARTITION OF event_stream_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 3); -- ============================================================ -- PRUNING BEHAVIOR: which queries prune and at which level -- Understanding this is essential for query design on composite tables -- ============================================================ -- FULL PRUNING: both range key and hash key in WHERE -- Planner prunes level 1 (month) then level 2 (hash bucket) -- Touches exactly 1 leaf partition EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM event_stream WHERE event_time >= '2026-01-01' AND event_time < '2026-02-01' AND user_id = 12345; -- Partitions scanned: 1 — the hash bucket for user 12345 within January -- PARTIAL PRUNING: range key only -- Level 1 prunes to one month; level 2 scans all 4 hash buckets -- Still useful — touches 4 partitions instead of 192 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM event_stream WHERE event_time >= '2026-01-01' AND event_time < '2026-02-01'; -- Partitions scanned: 4 (all hash sub-partitions of 2026_01) -- NO PRUNING: hash key only, no range key -- Level 1 cannot prune — hash key alone doesn't establish a time boundary -- Scans every month's hash bucket for the matching remainder -- Often slower than the unpartitioned equivalent due to per-partition overhead EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM event_stream WHERE user_id = 12345; -- Partitions scanned: all leaf partitions across all months -- ============================================================ -- COMPOSITE PARTITION HEALTH CHECK -- Verify all sub-partitions exist for each range parent -- Missing sub-partitions silently drop inserts to the DEFAULT bucket -- ============================================================ SELECT parent.relname AS range_partition, child.relname AS hash_sub_partition, pg_size_pretty(pg_total_relation_size(child.oid)) AS size FROM pg_inherits i1 JOIN pg_class parent ON parent.oid = i1.inhrelid JOIN pg_inherits i2 ON i2.inhparent = parent.oid JOIN pg_class child ON child.oid = i2.inhrelid JOIN pg_class root ON root.oid = i1.inhparent WHERE root.relname = 'event_stream' ORDER BY parent.relname, child.relname;
- Level 1 (range key): WHERE event_time >= X AND event_time < Y prunes to the matching monthly range partition
- Level 2 (hash key): WHERE user_id = Z within a pruned range partition prunes to one hash bucket
- Both keys in WHERE: full pruning — exactly 1 leaf partition scanned regardless of total partition count
- Only range key in WHERE: partial pruning — all hash sub-partitions of the matching month are scanned
- Only hash key in WHERE: no level-1 pruning — all months must be evaluated, then hash prunes within each month
Querying Partition Metadata — Stop Guessing, Start Verifying
You think your table is partitioned? The optimizer might disagree. After a 'quick' ALTER TABLE someone forgot to check, you'll be scanning every partition like it's 1999. SQL Server exposes partition metadata through system views — use them before a production fire drill.
The three views you need: sys.partitions, sys.partition_schemes, and sys.partition_functions. They tell you exactly which partitions exist, their boundary values, and how data is mapped. No guesswork.
Start with sys.partitions to verify partition counts. Then check sys.partition_range_values for boundary clarity. If a partition has zero rows, that's a red flag — maybe your maintenance script dropped something it shouldn't have. Always query metadata before and after schema changes. It's the difference between a calm 'yes' in a meeting and a panicked rollback.
// io.thecodeforge — database tutorial -- Is your SalesOrderHeader actually partitioned? SELECT OBJECT_NAME(p.object_id) AS TableName, p.partition_number, p.rows, prv.boundary_id, prv.value AS boundary_value FROM sys.partitions p LEFT JOIN sys.partition_range_values prv ON prv.function_id = ( SELECT function_id FROM sys.partition_schemes ps WHERE ps.data_space_id = p.data_space_id ) AND prv.boundary_id = p.partition_number - 1 WHERE OBJECT_NAME(p.object_id) = 'SalesOrderHeader' ORDER BY p.partition_number;
Limitations That Will Bite You — Partitioning Isn't a Silver Bullet
Partitioning is a scalpel, not a chainsaw. Most devs learn this after their first failed ALTER TABLE SWITCH or a query that went parallel-psycho. Here's the reality: not all operations play nicely with partitions.
First, you cannot use ALTER TABLE SWITCH to move data between partitions with different columns, collations, or computed columns unless they're persisted. The metadata checks are strict — one mismatch and you're stuck with a manual data move.
Second, only SQL Server Enterprise Edition supports full partition-level operations like TRUNCATE on a single partition. Standard Edition treats partitioned tables as regular tables for many operations — you'll lose the performance benefit.
Third, IDENTITY columns don't reset per partition. If you truncate partition 2, the identity seed won't reset unless the entire table is truncated. That catches teams doing rolling window maintenance.
Fourth, indexes on partitioned tables have their own rules. Non-aligned indexes can kill partition pruning and make queries cross partitions unnecessarily.
// io.thecodeforge — database tutorial -- This SWITCH will fail silently if indexes don't align -- Stage table must have same schema, same clustered index CREATE TABLE SalesOrder_Stage ( OrderDate datetime, OrderID int, Amount decimal(10,2) ) ON [PRIMARY]; -- SWITCH to partition 3 ALTER TABLE SalesOrderHeader SWITCH PARTITION 3 TO SalesOrder_Stage; -- Fails: 'The index ... is not aligned with the switch partition' -- Workaround: ensure stage table has identical partitioned index -- Or use a non-partitioned swap table with no indexes -- Then rebuild indexes on target
Partition Permissions — Who Can Alter the Schema Without Breaking Queries
Partitioning adds schema-level objects that demand specific permissions beyond basic DML. The partition function and scheme are owned by the schema owner, not the table owner. Granting ALTER on a table does not automatically grant rights to modify partitions. To split or merge a partition boundary, a user needs ALTER ANY DATASPACE or ownership of the partition function. Without it, ADD PARTITION or SWITCH PARTITION fails with permission errors. Even viewing partition metadata via sys.partition_functions requires VIEW DEFINITION. The silent failure occurs when a DBA adds a new filegroup but forgets to grant the partition function owner execute rights on the filegroup’s database. Production trap: granting db_ddladmin to an application service account exposes the entire partition structure to accidental boundary shifts. Lock down partition function changes to a separate role and audit their usage.
// io.thecodeforge — database tutorial -- Grant rights to view partition scheme metadata GRANT VIEW DEFINITION ON PARTITION SCHEME: MonthlyScheme TO ReportingUser; -- Grant ability to alter partition structure GRANT ALTER ANY DATASPACE TO PartitionAdmin; -- Error: missing permission causes silent failure -- User must own partition function or have ALTER ANY DATASPACE ALTER PARTITION FUNCTION MonthlyPF() SPLIT RANGE ('2025-04-01'); -- Verify effective permissions SELECT dp.name, p.permission_name FROM sys.database_permissions p JOIN sys.database_principals dp ON p.grantee_principal_id = dp.principal_id WHERE p.major_id = OBJECT_ID('PartitionFunction: MonthlyPF');
New Filegroups for Partitions — When and Why to Create Them
Partitioning across a single filegroup negates I/O isolation, the primary performance benefit. When each partition resides on its own filegroup, you can place filegroups on separate physical drives or storage tiers. Create filegroups before the partition scheme references them. Use ALTER DATABASE ADD FILEGROUP, then add files. A common mistake is creating one filegroup for the entire table instead of per partition range. Hot partitions — like current month data — benefit from fast SSD filegroups; cold partitions can live on slower HDD filegroups. When you split a partition boundary, the new partition inherits the default filegroup unless you specify NEXT USED. Production trap: adding a filegroup after the scheme is built requires dropping and recreating the scheme, which forces a full data movement. Plan all filegroups upfront. For historical data, rotate filegroups quarterly and set them to READ_ONLY after the partition is no longer written to.
// io.thecodeforge — database tutorial -- Create filegroups for each partition range ALTER DATABASE SalesDB ADD FILEGROUP FG_Month2025_01; ALTER DATABASE SalesDB ADD FILEGROUP FG_Month2025_02; -- Add physical files to each filegroup ALTER DATABASE SalesDB ADD FILE ( NAME = 'Data_2025_01', FILENAME = 'D:\SSD\Data_2025_01.ndf' ) TO FILEGROUP FG_Month2025_01; ALTER DATABASE SalesDB ADD FILE ( NAME = 'Data_2025_02', FILENAME = 'E:\HDD\Data_2025_02.ndf' ) TO FILEGROUP FG_Month2025_02; -- Reference filegroups in partition scheme CREATE PARTITION SCHEME MonthlyScheme AS PARTITION MonthlyPF TO (FG_Month2025_01, FG_Month2025_02);
Partition pruning silently disabled — query scanned 800M rows instead of 2M
- Partition pruning requires the partition key to appear as a bare column in WHERE — no functions, no expressions, no casting through a function
- Always verify pruning with EXPLAIN immediately after deploying a partitioned schema — do not assume it works
- Add automated EXPLAIN checks in CI targeting your critical queries; pruning regressions are easy to introduce and hard to notice in production until latency spikes
- DATE_TRUNC, EXTRACT, CAST, and COALESCE applied to the partition key column in WHERE all disable pruning — this is not a bug, it is a fundamental constraint of how the planner evaluates partition boundaries at plan time
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';SELECT schemaname, tablename, n_live_tup AS row_count FROM pg_stat_user_tables WHERE tablename LIKE 'orders_%' ORDER BY tablename;SELECT COUNT(*) FROM pg_inherits i JOIN pg_class p ON p.oid = i.inhparent WHERE p.relname = 'orders';SELECT partitionname, partitionrangestart, partitionrangeend FROM pg_partitions WHERE tablename = 'orders' ORDER BY partitionrangestart;SELECT pid, query, state, now() - query_start AS duration FROM pg_stat_activity WHERE state = 'active' AND query ILIKE '%partition%' ORDER BY duration DESC;SELECT locktype, relation::regclass, mode, granted FROM pg_locks WHERE NOT granted;| Strategy | Best For | Pruning Trigger | Data Distribution | DROP for Retention | Max Practical Partitions |
|---|---|---|---|---|---|
| Range | Time-series, event logs, financial transactions | Date/timestamp range in WHERE using direct >= and < comparisons | Uneven — recent partitions grow as traffic increases, historical partitions remain static | Yes — drop by date range, instant metadata operation | ~500 (monthly over roughly 40 years) |
| List | Categorical data — region, status, department, product line | Exact value match or IN clause on the discrete partition key | Uneven — depends entirely on value distribution in your dataset | Yes — detach and drop by category value or group | 50–100 (one partition per logical category group) |
| Hash | Large tables with no natural range or list dimension; workloads needing even distribution for maintenance | Equality on the hash key — the planner computes hash(value) % modulus at plan time | Even — hash function distributes uniformly across all buckets | No — hash buckets have no semantic meaning; you cannot drop 'old' hash partitions | 32–128 (use powers of 2 to allow future modulus doubling) |
| Composite (Range + Hash) | Time-series tables where traffic growth creates hot partitions on the current range window | Range key prunes at level 1 (month); hash key prunes at level 2 (bucket within month) | Even within each range window — hash distributes writes across sub-partitions | Yes — drop the entire range partition (including all its hash sub-partitions) by date | ~500 total (e.g., 48 months × 4 hash = 192; 48 × 8 = 384) |
| None (Unpartitioned) | Tables under 10M rows; analytics workloads that routinely scan the full dataset | N/A — rely on indexes for point lookups and range scans | N/A | N/A — use time-based DELETE or archive-and-truncate patterns | N/A |
| File | Command / Code | Purpose |
|---|---|---|
| partitioning_strategies.sql | CREATE TABLE orders ( | Partitioning Strategies |
| pruning_diagnosis.sql | EXPLAIN (ANALYZE, BUFFERS) | Partition Pruning |
| partition_maintenance.sql | CREATE TABLE IF NOT EXISTS orders_2026_04 PARTITION OF orders | Partition Maintenance |
| composite_partitioning.sql | CREATE TABLE event_stream ( | Composite Partitioning and Cross-Partition Query Patterns |
| CheckPartitionMetadata.sql | SELECT | Querying Partition Metadata |
| PartitionLimitations.sql | CREATE TABLE SalesOrder_Stage ( | Limitations That Will Bite You |
| PartitionPermissions.sql | GRANT VIEW DEFINITION ON PARTITION SCHEME: MonthlyScheme TO ReportingUser; | Partition Permissions |
| PartitionFilegroupSetup.sql | ALTER DATABASE SalesDB ADD FILEGROUP FG_Month2025_01; | New Filegroups for Partitions |
Key takeaways
Common mistakes to avoid
5 patternsPartitioning on a column not used in WHERE clauses
Wrapping the partition key in a function in WHERE
Using DELETE instead of DROP PARTITION for data retention
Creating too many fine-grained partitions (thousands of daily or hourly partitions)
Not creating future partitions before data arrives
Interview Questions on This Topic
What is partition pruning and how would you verify it's working in a production query?
You have a table with 800 million rows partitioned by month. How would you design a data retention policy that keeps 13 months of data and drops older data without impacting production traffic?
When would you choose hash partitioning over range partitioning, and what are the trade-offs?
Explain the difference between partitioning and sharding. When would you use one over the other?
A query on a partitioned table is scanning all 48 partitions even though the WHERE clause filters on the partition key. Walk me through your debugging process.
How does composite partitioning (range + hash) solve the hot-partition problem, and what are the limits?
Frequently Asked Questions
No — and conflating the two is a common mistake. Partitioning and indexes operate at different levels and solve different problems. Partition pruning reduces the physical segments the planner considers at the table level — it skips entire partitions that cannot contain matching rows. Indexes provide fast lookup within a partition — they let the database find specific rows within a segment without a sequential scan.
A well-partitioned table still needs indexes on columns used in WHERE, JOIN, and ORDER BY clauses. The advantage is that each partition's index covers only that partition's rows, so the index is smaller, faster to traverse, and faster to rebuild or maintain. A composite query that prunes to one monthly partition and then uses an index on customer_id within that partition is orders of magnitude faster than either technique alone. The production standard for large tables is partition pruning plus local indexes — not one or the other.
PostgreSQL does not support converting an existing unpartitioned table to a partitioned table in-place — there is no ALTER TABLE ... PARTITION BY command that restructures an existing heap. The standard migration approach is:
- Create a new partitioned table with identical schema and all required partitions.
- Copy data from the old table in batches using INSERT INTO new_table SELECT ... FROM old_table WHERE ..., chunking by the partition key range to avoid a single massive transaction.
- Use logical replication or application-level dual-write to keep the new table current during the copy phase.
- Once the new table is in sync, swap names using ALTER TABLE RENAME inside a transaction — this requires a brief write lock.
- Update application connection strings or views to reference the new table and drop the old one.
For a true zero-downtime migration, the logical replication approach during the copy phase is essential — it lets you cut over during a low-traffic window with a write lock measured in seconds rather than hours. MySQL supports online DDL for partitioning modifications in some versions and storage engines, but the specific behavior depends on the MySQL version and whether the table uses InnoDB.
Monthly partitions are the right default for most time-series workloads. They balance partition count (48 for 4 years of data) with reasonable per-partition size for most traffic levels, they align naturally with billing cycles and reporting periods, and they make the DROP PARTITION retention pattern intuitive to reason about.
Switch to quarterly partitions when: your data volume per month is very low (under 1–2 million rows) and you want to reduce the total partition count; your retention policy is expressed in quarters rather than months; or your queries typically span 3+ months and rarely filter to a single month, making monthly pruning less effective.
Switch to weekly or daily partitions only when your monthly row count exceeds 100–200 million rows and you need finer-grained retention — but watch the total partition count ceiling. Daily partitions over 4 years gives you 1460 leaf partitions, well above the 500-partition threshold where planner overhead becomes a problem. In that scenario, use monthly range partitions with hash sub-partitions rather than daily range partitions.
Without a DEFAULT partition, the INSERT fails immediately with an error along the lines of 'no partition of relation found for row'. The entire transaction rolls back. The row is not inserted and does not land anywhere — it is simply rejected.
This is a silent operational failure mode: if your application does not retry on this specific error class, the data is lost. The failure typically happens at the start of a new month when the application starts generating data for a period that the partition creation job hasn't covered yet.
The defense has two layers: first, always create a DEFAULT partition that absorbs any value that doesn't match a defined range or list. Rows landing in DEFAULT are not lost and can be moved to the correct partition once it's created. Second, monitor the DEFAULT partition's row count weekly — accumulation there is an early warning that your partition creation automation has failed or fallen behind. An alert on DEFAULT partition row count gives you time to fix the creation job before the backlog grows large.
Foreign key constraints on partitioned tables have significant limitations that vary by database. In PostgreSQL, a foreign key from a partitioned table to another table requires the referenced table to be either unpartitioned or partitioned with matching boundaries and the same partition key. References from an unpartitioned table to a partitioned table are not supported at all in most PostgreSQL versions prior to 12, and support in later versions has caveats. Check your specific database version before relying on foreign key constraints across partitioned tables.
For joins, the key concept is partition-wise joins. When two tables are co-partitioned on the same key with identical boundaries — for example, both orders and order_items are partitioned by created_at with matching monthly boundaries — PostgreSQL can perform the join partition-by-partition in parallel, which is significantly faster than joining the full datasets. Enable partition_wise_join in PostgreSQL's planner settings to allow this optimization.
When tables are not co-partitioned and you're joining on a non-partition-key column, the database must probe every partition on the inner table for each outer row. In that scenario, ensure the join column is indexed on both tables. The cross-partition join overhead is unavoidable given the data model — you're paying a structural cost for the way the data is organized.
20+ years shipping high-throughput database systems. Notes here come from systems that actually shipped.
That's SQL Advanced. Mark it forged?
9 min read · try the examples if you haven't