Understanding Database Partitioning: From Concepts and Performance to When to Avoid It
19 min read

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 results

This 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.

TypeGood ForPartition PruningData Distribution
RangeTime-series data, sequential IDsVery effective for range queriesCan be uneven (busy vs quiet months)
ListDiscrete categories (region, status, tenant)Effective for per-category queriesDepends on category distribution
HashWrite scaling, no natural rangeLimited, equality onlyEven by design
CompositeVery large volumes, two dominant access patternsCombination of both levelsDepends 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
AspectPartitioningSharding
Data locationOne database instanceMany separate instances/servers
Cross-part transactionsNatively supported in one instanceNeeds distributed transactions (complex)
ScalabilityLimited by one server’s capacityCan scale horizontally almost without limit
Operational complexityRelatively low, managed by one engineHigh, needs a routing layer & coordination
Main purposeSpeeding up queries & maintenance on large tablesOvercoming 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 KEY on partitioned tables must include the partition key — a limitation that needs to be designed from the start.
  • DETACH PARTITION then DROP TABLE is far more efficient for old data retention than mass DELETE on 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 ANALYZE regularly, because query pattern changes can silently block pruning without visible errors.

Portfolio