Notion’s PostgreSQL Sharding: Key Decisions, Migration Process, and Hard Lessons
In early 2021 Notion transformed its monolithic PostgreSQL into a horizontally partitioned fleet by sharding the block table and related data, using workspace IDs as the partition key, migrating through double‑write, backfill, and verification steps, and learning hard lessons about timing, zero‑downtime, and schema design.
In early 2021 Notion performed a five‑minute maintenance window that actually reflected months of focused engineering effort to shard its monolithic PostgreSQL into a horizontally partitioned fleet.
Deciding When to Shard
The team monitored PostgreSQL VACUUM processes and noticed they were repeatedly stopping, indicating that the database could no longer reclaim disk space from dead tuples. More critically, the risk of transaction ID (TXID) wraparound —a safety mechanism that halts all writes to protect data—became a product‑survival threat, prompting the decision to shard.
Designing the Sharding Solution
Instead of vertical scaling or using packaged sharding solutions such as Citus or Vitess, Notion opted for an application‑level sharding approach to retain full control over data placement and avoid opaque cluster logic.
Application‑Level Sharding Decisions
What to shard? The block table, which stores each user‑created block as a row, was the highest priority. Any table reachable via foreign‑key relationships from block (e.g., space, discussion, comment) was also considered for sharding to avoid cross‑shard inconsistencies.
How to partition? A good partition key must distribute tuples evenly and keep distributed joins cheap. Because Notion is a team‑centric product and most queries are scoped to a single workspace, the workspace ID (a UUID) was chosen as the partition key.
How many shards? Capacity planning led to a design of 480 logical shards spread across 32 physical databases. Each physical database hosts 15 logical shards (PostgreSQL schemas), and each logical shard contains one copy of every sharded table (e.g., block, collection, space).
Why 480 Logical Shards?
The number 480 was selected because it is divisible by many factors (2, 3, 4, 5, 6, 8, 10, 12, 15, 16, 20, 24, 30, 32, 40, 48, 60, 80, 96, 120, 160, 240). This flexibility allows the fleet to grow from 32 to 40 to 48 hosts without needing to double the number of hosts, unlike a power‑of‑two shard count such as 512.
We evolved from a single‑database deployment to a fleet of 32 physical databases, each containing 15 logical shards, for a total of 480 logical shards.
Migration to Shards
The migration followed a four‑stage framework:
Double‑write : incoming writes were applied to both the old monolith and the new sharded databases.
Backfill : after double‑write began, existing data was copied to the new shards. Using an m5.24xlarge instance with 96 CPUs, the backfill script took roughly three days.
Verification : scripts compared UUID ranges between the monolith and shards, performing random sampling to validate data integrity. “Dark reads” fetched data from both sides to measure API latency and confirm consistency before cut‑over.
Switch‑over : the final cut‑over could be performed gradually (e.g., double‑read then full read migration).
Audit‑Log Double‑Write Strategy
Three double‑write options were evaluated: direct writes to both databases, PostgreSQL logical replication, and an audit‑log with a catch‑up script. The audit‑log approach was chosen because logical replication could not keep up with the high write volume on the block table.
A reverse audit‑log and script were also prepared to replay any writes back to the monolith if a rollback became necessary.
Verification Details
The verification script validated continuous UUID ranges, comparing each monolith record with its sharded counterpart. Because a full table scan would be expensive, the script sampled UUIDs and checked adjacent ranges. Dark reads added a modest API latency but gave confidence for a seamless cut‑over.
Hard Lessons Learned
Sharding too early : waiting until the monolith was under severe pressure forced a frugal migration that limited the use of logical replication and required custom catch‑up scripts.
Zero‑downtime ambition : the double‑write throughput became the main bottleneck; additional script optimisation could have reduced the cut‑over window to seconds.
Composite primary keys : rows originally used a separate id primary key and a space_id partition key. Merging them into a single column would have avoided passing both keys through the application.
Alternative architectures : options such as DynamoDB or NVMe‑heavy PostgreSQL on bare metal were explored but rejected due to risk or operational cost.
Outcome
Despite the challenges, the sharding effort succeeded: user‑visible downtime was reduced to a few minutes, performance improved noticeably, and the project demonstrated coordinated teamwork and decisive execution under time‑sensitive constraints.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
