Partitioning vs. Sharding

Two ways to split data

Both techniques split one large dataset into smaller pieces, for different reasons:

Partitioning Sharding
Where the pieces live Inside one database server On several independent servers
What it fixes Large tables: index size, VACUUM time, deleting old data, query pruning Limits of one machine: write throughput, storage, memory, CPU
Who routes queries The database's planner, transparently A routing layer: the application, a proxy (Vitess, Citus coordinator), or the database itself (distributed SQL)
Transactions and joins Normal Cross-shard ones are expensive or unavailable
graph TD subgraph part["Partitioning: one server"] s1["Parent table events"] --> p1["events_2024_01"] s1 --> p2["events_2024_02"] s1 --> p3["events_2024_..."] end subgraph shard["Sharding: several servers"] router["Router / application"] -->|"customer 1-1M"| db1["Server A"] router -->|"customer 1M-2M"| db2["Server B"] router -->|"customer 2M-3M"| db3["Server C"] end

The measurements below were made on PostgreSQL 16.2 with a 6-million-row events table, once as a plain table and once partitioned by month.

Partitioning within one database

Partitioning schemes

Scheme Rows go to the partition... Good for
Range Whose range contains the key: created_at in [2024-03-01, 2024-04-01) Time series; dropping old data; queries on recent data
List Whose value list contains the key: region IN ('VN', 'TH') A few known categories
Hash hash(key) mod N Spreading load evenly when there is no natural range
CREATE TABLE events (id bigint, created_at timestamptz NOT NULL, payload text)
  PARTITION BY RANGE (created_at);

CREATE TABLE events_2024_03 PARTITION OF events
  FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- ... one per month; a DEFAULT partition can catch everything else

The parent table holds no rows; each partition is a real table with its own heap, indexes, statistics, and VACUUM. An index created on the parent is created on every partition.

Partition pruning

The planner excludes partitions that cannot contain matching rows. Measured, querying 10 days in March:

SELECT count(*) FROM events_p
WHERE created_at >= '2024-03-10' AND created_at < '2024-03-20';

Finalize Aggregate
  -> Gather
       -> Partial Aggregate
            -> Parallel Index Only Scan using events_p_2024_03_created_at_idx
               on events_p_2024_03                      ← only March is touched
Execution Time: 9.141 ms

Eleven of twelve partitions never appear in the plan. Pruning happens at planning time for constants and at execution time for parameters and join values. It requires the query to constrain the partition key: a query on id alone produced a plan with 12 scan nodes, one per partition — more work than on an unpartitioned table with an index on id, since each partition's index is searched separately.

The biggest practical win: dropping old data

Measured, removing January (≈ 510,000 rows):

Method Time Side effects
DELETE FROM events_np WHERE created_at < '2024-02-01' (plain table) 433 ms 510,000 dead tuples to vacuum, WAL for every row, index bloat
ALTER TABLE events_p DETACH PARTITION events_p_2024_01; DROP TABLE events_p_2024_01; 4.2 ms None: files are unlinked

~100x faster here, and the gap grows with table size: deleting a month from a multi-terabyte table can run for hours and bloat everything, while dropping a partition stays a metadata operation. For data with a retention period (logs, events, metrics), this alone justifies partitioning by time. DETACH PARTITION ... CONCURRENTLY (PostgreSQL 14+) avoids blocking queries on the parent.

Other benefits and costs

Benefit Cost
Smaller per-partition indexes: hot recent data's index fits in memory Queries without the partition key scan every partition
VACUUM and ANALYZE per partition; old partitions become static Unique constraints and primary keys must include the partition key (uniqueness is enforced per partition)
Old partitions can move to cheaper storage (tablespaces) or be archived Too many partitions (thousands) inflate planning time and memory per backend
Bulk load into a separate table, then ATTACH Rows changing their partition key move between partitions (a delete + insert)

Rule of thumb: partition when a table is large (hundreds of GB or billions of rows) and most queries or maintenance operations align with one key — typically time. Keep partition counts in the tens or low hundreds.

