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