Understanding Database Partitioning: From Concepts and Performance to When to Avoid It
A table with tens of millions of rows has typical problems: queries that used to be instant start feeling heavy, index maintenance takes a long time, and operations like DELETE of old data can lock the table for painful minutes. Partitioning often appears as the first answer that comes to mind — split the big table into smaller pieces, problem solved. But the reality isn’t that simple. Partitioning designed with the wrong partition key, or applied to tables that don’t actually need it yet, can add operational complexity without meaningful performance benefits. This article discusses partitioning from its concepts, how databases actually process it behind the scenes, how big its performance impact realistically is, up to when you shouldn’t bother applying it at all.
What Is Partitioning
Partitioning is a technique for dividing one logical table into several partitions — separate physical pieces that are internally stored as their own storage objects, but from the application’s point of view still look like a single table. You still INSERT, SELECT, UPDATE against the parent table name as usual; the database determines which partition is relevant for storing or reading a particular row.
Imagine an orders table with one hundred million rows. Without partitioning, all those rows live in one single physical structure — one set of data files, one set of indexes. With date-based partitioning for example, the data is split into per-month partitions: orders_2026_01, orders_2026_02, and so on. Each partition has its own data files and indexes, even though logically they’re all still part of the orders table.
-- Parent table partitioned by date range
CREATE TABLE orders (
id BIGSERIAL,
customer_id BIGINT NOT NULL,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
-- Individual partition for each month
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');
flowchart TD
A[Logical Table: orders] --> B[Partition: orders_2026_01]
A --> C[Partition: orders_2026_02]
A --> D[Partition: orders_2026_03]
A --> E[... and so on]The column used to determine which partition holds a particular row is called the partition key. Choosing the partition key is the most crucial decision in partitioning design — a wrong decision here will be felt in almost every subsequent section of this article, especially in the performance part.
It’s important to note that partitioning differs from simply dividing data manually into several separate tables without a formal relationship. The real hallmark of partitioning is the parent table abstraction — the application doesn’t need to know which partition to target, the database handles that routing automatically based on the partition key definition.
How Partitioning Works Behind the Scenes
The main benefit of partitioning comes from a mechanism called partition pruning (sometimes called partition elimination). When you run a query with a WHERE condition touching the partition key, the query planner is smart enough to recognize which partitions might contain relevant data, then completely ignores the others — without reading them at all.
sequenceDiagram
participant App as Application
participant Planner as Query Planner
participant P1 as January Partition
participant P2 as February Partition
participant P3 as March Partition
App->>Planner: SELECT * FROM orders WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01'
Planner->>Planner: Analyze the WHERE condition against partition bounds
Planner->>Planner: Determine only the February partition is relevant
Planner-->>P1: (ignored, not scanned)
Planner->>P2: Scan only this partition
Planner-->>P3: (ignored, not scanned)
P2-->>App: Query resultsThis is why partitioning can give significant performance boosts for the right queries: instead of scanning one hundred million rows in one big table, the database only needs to scan a few million rows in one relevant partition. The effect is similar to an index, but works at the physical table structure level, not the individual row level.
However, this also explains the flip side that’s often missed: if a query doesn’t include a condition on the partition key, the planner has no way to prune. As a result the database must scan all partitions one by one — which in some cases is actually slightly slower than one big unpartitioned table, because there’s additional overhead in opening and coordinating many partitions at once.
flowchart TD
A[Query WITHOUT a partition key filter] --> B{Planner can't prune}
B --> C[Scan January partition]
B --> D[Scan February partition]
B --> E[Scan March partition]
B --> F[... scan all partitions]Partition Pruning: Static vs Dynamic
There are two types of partition pruning to understand. Static pruning happens when the query is parsed, before execution begins — suitable for conditions with already-determined literal values, like WHERE created_at = '2026-02-15'. Dynamic pruning happens during execution, needed for cases like WHERE created_at = (SELECT date FROM report_parameter) where the partition key value is only known after the subquery executes. Not all databases support dynamic pruning well — modern PostgreSQL versions support it, but older versions or other databases may be forced to scan all partitions even though logically it should be prunable.
Partitioning Types
There are several strategies for determining how rows are distributed to partitions, each suited to different data and query patterns.
Range Partitioning
Rows are distributed based on value ranges — most commonly used for date columns or sequential numbers like IDs.
CREATE TABLE activity_log (
id BIGSERIAL,
user_id BIGINT,
activity TEXT,
created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE activity_log_q1_2026 PARTITION OF activity_log
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
Range partitioning is best suited for time-series data like logs, transactions, or metrics — common access patterns are queries against a specific time range, and old data is often archived or deleted periodically.
List Partitioning
Rows are distributed based on a list of specific discrete values — suitable for categorical columns like region, status, or tenant.
CREATE TABLE orders (
id BIGSERIAL,
region TEXT NOT NULL,
total NUMERIC(12,2)
) PARTITION BY LIST (region);
CREATE TABLE orders_java PARTITION OF orders
FOR VALUES IN ('jakarta', 'bandung', 'surabaya');
CREATE TABLE orders_outside_java PARTITION OF orders
FOR VALUES IN ('medan', 'makassar', 'denpasar');
List partitioning fits when application queries naturally filter often by that category, for example per-region operational reports.
Hash Partitioning
Rows are distributed based on the hash result of the partition key, spread evenly across a number of partitions without caring about the meaning of the values.
CREATE TABLE user_sessions (
id BIGSERIAL,
user_id BIGINT NOT NULL,
data JSONB
) 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);
Hash partitioning is useful when you need even data distribution for write scaling purposes and there’s no natural value range to use for range or list — for example load distribution based on a user_id whose values are random. The trade-off is that partition pruning for hash is far more limited than range: you can’t prune based on value ranges, only exact value equality.
Composite Partitioning
Combines two strategies in layers — for example range at the first level, then hash or list at the second level (also called sub-partitioning).
-- Level 1: range by date
CREATE TABLE transactions (
id BIGSERIAL,
region TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE transactions_2026_q1 PARTITION OF transactions
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01')
PARTITION BY LIST (region);
-- Level 2: list by region, inside the Q1 partition
CREATE TABLE transactions_2026_q1_java PARTITION OF transactions_2026_q1
FOR VALUES IN ('jakarta', 'bandung');
Composite partitioning fits tables with very large volumes that have two dominant access patterns simultaneously — for example queries that frequently filter by date and region at the same time.
| Type | Good For | Partition Pruning | Data Distribution |
|---|---|---|---|
| Range | Time-series data, sequential IDs | Very effective for range queries | Can be uneven (busy vs quiet months) |
| List | Discrete categories (region, status, tenant) | Effective for per-category queries | Depends on category distribution |
| Hash | Write scaling, no natural range | Limited, equality only | Even by design |
| Composite | Very large volumes, two dominant access patterns | Combination of both levels | Depends on strategy combination |
Partitioning vs Sharding — A Difference Often Confused
The terms partitioning and sharding are often used interchangeably incorrectly, even though they operate at different levels.
Partitioning happens inside the same database instance. All partitions remain on the same database server (or cluster), managed by one database engine, and cross-partition queries can still be done in a single transaction without cross-network coordination issues.
Sharding divides data across several separate database instances — possibly on different servers, even different datacenters. Each shard is essentially an independent database with the same schema, and the application (or middleware layer) is responsible for determining which shard to target for specific data.
flowchart TD
subgraph Partitioning [Partitioning - One Database Instance]
A[Logical Table] --> B[Partition 1]
A --> C[Partition 2]
A --> D[Partition 3]
end
subgraph Sharding [Sharding - Many Separate Instances]
E[Application/Router] --> F[Database Shard 1]
E --> G[Database Shard 2]
E --> H[Database Shard 3]
end| Aspect | Partitioning | Sharding |
|---|---|---|
| Data location | One database instance | Many separate instances/servers |
| Cross-part transactions | Natively supported in one instance | Needs distributed transactions (complex) |
| Scalability | Limited by one server’s capacity | Can scale horizontally almost without limit |
| Operational complexity | Relatively low, managed by one engine | High, needs a routing layer & coordination |
| Main purpose | Speeding up queries & maintenance on large tables | Overcoming one server’s capacity limits |
The two aren’t mutually exclusive options — many large-scale architectures use both at once: data is sharded across several servers by tenant for example, then within each shard, large tables are further partitioned by date for easier querying and retention.
Some people call partitioning “neatly organized vertical scaling”, while sharding is “horizontal scaling”. This analogy helps but isn’t fully precise — partitioning doesn’t add total server capacity, it only organizes data already on the same server to be accessed and managed more efficiently.
Creating and Managing Partitions
Besides creating initial partitions, long-term partition management — adding new partitions and dropping old ones — is just as important as the initial design.
-- Adding a new partition for the coming month
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- Detaching an old partition without deleting its data (can be archived separately)
ALTER TABLE orders DETACH PARTITION orders_2023_01;
-- Attaching a regular table as a new partition
ALTER TABLE orders ATTACH PARTITION orders_2026_04
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
-- After detaching, the old partition can be dropped directly for retention policy
DROP TABLE orders_2023_01;
The DETACH PARTITION then DROP TABLE operation is far more efficient than DELETE FROM orders WHERE created_at < '2023-02-01' on a large unpartitioned table. A regular DELETE must remove rows one by one, producing many dead tuples that need to be cleaned by the vacuum process, and can lock the table for a long time. DETACH then DROP on a partition only needs to change metadata and delete files directly — the operation is nearly instant no matter how large that partition is.
Automating Partition Creation
Because new partitions for a time range (for example next month) must exist before data starts coming in, teams usually automate this process via a scheduled job.
-- Example PostgreSQL function to automatically create next month's partition
CREATE OR REPLACE FUNCTION create_next_month_partition()
RETURNS void AS $$
DECLARE
start_date DATE := DATE_TRUNC('month', NOW() + INTERVAL '1 month');
end_date DATE := start_date + INTERVAL '1 month';
partition_name TEXT := 'orders_' || TO_CHAR(start_date, 'YYYY_MM');
BEGIN
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END;
$$ LANGUAGE plpgsql;
Some databases like recent PostgreSQL versions are starting to natively support automatic partition creation for time ranges, but many teams still prefer explicit control via scheduled jobs so the process can be clearly monitored and audited.
If you forget to create a partition for an upcoming time range before data starts coming in, the INSERT will fail with a “no partition found” error (depending on whether you have a default partition or not). Always make sure the partition creation automation runs with enough lead time — don’t wait until the current month has started to create its partition.Performance: Benefits and Pitfalls of Partitioning
This is the core of the entire partitioning discussion, because the decision to apply partitioning is essentially a performance and operational decision — not about a “nice-to-have” new feature.
Real Benefits for the Right Queries
For queries that include a condition on the partition key, the benefit can be dramatic. Imagine an orders table with one hundred million rows partitioned monthly into roughly three million rows per partition. A query looking for transactions in a specific month only needs to scan one partition containing three million rows, not one hundred million — a potential performance improvement of multiple times, depending on how selective the partition pruning condition is.
Another often overlooked benefit is index maintenance. An index on a one-hundred-million-row table takes much longer to rebuild (REINDEX) than an index on a three-million-row partition. Because each partition has its own indexes, maintenance operations can be done per partition, so the downtime or load caused is much smaller and can be scheduled granularly.
Pitfalls Often Not Realized
Queries without a partition key filter get slower, not faster. This is the most important point to understand. If your application’s access patterns include many queries that don’t touch the partition key at all — for example SELECT * FROM orders WHERE customer_id = 42 on a table partitioned by created_at — the database is forced to scan all partitions one by one. The overhead of opening, coordinating, and merging results from many partitions can make the query slower than if the table weren’t partitioned at all and simply relied on a regular index on the customer_id column.
-- ✗ Query doesn't touch the partition key (created_at), must scan all partitions
SELECT * FROM orders WHERE customer_id = 42;
-- ✓ Combine with a partition key condition when possible
SELECT * FROM orders
WHERE customer_id = 42
AND created_at >= '2026-01-01' AND created_at < '2026-04-01';
Over-partitioning adds planning overhead. Every partition adds work for the query planner to determine which ones are relevant, even before pruning happens. If you create thousands of tiny partitions (for example hourly partitions for a table that isn’t actually that big), the planning overhead itself can cost more than the pruning benefit gained. Some databases have a practical recommended partition count limit — beyond it, overall system performance (including query planning for other tables) can be affected.
Local indexes vs global indexes. Most databases require indexes on partitioned tables to be created as per-partition local indexes, not a single global index covering the whole table. This means if you need uniqueness (UNIQUE) across the entire table rather than just per partition, you must ensure the partition key is included in that constraint’s definition — a limitation that often surprises teams designing a partitioned schema for the first time.
-- ✗ A UNIQUE constraint on a column that isn't part of the partition key will be rejected
CREATE TABLE orders (
id BIGSERIAL,
invoice_code TEXT UNIQUE, -- error: must include the partition key
created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
-- ✓ Include the partition key in the UNIQUE definition
CREATE TABLE orders (
id BIGSERIAL,
invoice_code TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (invoice_code, created_at)
) PARTITION BY RANGE (created_at);
Joins between large partitioned tables need extra attention. If both JOINed tables are partitioned with aligned schemas (called a partition-wise join), some databases can perform the join per matching partition pair, far more efficient than a brute-force join over all the data. But this requires explicit database support and proper configuration — it doesn’t happen automatically just because both tables are partitioned.
Measuring Performance Impact for Real
Don’t assume partitioning will definitely help without testing it. Use EXPLAIN ANALYZE to verify that pruning is actually happening as expected.
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
If the output shows only one partition scanned (orders_2026_02) and other partitions appear as “pruned” or don’t appear at all in the plan, that’s a sign partition pruning works as expected. If all partitions still appear in the execution plan even though your query already includes the partition key condition, there’s a possibility that the data type, implicit cast, or WHERE condition shape is preventing the planner from pruning — a problem that often occurs when the condition is written as a function like WHERE DATE(created_at) = '2026-02-15' instead of a direct comparison.
When to Use Partitioning
USE partitioning if:
✓ the table is already tens of millions of rows and still growing
✓ there's a natural partition key consistently used in query patterns (date, region, tenant)
✓ you need periodic retention/archiving of old data (dropping partitions is far faster than DELETE)
✓ index maintenance on the large table is becoming an operational problem
✓ most application queries indeed filter by the candidate partition key
When to Avoid It
Because this article’s title explicitly highlights this question, it’s important to discuss it more deeply than a mere list of ✗ points.
The table is still relatively small. If your table only has a few hundred thousand or even a few million rows, a regular index on the right column is most likely fast enough without any partitioning at all. Partitioning adds schema complexity, query constraint complexity (like the UNIQUE case above), and operational complexity (needing automation for new partition creation) — this cost only pays off when data size truly becomes a real problem, not a hypothetical future one.
Query patterns don’t consistently touch the candidate partition key. This is the most common reason partitioning projects fail to deliver expected benefits. Before deciding on a partition key, audit the most frequent and heaviest queries run against the table. If those queries filter by diverse columns and there’s no single dominant column consistently used, partitioning by just one column won’t provide meaningful pruning benefit for most queries — and actually risks slowing down queries that don’t touch the partition key, as discussed in the performance section.
The team isn’t ready for the additional operational burden. Partitioning isn’t “set once and forget”. There are ongoing requirements: ensuring new partitions are always available before data arrives, monitoring partition count and size to avoid over-partitioning, and understanding the constraint limitations that arise from partitioning. If the team doesn’t yet have the capacity or processes to handle this operational burden sustainably, partitioning can turn from a solution into a new source of incidents — for example INSERTs failing because the current month’s partition wasn’t created.
The real performance problem is elsewhere. Sometimes a large table feels slow not because of its size, but because of a missing proper index, inefficient queries (like SELECT * on a table with many large columns), or server resources already near their limits. Partitioning doesn’t solve these problems. Before deciding on partitioning, make sure you’ve ruled out more fundamental and cheaper-to-fix performance causes.
AVOID partitioning if:
✗ the table is still small to medium-sized with no real performance problem
✗ there's no partition key consistently used across most queries
✗ the team isn't ready to handle the operational burden of creating & monitoring partitions
✗ the real performance problem comes from missing indexes or inefficient queries
Common Anti-Patterns
-- ✗ Partition key chosen from a column rarely used in WHERE
CREATE TABLE orders (
id BIGSERIAL,
referral_code TEXT, -- rarely used for query filtering
created_at TIMESTAMP NOT NULL
) PARTITION BY LIST (referral_code);
-- ✓ Choose the partition key based on the column most often used for filtering
CREATE TABLE orders (
id BIGSERIAL,
referral_code TEXT,
created_at TIMESTAMP NOT NULL
) PARTITION BY RANGE (created_at);
-- ✗ Over-partitioning: hourly partitions for a table whose data isn't that big
CREATE TABLE small_log_2026_01_01_00 PARTITION OF small_log
FOR VALUES FROM ('2026-01-01 00:00:00') TO ('2026-01-01 01:00:00');
-- Produces thousands of tiny partitions, planning overhead costs more than the benefit
-- ✓ Partition granularity matched to the actual data volume
CREATE TABLE small_log_2026_01 PARTITION OF small_log
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- ✗ A WHERE condition in function form blocks partition pruning
SELECT * FROM orders WHERE DATE(created_at) = '2026-02-15';
-- ✓ Use a direct condition shape so the planner can prune
SELECT * FROM orders
WHERE created_at >= '2026-02-15' AND created_at < '2026-02-16';
Partitioning Best Practices
1. Audit Query Patterns Before Choosing the Partition Key
Don’t guess. Analyze the query log or pg_stat_statements (in PostgreSQL) to see which columns appear most often in WHERE conditions on the heaviest queries, then make that the primary partition key candidate.
2. Match Granularity to Real Data Volume
Partitions should ideally be large enough to reduce planning overhead, but small enough to provide meaningful pruning benefits. As a rough guideline, target a partition size that keeps each partition comfortably manageable (for example its index can be rebuilt in a reasonable time), rather than just following a time pattern that “feels neat” like daily or hourly without considering the actual volume.
3. Automate Partition Creation and Cleanup
Don’t rely on manual processes for creating new partitions or removing old ones. Use a scheduled job with enough lead time, and make sure there’s alerting if this automation fails to run.
4. Design Constraints with the Partition Key from the Start
Because UNIQUE and PRIMARY KEY on partitioned tables must include the partition key, design these constraints at the design stage, not after the table is already running in production and hard to change.
5. Verify Pruning with EXPLAIN Regularly
Don’t just verify once at the initial implementation. Over time, application query patterns can change, ORMs can generate unexpected query shapes, or data types can change — all of these can silently block partition pruning without any visible error. Make EXPLAIN ANALYZE checks part of periodic reviews, not just a one-time verification step.
6. Monitor Partition Count and Size Distribution
Too many partitions or too uneven sizes (for example one partition much larger than others due to seasonal traffic spikes) can be a sign the partition key needs revision or the partitioning strategy should be adjusted to composite.
A short checklist for quick reference:
BEFORE APPLYING PARTITIONING:
□ Query patterns already audited, partition key chosen based on real data
□ Table size is indeed a real performance/operational problem
□ Team is ready for the operational burden of creating & monitoring partitions
□ UNIQUE/PRIMARY KEY constraints already designed to include the partition key
AFTER PARTITIONING IS RUNNING:
□ New partition creation automation runs with enough lead time
□ Alerting is active if the automation fails
□ EXPLAIN ANALYZE is checked regularly to ensure pruning still happens
□ Size distribution across partitions is monitored periodically
RETENTION & ARCHIVING:
□ A DETACH + DROP strategy for old partitions is defined
□ Old data archiving (if needed) runs before DROP
Summary
- Partitioning divides one logical table into several physical partitions, managed within the same database instance, unlike sharding which divides data across many separate instances.
- The main benefit comes from partition pruning — queries with a condition on the partition key only need to scan the relevant partition, not the whole table.
- Queries that don’t touch the partition key can actually be slower because they must scan all partitions without pruning benefits.
- Available partitioning types: range (time-series), list (discrete categories), hash (even write scaling), and composite (layered combination).
UNIQUE/PRIMARY KEYon partitioned tables must include the partition key — a limitation that needs to be designed from the start.DETACH PARTITIONthenDROP TABLEis far more efficient for old data retention than massDELETEon a large table.- Avoid partitioning if the table is still small, queries don’t consistently use the partition key, or the team isn’t ready to handle the operational burden of creating and monitoring partitions.
- Always verify partition pruning with
EXPLAIN ANALYZEregularly, because query pattern changes can silently block pruning without visible errors.