Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 010 / 100

Database sharding explained: what developers should know

What to do when one database is no longer enough.

Expona Admin8 min read

You've optimized your indexes, added Redis caching, and scaled to multiple read replicas. Your database is still choking under peak load. At this point, you're staring at the hardest distributed systems problem most teams face: database sharding explained—and it forces you to stop thinking of your data as one unified table and start thinking like a distributed system.

I've lived through this transition three times. Once with a ride-hailing platform where a single user ID index couldn't keep up with concurrent bookings. Once with an e-commerce backend where a single orders table became the bottleneck during flash sales. And once with a CRM where tenant isolation made sharding both necessary and dangerous. Sharding is powerful, but it's also the point of no return. Once you shard, you're committing to operational complexity that touches every layer of your stack.

Try everything else first — Indexes, caching, read replicas and archiving buy a lot of time

Before you shard, exhaust every other optimization. I mean it. Sharding introduces operational overhead that most teams underestimate: deployment becomes harder, debugging becomes a nightmare, and data migrations become terror. I've seen teams shard too early and regret it for years.

Start with the basics. An index on the wrong column can tank performance, but fixing it takes an afternoon. A missing index on a foreign key causes table scans that lock your database during business hours. A full table scan that should have been a lookup is exactly the kind of thing that forces sharding decisions you didn't need to make.

Then move to caching. Redis sitting between your application and database can absorb 70–80% of read traffic with minimal complexity. A user profile lookup that hits the database every time becomes a sub-millisecond Redis hit. Caching doesn't scale forever, but it buys years.

Read replicas let you push read traffic off the primary. In PostgreSQL, you spin up a replica, point your read queries there, and suddenly your primary isn't drowning in analytics queries or user profile requests. This works until your write throughput explodes, not your read throughput.

Data archiving is the move nobody thinks about until it's too late. Your orders table has 500 million rows. Half of them are from three years ago. Archive them to cold storage. Queries on recent data will be 10x faster. Your backup times shrink. Your index lookups tighten. This is free performance and people skip it.

At the ride-hailing platform, we hit the wall because write throughput on the bookings table was 8,000 requests per second during peak hours. Indexes couldn't help. Caching helped with reads but bookings are write-heavy. Read replicas didn't matter because we bottlenecked on the primary. That's when sharding became real.

Choose a shard key — Tenant ID or user ID usually works. Hot keys create hot shards

Your shard key is the column you'll use to distribute data across database instances. It's the most important decision you make. Get this wrong and you've wasted months of engineering work.

In multi-tenant systems, tenant ID is nearly always the right choice. Each tenant's data lives on its own shard. Queries are scoped to a single shard. Backups are tenant-specific. Scaling is clean because tenants scale independently. When tenant A gets huge and tenant B stays small, you can split tenant A across two shards without touching tenant B.

In consumer platforms, user ID works well. User 123's bookings, messages, and preferences all live on shard 5. User 456 lives on shard 7. You can grow capacity per user without reshuffling everyone's data.

The danger is hot keys. If your shard key is a geographic region and 30% of your traffic is from one city, that shard handles 30% of the load while others sit idle. If your shard key is user ID and one user is a bot running 100,000 concurrent requests, that shard melts while others are quiet. I've seen this destroy platforms. One celebrity user on a social network caused their entire shard to time out.

The fix for hot keys is complex. Some teams use compound keys: user ID plus a secondary dimension. Some use a shard map that reassigns hot keys to their own dedicated shard. Some redesign the feature entirely to avoid the hot key problem. All of these are expensive.

At the CRM platform, we sharded by tenant ID initially. One enterprise customer represented 40% of our database volume. Their shard was hot. We had to build a secondary tier that split high-volume tenants further. It was messy but it worked.

Sharding strategies — Compare STRATEGY vs TRADE-OFF: Range | Simple, risk of hot ranges; Hash | Even spread, hard range queries; Directory | Flexible, extra lookup

Three main strategies exist. Each has trade-offs you live with forever.

Range-based sharding

You assign ranges of your shard key to different shards. User IDs 1–1,000,000 go to shard 1. IDs 1,000,001–2,000,000 go to shard 2. Orders from 2024-01-01 to 2024-03-31 go to shard 1. Orders from 2024-04-01 to 2024-06-30 go to shard 2.

Trade-off: Range queries are fast. You know exactly which shard holds orders for Q1. But ranges can become unbalanced. If 80% of your users were created in the last month, they all land in the highest range, creating a hot shard. You'll need to rebalance ranges periodically, which is operational pain.

Hash-based sharding

You hash the shard key and use the hash modulo the number of shards to decide placement. shard_id = hash(user_id) % 8. User 12345 hashes to 456789, which mod 8 is 5, so shard 5 gets it. User 12346 might hash to a different shard. Data spreads evenly across shards automatically.

