Skip to content
Back to course
Lesson 7 of 21 · Part 2

Database Partitioning and Sharding: When One Node Is Not Enough

Range, hash, and consistent-hash partitioning, hot spots, resharding, and how Notion, Figma, and Discord partition data in practice.

advanced17 min read4 diagramsUpdated 11 May 2026✓ Completed
On this page

TL;DR: Sharding splits a single logical database across many physical nodes so you can scale past one machine’s CPU, IOPS, and connection limits. Pick a partition key with high cardinality and even distribution. Use hash-based routing unless you need range scans. Pre-shard into many logical partitions (Notion chose 480 for its divisibility1) so you can grow physical capacity without re-bucketing data. Shard only when vertical scaling is truly exhausted: Figma called horizontal sharding “an order of magnitude more complex than our previous scaling efforts”2 and took nine months to ship the first table.

Learning Objectives

After this module, you will be able to:

  • Choose between range, hash, and consistent-hash partitioning for a workload
  • Pick a partition key that avoids hot spots
  • Design a shard-routing layer (client-side, proxy, or vtgate-style)
  • Plan a resharding operation with minimal downtime
  • Recognize when partitioning is premature and vertical scaling still wins

Intuition

Imagine a city with one giant central library. Every resident drives downtown to borrow books, return books, and ask questions. At 50,000 residents the parking lot is full, the librarians are overwhelmed, and the building physically cannot hold more shelves.

The city council opens branch libraries. Each branch holds books for a specific set of zip codes. A resident walks into their local branch and finds everything they need without crossing town. The central catalog still knows which branch has which book, but day-to-day traffic is local.

This is sharding. The “zip code” is your partition key. The “central catalog” is your routing layer. The hard part is choosing zip-code boundaries that spread residents evenly, handling the person who visits multiple branches (cross-shard queries), and opening a new branch without closing the others for renovation (online resharding).

Now imagine one zip code contains a stadium. On game day, that branch is crushed while others sit idle. That is a hot partition, and it is the single most common failure mode in sharded systems.

Theory

Why shard

A single database node has hard ceilings: CPU cores, disk IOPS, RAM for the buffer pool, and connection slots. Postgres creates a dedicated OS process per connection; between shared memory mappings, work_mem allocations, and per-process overhead, raw Postgres practically tops out in the low hundreds of connections before contention and memory pressure dominate.3 Notion hit the largest available RDS instance, faced VACUUM stalls threatening transaction-ID wraparound, and could no longer run safe migrations under load.1 Figma’s tables reached several terabytes and billions of rows, with vacuums causing reliability impact and write rates projected to exceed the IOPS ceiling.2

Sharding removes these ceilings by distributing the keyspace across independent nodes. Each shard handles a fraction of the traffic, has its own buffer pool, its own VACUUM schedule, and its own connection budget. But it is a one-way door: you lose cross-shard joins, cross-shard transactions, and globally unique indexes. Make sure vertical scaling and read replicas are truly exhausted first.

Partitioning vs sharding

The terms overlap but carry different connotations in practice. Partitioning usually means splitting rows across tables or child tables inside one database instance (Postgres PARTITION BY RANGE, MySQL PARTITION BY HASH). Sharding means splitting rows across independent database instances that share nothing at the storage layer.4 The two coexist: Notion’s 480 logical shards are Postgres schemas inside 32 (later 96) physical RDS instances, which is partitioning inside sharding.15

Sharding strategies

Diagram
Three partitioning families: range preserves locality, hash ensures even distribution, consistent hashing minimizes data movement on membership changes.
flowchart TD
    subgraph Range["Range partitioning"]
        R1["Shard 1: A-F"]
        R2["Shard 2: G-M"]
        R3["Shard 3: N-Z"]
    end
    subgraph Hash["Hash partitioning"]
        H1["Shard 1: hash % 3 = 0"]
        H2["Shard 2: hash % 3 = 1"]
        H3["Shard 3: hash % 3 = 2"]
    end
    subgraph Ring["Consistent hashing"]
        C1["Node A (v1, v2, v3)"]
        C2["Node B (v1, v2, v3)"]
        C3["Node C (v1, v2, v3)"]
    end

Range partitioning. Rows sorted by key are split at boundary values. Good for range scans; pathological for append-heavy workloads where the latest shard becomes a hot partition. Citus explicitly advises: “Do not choose a timestamp as the distribution column.”6

Hash partitioning. shard = hash(key) % N. Even distribution by construction; destroys range-scan locality on the hashed column. Figma uses this approach: “As long as we picked a sufficiently random hash function, we would ensure a uniform distribution of data.”2 The downside: hash % N remaps nearly every key when N changes.

Consistent hashing. Karger et al. (1997) map both keys and nodes to a unit circle. Each key is assigned to the first node clockwise. Adding a new node steals only ~1/N of keys instead of nearly all.7 Virtual nodes (multiple points per physical node) smooth out variance. The bounded-loads extension (Mirrokni et al., 2016) caps each node at (1+e) times the average load, moving at most O(1/e^2) keys per event.8 Vimeo reported an ~8x reduction in shared cache bandwidth after deploying it in HAProxy.910

Directory-based. An external lookup table maps keys to shards. Flexible per-tenant placement; the directory is a single point of failure and adds a network hop. Notion uses application-level directory routing from workspace_id to (database, schema).1

Geographic. Data partitioned by user region for GDPR data residency or latency. Instagram scaled to multiple data centers this way in 2015-2016.11

Choosing a partition key

The partition key is the single most important design decision in a sharded system. Every production source converges on the same criteria:

  • High cardinality. Enough distinct values to spread evenly across shards.
  • Even distribution. No single value dominates traffic.
  • Stability. The key does not change after creation (UUIDs, not usernames).
  • Present in hot-path queries. If the key is not in the WHERE clause, every query becomes a scatter-gather.

Notion chose workspace_id: UUID-typed, high-cardinality, and naturally scopes the vast majority of reads because users almost always work inside one workspace.1 Discord chose (channel_id, bucket) where bucket is a static time window so partitions do not grow unboundedly.12 Figma chose multiple keys (UserID, FileID, OrgID) because no single column existed in their model, then colocated related tables under each key into a “colo.”2

The industry pattern for multi-tenant systems is a compound key: (tenant_id, local_id) where tenant_id partitions and local_id is unique inside the tenant. This gives a single unique index per shard that is globally unique without cross-shard coordination.6

Resharding

The dominant pattern is replication-based online resharding: set up new shards, stream data from old to new via logical replication, verify with dark reads, then flip the router.

Notion’s 2023 re-shard is the cleanest reference.5 They moved from 32 to 96 physical databases:

  1. Provision 96 new RDS instances
  2. Start Postgres logical replication (3 publications per source, 5 schemas each)
  3. Copy without indexes (reducing sync from 3 days to 12 hours)
  4. Rebuild indexes on new shards
  5. Verify with dark reads (1-second setTimeout to wait for replication)
  6. Pause traffic at PgBouncer, verify replication caught up, update routing, resume

User-visible impact: “about a second of a ‘saving’ loading spinner” per shard.5 Reverse replication was armed the entire time as a rollback escape hatch.

Diagram
Online resharding: replicate, verify with dark reads, pause briefly at the proxy, flip routing, keep reverse replication as an escape hatch.
sequenceDiagram
    participant App
    participant PgBouncer
    participant OldDB as Old Shard
    participant NewDB as New Shard
    App->>PgBouncer: normal traffic
    PgBouncer->>OldDB: route queries
    Note over OldDB,NewDB: Start logical replication
    OldDB-->>NewDB: stream WAL (12 hours)
    App->>PgBouncer: dark reads
    PgBouncer->>NewDB: shadow read (verify)
    Note over PgBouncer: Pause traffic for this shard
    OldDB-->>NewDB: final WAL records
    Note over PgBouncer: Update routing to NewDB
    App->>PgBouncer: resume
    PgBouncer->>NewDB: route queries
    Note over OldDB: Reverse replication armed for rollback

Cross-shard queries

When a query includes the shard key in WHERE, the router sends it to one shard. When it does not, the router must scatter the query to every shard and gather results. Figma’s analogy: “like a database-wide game of hide-and-seek: You send out your query to every shard (scatter), then piece together answers from each (gather).”2

Scatter-gather latency is bounded by the slowest shard (tail amplification). At scale, “having too many scatter-gathers would impact horizontal sharding scalability. Because the queries have to touch every single database, each scatter-gather contributes the same amount of load as it would if the database was unsharded.”2

Diagram
A query with the shard key hits one shard directly; without it, the router scatters to all shards and gathers, bounded by the slowest.
flowchart TD
    subgraph Single["Single-shard query"]
        Q1["WHERE workspace_id = W"] --> R1[Router]
        R1 --> S1[Shard 3]
        S1 --> Res1[Result]
    end
    subgraph Scatter["Scatter-gather query"]
        Q2["WHERE created_at > X"] --> R2[Router]
        R2 --> SA[Shard 1]
        R2 --> SB[Shard 2]
        R2 --> SC[Shard 3]
        R2 --> SD[Shard 4]
        SA --> G[Gather]
        SB --> G
        SC --> G
        SD --> G
        G --> Res2[Result]
    end

Cross-shard joins require either colocation (both tables share the shard key so rows live on the same shard), reference tables (small tables replicated to every shard), or a distributed join plan (proxy pulls rows from multiple shards and joins in memory).6

Cross-shard transactions are the hardest problem. Both Figma and Shopify explicitly removed them rather than rely on two-phase commit. Figma: “we chose not to support atomic cross-shard transactions because we could work around cross-shard transaction failures.”2 Shopify: “We opted to remove all cross shard transactions to avoid any issues.”14

Hot keys and multi-tenancy

Real-world traffic follows a power law. Discord observed hot partitions when a large server with hundreds of thousands of members shared a partition with small servers: “reads are more expensive than writes… Lots of concurrent reads as users interact with servers can hotspot a partition.”12

Mitigations:

  • Request coalescing. Discord’s Rust data services layer: “If multiple users are requesting the same row at the same time, we’ll only query the database once.”12
  • Compound partition keys. Discord’s (channel_id, bucket) splits a single channel’s messages across many partitions as it ages.12
  • Write sharding. DynamoDB pattern: append a random suffix to the hot key (user#42#001 through user#42#099) so writes spread across 100 partitions; reads query all suffixes.15
  • Per-key rate limiting. Cap the rate any one key can demand from the database.

For multi-tenant SaaS, three canonical patterns exist: silo (one database per tenant, strongest isolation, highest cost), pool (all tenants share tables with a tenant_id column, lowest cost, noisy-neighbor risk), and bridge (shared database, schema per tenant, middle ground).16 Shopify’s pod architecture is a pragmatic hybrid: pods are silos of datastores (MySQL + Redis + Memcached + cron) with pooled stateless compute. A pod failure affects only that pod’s shops, not the platform.17

Real-World Example

Figma: from one Postgres instance to horizontal sharding in nine months.

Figma’s database stack grew approximately 100x between 2020 and 2024.2 By end of 2022, they had 12 vertically partitioned Postgres databases, but some tables had billions of rows and several terabytes, approaching the RDS IOPS ceiling.

In late 2022, Figma committed to horizontal sharding. They built DBProxy, a Go service that sits between the application and PgBouncer. DBProxy parses SQL into an AST, extracts the shard key from the WHERE clause, looks up the physical database in a topology stored in S3 and etcd, rewrites the query to target the correct shard’s view, and streams the result.2

The key innovation was logical sharding via Postgres views:

CREATE VIEW table_shard1 AS
  SELECT * FROM table
  WHERE hash(shard_key) >= min_range
    AND hash(shard_key) <  max_range;

This let Figma test shard routing with a config-flag rollback (seconds) before committing to the riskier physical split. Related tables are grouped into “colos” that share a shard key, so cross-table joins inside a colo remain single-shard.

Diagram
Figma’s logical-to-physical shard mapping: views act as logical shards on a single database, then physical splits move views to dedicated instances without changing application code.
flowchart LR
    App[Application] --> DBP[DBProxy]
    DBP -->|"hash(FileID) -> range"| V1["View: shard_1"]
    DBP -->|"hash(FileID) -> range"| V2["View: shard_2"]
    DBP -->|"hash(FileID) -> range"| V3["View: shard_3"]
    subgraph PhysDB["Physical Postgres (pre-split)"]
        V1 --> T[Underlying table]
        V2 --> T
        V3 --> T
    end
    subgraph PhysDB2["Physical Postgres (post-split)"]
        V1b["View: shard_1"] --> T1[Table partition 1]
        V2b["View: shard_2"] --> T2[Table partition 2]
    end

The first horizontally sharded table went live in September 2023 with 10 seconds of partial primary-availability impact and zero replica impact.2 Figma explicitly rejected CockroachDB, TiDB, Spanner, and Vitess because migrating an existing Postgres stack was deemed riskier than building DBProxy in-house under a hard deadline.

Open problems Figma documents: globally unique ID generation, atomic cross-shard transactions, global unique indexes, and fully automated reshard operations.2

Trade-offs

ApproachProsConsBest whenOur Pick
Range partitioningEfficient range scans; locality for ordered readsHot ranges on append-heavy workloads; skew near boundariesTime-series with explicit non-time bucketingOnly if range scans are on the critical path
Hash partitioningEven load by construction; simplest to reason aboutNo range queries on hashed column; hash % N moves most keys on reshardPoint lookups, evenly distributed writes, static shard countDefault choice for most OLTP
Consistent hashingMinimal data movement on rebalance (~1/N); tolerates dynamic membershipVariance without virtual nodes; tuning complexityDynamic membership (caches, Dynamo-style clusters)When shard set changes frequently
Directory-basedFlexible per-tenant placement; supports moving a big tenant to its own shardDirectory lookup hop; directory is SPOFMulti-tenant SaaS with per-tenant scale outliersWhen you need per-tenant control

Common Pitfalls

Exercise

Your single Postgres primary hits 85% CPU on writes at 30K WPS and the dataset is 2 TB. You project 5x growth in 18 months. Design a sharding plan: partition key, number of shards, routing layer, resharding strategy, and how you will keep cross-tenant reports working during the migration.

Hint

Think about what entity scopes 90%+ of your queries. Consider pre-sharding into a highly divisible number of logical shards. For cross-tenant reports, consider whether a read replica or an analytics pipeline (CDC to a warehouse) can serve the reporting workload without scatter-gathering the OLTP shards.

Solution

Partition key: tenant_id. Most queries in a multi-tenant SaaS filter by tenant. High cardinality (thousands of tenants), even distribution (no single tenant dominates unless you have a celebrity, which you handle with a dedicated shard), stable (tenant IDs never change), and present in every hot-path query.

Number of shards: 480 logical shards (following Notion’s divisibility reasoning). Start with 16 physical databases (30 schemas each). At 5x growth, move to 48 physical databases (10 schemas each) without re-bucketing.

Routing layer: Application-level. A thin routing function maps hash(tenant_id) % 480 to a logical shard number, then a config file maps logical shard to physical database. Deploy PgBouncer between the app and each physical database to pool connections.

Resharding strategy: Postgres logical replication from old physical databases to new ones. Copy without indexes, rebuild after, verify with dark reads, then pause-and-flip at PgBouncer. Keep reverse replication armed for rollback. Target: under 2 seconds of user-visible impact per shard.

Cross-tenant reports: Do not scatter-gather the OLTP shards. Set up CDC (Debezium on each shard’s WAL) streaming into a columnar warehouse (ClickHouse or BigQuery). Reports query the warehouse, which is optimized for full scans and aggregations. This decouples analytics latency from OLTP latency entirely.

Trade-off accepted: Cross-tenant reports see data with replication lag (seconds to minutes). If real-time cross-tenant queries are required, consider Citus with co-located distributed tables, but accept the operational complexity of a distributed coordinator.

Key Takeaways

  • Sharding is a one-way door. Exhaust vertical scaling, read replicas, and vertical partitioning first.
  • Pick the partition key for the worst access pattern, not the average one. High cardinality, even distribution, stable, present in hot-path queries.
  • Hash-based routing is the default for OLTP. Use consistent hashing only when the shard set is dynamic.
  • Pre-shard into many logical partitions (pick a highly divisible number like 480 or 360). You can grow physical capacity without re-bucketing data.
  • Cross-shard joins and transactions are expensive or impossible. Colocate related data under the same shard key.
  • Hot partitions will find you. Design so you can add request coalescing, write sharding, or split on short notice.
  • Online resharding via logical replication, dark reads, and proxy-level pause-and-flip is the industry standard pattern.

Further Reading

Flashcards

What is the difference between partitioning and sharding?

Partitioning splits rows across tables inside one database instance (Postgres PARTITION BY). Sharding splits rows across independent database instances that share nothing at the storage layer. They often coexist: Notion uses Postgres schemas (partitioning) inside separate RDS instances (sharding).

What are the four criteria for a good partition key?

High cardinality, even distribution, stability (does not change after creation), and presence in hot-path queries so the router can target one shard instead of scattering.

Why is a timestamp a bad partition key?

All writes pile up on the “current” shard while old shards sit idle. This creates a permanent hot partition on the newest time range.

What does consistent hashing guarantee when adding a node?

Only ~1/N of keys move to the new node, compared to hash-mod-N which remaps nearly every key. Virtual nodes smooth out variance across physical nodes.

Why did Notion choose 480 logical shards?

480 is highly divisible (by 2, 3, 4, 5, 6, 8, 10, 12, 15, 16, 20, 24, 30, 32, 40, 48, 60, 80, 96, 120, 160, 240). This allows incremental physical growth from 32 to 40 to 48 to 96 hosts without re-bucketing data.

What is scatter-gather and why is it expensive?

When a query lacks the shard key, the router fans it to every shard and aggregates results. Latency is bounded by the slowest shard (tail amplification), and each scatter-gather contributes the same load as if the database were unsharded.

How did Figma decouple logical sharding from physical sharding?

They used Postgres views (CREATE VIEW … WHERE hash(shard_key) >= min AND < max) as logical shards. This let them test routing with a config-flag rollback in seconds before committing to the riskier physical data split.

What is the pre-sharded virtual partitions pattern?

Create many logical shards (hundreds or thousands) mapped to fewer physical databases. To scale, move logical shards between physical databases without re-hashing any keys. Instagram, Notion, and Figma all use this pattern.

How do Figma and Shopify handle cross-shard transactions?

They removed them entirely. Both explicitly chose not to support atomic cross-shard transactions, working around failures at the application level instead of relying on two-phase commit.

What is Discord’s solution to hot partitions?

A Rust “data services” layer with request coalescing: if multiple users request the same row simultaneously, only one query goes to the database. Combined with the (channel_id, bucket) partition key that splits old channels across many partitions.

What is the DynamoDB write-sharding pattern for hot keys?

Append a random suffix to the hot key (user#42#001 through user#42#099) so writes spread across many partitions. Reads must then query all suffixes and aggregate.

What is Notion’s online resharding sequence?

Provision new databases, start logical replication, copy without indexes (12 hours vs 3 days), rebuild indexes, verify with dark reads, pause traffic at PgBouncer, verify replication caught up, update routing, resume. User-visible impact: about 1 second per shard.

What are the three multi-tenancy patterns?

Silo (one database per tenant, strongest isolation, highest cost), pool (all tenants share tables with tenant_id column, lowest cost, noisy-neighbor risk), and bridge (shared database, schema per tenant, middle ground).

References

Footnotes

  1. Garrett Fidalgo, “Herding elephants: Lessons learned from sharding Postgres at Notion”, Notion Blog, 2021-10-06. https://www.notion.com/blog/sharding-postgres-at-notion ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  2. Sammy Steele, “How Figma’s databases team lived to tell the scale”, Figma Blog, 2024-03-14. https://www.figma.com/blog/how-figmas-databases-team-lived-to-tell-the-scale/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14

  3. PostgreSQL Documentation, “Resource Consumption” (work_mem, shared_buffers, and per-backend resource costs). https://www.postgresql.org/docs/current/runtime-config-resource.html ↩

  4. PostgreSQL Documentation, “Table Partitioning”. https://www.postgresql.org/docs/current/ddl-partitioning.html ↩

  5. Arka Ganguli, Tanner Johnson, Ben Kraft, Nathan Northcutt, “The Great Re-shard: adding Postgres capacity (again) with zero downtime”, Notion Blog, 2023-07-17. https://www.notion.com/blog/the-great-re-shard ↩ ↩2 ↩3 ↩4 ↩5

  6. Citus Documentation, “Choosing Distribution Column” and “Table Co-Location”, 13.0. https://docs.citusdata.com/en/stable/sharding/data_modeling.html ↩ ↩2 ↩3 ↩4

  7. D. Karger, E. Lehman, T. Leighton, M. Levine, D. Lewin, R. Panigrahy, “Consistent Hashing and Random Trees”, STOC 1997. https://dl.acm.org/doi/10.1145/258533.258660 ↩

  8. V. Mirrokni, M. Thorup, M. Zadimoghaddam, “Consistent Hashing with Bounded Loads”, arXiv 1608.01350, 2016. https://arxiv.org/abs/1608.01350 ↩

  9. Andrew Rodland, “Improving load balancing with a new consistent-hashing algorithm”, Vimeo Engineering Blog, 2018. https://web.archive.org/web/20250101010558/https://medium.com/vimeo-engineering-blog/improving-load-balancing-with-a-new-consistent-hashing-algorithm-9f1bd75709ed ↩

  10. Vahab Mirrokni, Morteza Zadimoghaddam, “Consistent Hashing with Bounded Loads”, Google AI Blog, 2017-04-03. https://ai.googleblog.com/2017/04/consistent-hashing-with-bounded-loads.html ↩

  11. Instagram Engineering, “Instagration Pt. 2: Scaling our infrastructure to multiple data centers”, 2016-04-28. https://web.archive.org/web/20210112134648/https://instagram-engineering.com/instagration-pt-2-scaling-our-infrastructure-to-multiple-data-centers-5745cbad7834 ↩

  12. Bo Ingram, “How Discord Stores Trillions of Messages”, Discord Blog, 2023-03-06. https://discord.com/blog/how-discord-stores-trillions-of-messages ↩ ↩2 ↩3 ↩4 ↩5

  13. Instagram Engineering, “Sharding & IDs at Instagram”, 2012-12-30. https://web.archive.org/web/20210112134155/https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c ↩

  14. Hammad Khalid et al., “Horizontally scaling the Rails backend of Shop app with Vitess”, Shopify Engineering, 2024-01-17. https://shopify.engineering/horizontally-scaling-the-rails-backend-of-shop-app-with-vitess ↩ ↩2

  15. AWS Documentation, “Using write sharding to distribute workloads evenly”. https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-partition-key-sharding.html ↩

  16. AWS Prescriptive Guidance, “Multi-tenant SaaS partitioning models for PostgreSQL”. https://docs.aws.amazon.com/prescriptive-guidance/latest/saas-multitenant-managed-postgresql/partitioning-models.html ↩

  17. Xavier Denis, “A Pods Architecture To Allow Shopify To Scale”, Shopify Engineering, 2018-03-02. https://shopify.engineering/a-pods-architecture-to-allow-shopify-to-scale ↩

Finished reading?

Mark it complete to track your progress through the course. Progress is saved in this browser.

This lesson is adapted from “Database Partitioning and Sharding: When One Node Is Not Enough” in The Engineering Handbook by Handbook Academy and contributors, used under CC BY-SA 4.0. Changes: restyled and reorganized for this site. View original source.