Sharding across servers

Why shard

A single primary has hard ceilings: write throughput (one WAL, one machine's fsync and CPU), storage, and memory for the working set. Replication scales reads but every write still goes to one node. Sharding gives each server responsibility for a subset of the rows, so writes, storage, and cache scale with the number of servers.

Choosing the shard key

The shard key decides which server owns a row. It determines almost everything:

Property of a good shard key Why
Matches the dominant access pattern Queries that include the key go to one shard; queries without it go to all shards (scatter-gather)
High cardinality, even distribution Avoids one shard holding most of the data
Spreads write load A monotonically increasing key (timestamp, auto-increment id) with range sharding sends all new writes to the last shard — a hot spot
Groups data that is transacted or joined together Single-shard transactions and joins stay local

For a multi-tenant SaaS, tenant_id is the classic key: almost every query is scoped to a tenant, and each tenant's rows (users, orders, invoices) live together. The risk is one huge tenant outgrowing a shard.

Mapping keys to shards

Strategy How Trade-off
Range Shard owns a key range (A–F, G–M) Efficient range scans; hot spots with sequential keys; ranges can be split when they grow (Bigtable, HBase, CockroachDB, TiKV split ranges automatically at ~64–512 MB)
Hash mod N shard = hash(key) % N Even distribution; but changing N moves almost every key
Consistent hashing Keys and shards placed on a hash ring; a key belongs to the next shard clockwise; usually with virtual nodes Adding a shard moves only ~1/N of keys (Cassandra, DynamoDB) — see Distributed Cache
Directory / lookup table A mapping service records which shard holds each key (or tenant) Flexible: move individual tenants; the directory must be highly available and cached

Worked example — why hash % N hurts resharding. With 4 shards, key k lives on hash(k) % 4. Going to 5 shards, a key stays put only if hash(k) % 4 == hash(k) % 5, which holds for about 1 in 5 keys — ~80% of all data moves. With consistent hashing, adding the fifth shard moves only ~20% (the fraction the new shard takes over).

What sharding costs

Problem Why Typical solutions
Cross-shard queries Query lacks the shard key: must ask every shard and merge Choose the key for the dominant pattern; secondary indexes as separate (global) tables; a separate analytics store
Cross-shard joins Rows to join live on different servers Co-locate by sharding related tables on the same key; replicate small reference tables to every shard; see Joins
Cross-shard transactions No single WAL makes them atomic Two-phase commit, Percolator-style transactions, or sagas — see Distributed Transactions
Global uniqueness and IDs No single sequence UUIDs, Snowflake-style IDs (time + machine + counter)
Resharding Data must move while serving traffic Pre-split into many logical shards mapped onto fewer physical servers; online migration tooling
Operations N databases to back up, upgrade, monitor Automation, managed services

Who does the routing

Layer Examples
Application code Many early-stage sharded systems; simplest, most rigid
Proxy / middleware Vitess (MySQL), Citus (PostgreSQL extension with a coordinator), ProxySQL
Database-native Distributed SQL: CockroachDB, TiDB, YugabyteDB, Spanner — ranges split, move, and replicate automatically via Raft; transactions across shards are supported at the cost of extra round trips

Order of operations

Before sharding, exhaust the cheaper options: indexes and query fixes, vertical scaling (a single modern server handles tens of thousands of transactions per second and many terabytes), read replicas for read load, caching, partitioning for large tables, and moving analytics to a separate system. Sharding is a one-way door: it complicates every query, transaction, and operation for the rest of the system's life. See Vertical vs. Horizontal Scaling.

Common pitfall

Choosing a shard key without checking the access patterns first. Sharding by user_id when the dominant query is "all orders in a date range across all users" turns that query into a scatter-gather over every shard — the same effect measured above at partition level, where a query without the partition key ran 12 scans instead of one, but now over the network and with the slowest shard deciding the latency. The key is effectively permanent once traffic depends on it: changing it means migrating live data between servers. Write down the top queries and transactions, check that each includes the candidate key, and only then commit to it.