Trade-off: Hot keys are less likely because hashing distributes uniformly. But range queries are nightmare. If you want all orders between two dates, you have to hit every shard and aggregate results. That's slow and complex. Adding or removing shards requires rehashing everything.

Here's a practical example:

typescript

// Hash-based shard assignment
function getShardId(userId: string, totalShards: number): number {
  const hash = crypto.createHash('md5').update(userId).digest();
  const hashValue = parseInt(hash.toString('hex').substring(0, 8), 16);
  return hashValue % totalShards;
}

// User 'user_12345' might land on shard 3
console.log(getShardId('user_12345', 8)); // Example output: 3

// To add a new shard, you'd need to rehash and migrate data
// This is expensive and dangerous

Directory-based sharding

You maintain a separate lookup table (or Redis cache) that maps shard keys to shard IDs. Tenant ID 999 → shard 5. Tenant ID 1000 → shard 7. When a query comes in, you hit the directory, get the shard ID, and route the query.

Trade-off: Flexible. You can move tenants between shards without rehashing the world. You can detect hot shards and rebalance. But you add a lookup every single query. If the directory goes down, your entire system fails. If the directory is slow, you're slow. The directory itself can become a bottleneck.

At the e-commerce platform, we used directory-based sharding. Every product lookup hit Redis for the shard mapping. Redis was replicated and backed by PostgreSQL. When a shard got hot, we could rebalance by updating the directory. It was operationally cleaner than hash sharding but added latency.

Strategy Even distribution Range queries Rebalancing Operational complexity
Range Risk of hot ranges Fast Moderate Low
Hash Excellent Slow (scan all) Hard (rehash all) High
Directory Excellent Fast Easy Very high (SPOF)

Cross-shard queries hurt — Joins and transactions across shards are slow and complex

Sharding breaks the assumption that your data lives in one place. A simple SQL join becomes a distributed problem.

Imagine you shard orders by user ID. User 123's orders live on shard 5. But you want a query like: "Show me the latest 10 orders where the product category is 'electronics'." Your product data might live on a different shard (or not be sharded at all). Now you have to:

  1. Find which shard has user 123 (if using directory, hit the lookup).
  2. Query shard 5 for user 123's orders.
  3. For each order, look up the product on the product shard.
  4. Filter by category in application memory.
  5. Return results.

That's at least 3–4 round trips per request. A query that took 5ms on a single database now takes 50ms across shards. At scale, this adds up.

Transactions across shards are worse. Imagine you're decrementing a customer's wallet balance (shard 1) and incrementing a merchant's balance (shard 2) in a payment. If shard 1 commits and shard 2 fails, your system is broken. Two-phase commit exists but it's slow and prone to deadlocks.

The solution is to design your schema and queries to avoid cross-shard operations. Denormalize data. Store product category on the order itself. Accept eventual consistency. Build an event log. Make one shard the source of truth and replicate to others asynchronously.

At the trading platform, we sharded by account ID. But account balance queries needed to consider pending trades across multiple shards. We fixed it by maintaining an account balance view that was updated asynchronously from a transactional event log. Queries hit the view (fast, single shard). Writes hit the event log (transactional, single table). Eventually consistent but correct.

Common mistakes

Sharding too early. You hit one problem (read throughput) and assume sharding is the answer. You end up with unnecessary operational burden. Start with replicas and caching.

Choosing the wrong shard key. You shard by product ID instead of user ID. Now every user query hits every shard. It's slower than before sharding. You're stuck.

Not accounting for hot shards. You assume uniform distribution and build no monitoring for shard load. One shard melts while others sit idle. You discover it in production during a spike.

Forgetting about the migration. You design a perfect sharding scheme. Then you realize you need to move 500GB of data from the old database to the sharded one without downtime. That's a week of engineering. Plan it upfront.

Building joins across shards casually. Every cross-shard join doubles or triples query latency. You end up with a system that's slower than the pre-sharded version because queries now scatter-gather.

Key takeaways

  • Sharding is the last resort. Indexes, caching, read replicas, and archiving solve 95% of scaling problems. Master those first.
  • Your shard key is your most important design decision. Tenant ID and user ID usually work. Test for hot keys before you commit.
  • Hash sharding gives even distribution but makes range queries expensive. Range sharding makes queries fast but risks hot ranges. Directory sharding is flexible but adds complexity and a potential single point of failure.
  • Cross-shard queries are expensive. Design your schema to minimize them. Denormalize, use eventual consistency, and embrace event sourcing.
  • Plan your migration strategy before you shard. Moving hundreds of gigabytes of data without downtime is the hard part, not the sharding itself.
  • Once you shard, you're in the distributed systems world. Monitoring, rebalancing, and debugging become vastly harder. Only shard when you absolutely have to.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles