Sharding Postgres Without Leaving Postgres
Figma's database stack grew almost 100x since 2020, and by late 2022 its largest tables were each nearing the limits of a single database server. Vertical partitioning can't fix that, since the smallest thing it can move is one whole table. Rather than switching to a NewSQL or NoSQL database, the databases team split those tables across many servers from inside their own application. The design: a small Go proxy (DBProxy) with a minimal query engine, a few chosen shard keys, hashed so data spreads evenly, and related tables grouped into 'colos' so common joins and transactions keep working. The signature move was to make a table act sharded before moving any data: Postgres 'views' plus on/off flags let the team rehearse the whole sharded setup on the original server, with rollback in seconds. Only after that rehearsal proved itself in production did they run the real, hard-to-reverse split. The first table shipped in September 2023, roughly 9 months in, with about 10 seconds where some writes failed on the main databases.
Walk a production table from one big table to truly split, breaking something at each step. See why Figma made the table act sharded (with views, saved queries that make one table look like many) before moving a single row.
Problem
Figma's database stack has grown almost 100x since 2020: the kind of problem a business wants, and the kind an infrastructure team has to stay ahead of. In 2020 the company ran a single Postgres database on AWS's largest instance. By the end of 2022 the team had a distributed setup with caching, read replicas, and a dozen vertically partitioned databases, each holding a group of related tables like 'Figma files' or 'Organizations'. Figma's earlier post on scaling to multiple databases frames vertical partitioning the same way this one does: an easy, high-impact lever that bought real runway, and a stepping stone toward the real goal, horizontal sharding, not the finish line.
Staying ahead meant measuring where the limit actually was. As the fleet grew, the team combined historical data and load testing to quantify each database's limits (CPU, IO, table size, rows written) and predict the runway left per shard. The data showed that some tables, at several terabytes and billions of rows, were becoming too large for any single database. Reliability began to suffer during Postgres vacuums, the background cleanup that keeps Postgres from running out of transaction IDs and breaking down, and the highest-write tables were on track to exceed the write speed Amazon RDS could give them. Vertical partitioning could not save them, because its smallest unit is a single table.
The two moves are different in kind: vertical partitioning moves whole tables onto separate servers, while horizontal sharding spreads one table's rows across servers. Only the second can relieve a single oversized table.
The obvious exits were all one-way doors. The team evaluated CockroachDB, TiDB, Spanner, and Vitess, but moving to any of them meant a complex migration across two different database stores and setting aside years of hard-won expertise running RDS Postgres. With only months of runway at Figma's growth rate, proving a whole new storage system safe while migrating the most business-critical data was judged too risky; the team favored known low-risk options over easier-looking ones with more uncertainty. NoSQL was ruled out on its own grounds: Figma's product runs on a complex relational data model (file and organization metadata, comments, file versions) that NoSQL APIs can't express, and adopting one would have meant rewriting almost the entire backend.
That left building horizontal sharding on top of the RDS Postgres the team already ran, without reimplementing a general-purpose sharded database in-house and competing with open-source projects and database vendors. Because the solution was tailored to Figma's architecture, it could ship a much smaller feature set. It skipped all-or-nothing writes that span shards, grouped related tables so application code barely had to change, and supported only the slice of Postgres the product actually used. And the sharded and unsharded versions stayed compatible, so the team could roll back if something unexpected went wrong.
Solution
The project began with an unusually explicit goals list that explains most of the design:
- Minimize developer impact, so product teams keep shipping instead of refactoring.
- Scale out transparently, so future shard splits need no application changes.
- Skip expensive backfills, which at Figma's table sizes would have taken months.
- Make incremental progress and avoid one-way migrations, keeping rollback possible after a physical split.
- Keep strong consistency without tricks like double-writes, and play to the team's strengths under deadline.
The first decision was the shard key, and Figma's answer diverges from the textbook. No single column worked across the data model, and a unified key would have meant a new column on every table, backfills, and heavy refactoring. Instead the team chose a handful of keys, UserID, FileID, OrgID, so almost every table could shard by one. Related tables sharing a key are grouped into 'colos' that share one shard key and physical layout. Inside a colo, joins and transactions still work as long as they stay within a single shard-key value, which is how most code already queried.
Many of those keys either counted up in order or started with a timestamp, so most rows would have piled onto a few shards. Rather than migrate to random IDs, the team routes on a hash of the shard key for an even spread, trading away efficient range scans (rare enough at Figma to accept).
The serving stack was rebuilt around DBProxy, a new Go service between the application and the connection pooler (the layer that hands out database connections). It adds load-shedding and retries, plus the query engine at the heart of the system, which runs a short pipeline:
- A parser turns the application's SQL into a tree.
- A logical planner reads it for the query type and the logical shard IDs.
- A physical planner maps those to real databases and rewrites the query for the right shard.
A query that carries a shard key routes to one database; a query without one must ask every shard and combine the answers (a scatter-gather), as expensive as if nothing were sharded. A shadow-planning test harness tried possible schemes against real traffic without affecting it. From that the team picked a query language covering the most common 90% of queries: all range scans and point lookups, but joins only within a colo, on the shard key.
The signature move is separating logical sharding from physical sharding. Logical sharding makes a table behave as if it were already split, while every row still sits on one server. Figma builds this with Postgres 'views' (saved queries), one per shard, each exposing just that shard's rows, so the application reads and writes 'shards' that are really still one table. Rollout ran gradually behind on/off flags, and rolling back a bug was a config change that rerouted to the main table in seconds. The views were a risk to prove, not assume, since they add overhead and can change how Postgres plans queries; the team load-tested them (under 10% overhead worst case) against live traffic. By the first real split, the sharded setup had already been running in production.
Routing depends on an always-current map of which rows live on which database, and sending a query to the wrong one is the single failure the team could not tolerate. The team's system pushes updates to that map in under a second and enforces one rule above all: every shard maps to exactly one physical database.
The real split moves data from one database to many, and the switchover had to survive a new failure mode: succeeding on only some shards. The team skipped filtered replication (copying only each shard's own slice) and instead copies the whole dataset to every shard, then limits each to its subset. The first sharded table shipped in September 2023, about 9 months in, with just 10 seconds where some writes failed on the main databases and no slowdowns after. Ahead lie more complex databases (dozens of tables, thousands of call-sites) and a named list of things still to build:
- sharded schema updates,
- globally unique IDs for sharded primary keys,
- all-or-nothing writes across shards for critical cases,
- unique indexes that hold across shards,
- a data-access library (ORM) that works seamlessly with sharding,
- and one-click resharding.
And the post is honest: once enough runway is banked, the team will weigh this in-house RDS path against the maturing NewSQL options.
Tradeoffs
- Giving up all-or-nothing writes across shards took a guarantee the database used to provide and made it every engineer's problem to handle in code. Writes that span shards can now partially fail (some databases commit while others do not), and the post names the concrete nightmare: move a team between two organizations, and find half its data missing. Product code must be written to survive these partial failures, a cost paid feature by feature, indefinitely, by engineers who never chose the database architecture. The team judged working around cross-shard failures cheaper than building distributed transactions under deadline, and tellingly, all-or-nothing cross-shard writes for critical cases sit on the future-work list: they admit it is a known gap rather than pretending it isn't.
- Sharding also dissolved the guarantees Postgres used to enforce for free. Foreign keys and globally unique indexes can no longer be enforced across shards (unique indexes survive only when they include the shard key), and schema changes must now be coordinated across every shard to keep them in sync. Each was once a property the database guaranteed and is now a property the platform's tooling must actively maintain. The future-work list (cross-shard unique indexes, sharded schema updates, globally unique IDs) reads as a catalog of exactly these rebuilding projects.
- The speedup only helps certain kinds of queries. A scatter-gather touches every shard and adds the same load as an unsharded fleet, so horizontal sharding only relieves queries that carry a shard key. That is why DBProxy's language covers the most common 90% of queries, and why developers had to rewrite the rest. Hash-based routing adds a permanent constraint of its own: efficient range scans over shard keys are gone, fine today because the pattern is rare at Figma, but a structural penalty on any future feature that wants one. Both constraints are invisible until a product team designs against them.
- Figma now owns a query engine. DBProxy's parser, planners, and mapping system are exactly the kind of software the team said it did not want to build itself, competing with companies that make databases. The post concedes that supporting full SQL would have made DBProxy resemble the Postgres query engine itself, a line held only by continually saying no. Every unsupported query shape, the sharding-friendly data-access library, the cross-shard unique indexes, and the one-click resharding on the roadmap all fall to the databases team. It is platform surface that grows with the product, owned by the small team that chose this path precisely because it was small.
- This whole in-house approach may not be permanent, and the post says so. The project started 18 months earlier under tight deadline pressure, and it closes by committing to reevaluate the in-house path against open-source and managed NewSQL options once enough runway is banked. Nine months of engineering bought Figma control, reversibility, and time, not a verdict that in-house RDS sharding is the end state. A reader tempted to copy the architecture should copy the reasoning instead: the choice was right for Figma's expertise, runway, and risk posture, all three of which the team expects to change.
Patterns in this article
- Application-Layer Sharding
Figma shards its primary transactional store through DBProxy's query engine (shard keys scrambled with a hash, related tables grouped so common joins and transactions keep working), where Discord shards a search-index fleet. Same idea, different storage underneath: the application layer owns where data goes and how queries are routed, so the database itself can stay stock.
- Logical–Physical Migration Split
Postgres views made the sharded topology real to every client while the data stayed on one host; feature flags ramped traffic and rollback was a seconds-fast configuration change. The one-way physical failover ran only after the sharded world had already proven itself under live production traffic.
- Shard-Key Colocation
Figma's 'colos' are exactly this pattern: related tables grouped so they share one shard key and one physical layout. Inside a colo, cross-table joins and full transactions work, as long as they stay within a single value of the shard key. Most application code already queried that way, which is what made the abstraction cheap for product developers: the payoff is that the queries you were already running stay free.
Also solving this
Other systems in behindscale's Single-table scaling ceiling class: