Picture this: You’re running a successful e-commerce platform. What started as a few hundred orders per day has exploded to millions. Your single database server, once handling queries with ease, now groans under the weight of constant reads and writes. Response times have degraded from milliseconds to seconds. Your database has hit its vertical scaling limit—you can’t just throw more RAM or CPU at the problem anymore.
This is where database sharding enters the picture. It’s the technique that powers the massive scale of companies like Instagram (storing billions of photos), Uber (tracking millions of rides simultaneously), and Discord (handling millions of concurrent users). In system design interviews, demonstrating a deep understanding of sharding shows you can think beyond single-server architectures to build truly scalable systems.
Why do interviewers love asking about sharding? Because it touches on so many critical aspects of distributed systems: data distribution strategies, consistency challenges, query routing, and operational complexity. It’s a litmus test for whether you understand the trade-offs involved in scaling beyond a single machine. For mid-level engineers, knowing when and why to shard is essential. For senior engineers, understanding the implementation details and operational challenges is expected.
What Sharding Really Means
At its core, sharding is about breaking your data into smaller, independent pieces called shards, each living on a separate database server. Think of it like organizing a massive library. Instead of cramming all books into one building until the shelves buckle, you create multiple library branches. Each branch holds a portion of the collection, and you need a system to know which branch has which books.
Let me walk you through this concept step by step. Imagine we have a users table with 100 million records. In a traditional setup, all these records live in one database. Every query, whether reading or writing, hits this single database. As traffic grows, this becomes a bottleneck—not just for performance, but for availability too. If this database goes down, your entire application goes down.
With sharding, we might split these 100 million users across 10 database servers, each holding 10 million users. But here’s where it gets interesting: how do we decide which users go to which shard? This decision—the sharding strategy—is perhaps the most critical choice you’ll make in your sharding implementation.
flowchart TD A[Application Layer] --> B[Shard Router] B --> C{Determine Shard} C -->|Users 0-9M| D[Shard 1<br/>DB Server 1] C -->|Users 10-19M| E[Shard 2<br/>DB Server 2] C -->|Users 20-29M| F[Shard 3<br/>DB Server 3] C -->|Users 90-99M| G[Shard N<br/>DB Server N] style A fill:#e1f5fe style B fill:#fff59d style D fill:#c8e6c9 style E fill:#c8e6c9 style F fill:#c8e6c9 style G fill:#c8e6c9
The beauty of sharding is that each shard operates independently. Shard 1 doesn’t need to know about Shard 2’s data. They can be optimized, backed up, and even fail independently. This independence is what enables horizontal scaling—you can keep adding more shards as your data grows.
Common Sharding Strategies
The strategy you choose for distributing data across shards fundamentally shapes your system’s behavior. Let me walk through the most common approaches, each with its own personality and quirks.
Range-Based Sharding
Range-based sharding divides data based on a continuous range of values. It’s intuitive and simple to understand. For example, in our user database, we might shard by user ID ranges:
class RangeBasedShardRouter:
def __init__(self):
<em># Define shard ranges</em>
self.shard_ranges = [
{"min": 0, "max": 999999, "shard": "shard1"},
{"min": 1000000, "max": 1999999, "shard": "shard2"},
{"min": 2000000, "max": 2999999, "shard": "shard3"},
{"min": 3000000, "max": 3999999, "shard": "shard4"}
]
def get_shard(self, user_id):
"""
Determine which shard a user_id belongs to.
This is O(n) but could be optimized with binary search for many shards.
"""
for range_config in self.shard_ranges:
if range_config["min"] <= user_id <= range_config["max"]:
return range_config["shard"]
raise ValueError(f"No shard found for user_id: {user_id}")
def get_range_for_query(self, min_id, max_id):
"""
For range queries, determine which shards to query.
This is powerful - we can limit our query to specific shards.
"""
affected_shards = []
for range_config in self.shard_ranges:
<em># Check if ranges overlap</em>
if not (max_id < range_config["min"] or min_id > range_config["max"]):
affected_shards.append(range_config["shard"])
return affected_shards
The elegance of range-based sharding shines when you need to run range queries. Want all users created in the last month? If you’re sharding by timestamp, you might only need to query one or two recent shards instead of all of them. This is why time-series databases often use range-based sharding—it aligns perfectly with their access patterns.
But here’s the catch: range-based sharding can lead to uneven data distribution. If your user IDs aren’t uniformly distributed (maybe you had a viral marketing campaign that brought in millions of users in one week), some shards will be much larger than others. This “hot shard” problem is range-based sharding’s Achilles’ heel.
Hash-Based Sharding
Hash-based sharding takes a different approach. Instead of organizing data by its natural order, it uses a hash function to pseudo-randomly distribute data across shards. Here’s how it works:
import hashlib
class HashBasedShardRouter:
def __init__(self, num_shards):
self.num_shards = num_shards
self.shards = [f"shard{i}" for i in range(num_shards)]
def get_shard(self, key):
"""
Use consistent hashing to determine shard.
MD5 is used here for distribution, not security.
"""
<em># Convert key to bytes and hash it</em>
hash_value = hashlib.md5(str(key).encode()).hexdigest()
<em># Convert hex to integer and modulo by number of shards</em>
shard_index = int(hash_value, 16) % self.num_shards
return self.shards[shard_index]
def get_shards_for_batch(self, keys):
"""
For batch operations, group keys by their target shard.
This minimizes the number of database connections needed.
"""
shard_groups = {}
for key in keys:
shard = self.get_shard(key)
if shard not in shard_groups:
shard_groups[shard] = []
shard_groups[shard].append(key)
return shard_groups
The hash function acts like a randomizer, spreading your data evenly across all shards. Even if a million users sign up in one day, they’ll be distributed roughly equally across all your shards. This solves the hot shard problem that plagues range-based sharding.
However, hash-based sharding has its own trade-offs. Range queries become expensive—to find all users created in the last month, you must query every single shard and merge the results. You’ve traded efficient range queries for even distribution. Additionally, adding or removing shards becomes complex because it changes the hash mapping, potentially requiring massive data migration.
Consistent Hashing
This is where consistent hashing enters the scene. It’s a clever variation of hash-based sharding that minimizes data movement when you add or remove shards. Instead of using simple modulo arithmetic, consistent hashing maps both shards and keys to points on a circle:
import hashlib
import bisect
class ConsistentHashRouter:
def __init__(self, num_virtual_nodes=150):
self.num_virtual_nodes = num_virtual_nodes
self.ring = {} <em># Hash value -> shard name</em>
self.sorted_keys = [] <em># Sorted list of hash values</em>
self.shards = set()
def _hash(self, key):
"""Generate a hash value for a key."""
return int(hashlib.md5(key.encode()).hexdigest(), 16)
def add_shard(self, shard_name):
"""
Add a shard to the ring with multiple virtual nodes.
Virtual nodes help distribute data more evenly.
"""
self.shards.add(shard_name)
for i in range(self.num_virtual_nodes):
<em># Create virtual node identifier</em>
virtual_node_key = f"{shard_name}:vnode{i}"
hash_value = self._hash(virtual_node_key)
self.ring[hash_value] = shard_name
bisect.insort(self.sorted_keys, hash_value)
def remove_shard(self, shard_name):
"""
Remove a shard and all its virtual nodes.
Data will automatically redistribute to remaining shards.
"""
if shard_name not in self.shards:
return
self.shards.remove(shard_name)
for i in range(self.num_virtual_nodes):
virtual_node_key = f"{shard_name}:vnode{i}"
hash_value = self._hash(virtual_node_key)
del self.ring[hash_value]
self.sorted_keys.remove(hash_value)
def get_shard(self, key):
"""
Find which shard a key belongs to by finding the next
shard on the ring in clockwise direction.
"""
if not self.sorted_keys:
raise ValueError("No shards available")
hash_value = self._hash(str(key))
<em># Find first shard with hash >= key hash</em>
index = bisect.bisect_right(self.sorted_keys, hash_value)
<em># Wrap around to the first shard if necessary</em>
if index == len(self.sorted_keys):
index = 0
return self.ring[self.sorted_keys[index]]
The magic of consistent hashing is in its stability. When you add a new shard, only a small fraction of keys (roughly 1/N where N is the number of shards) need to move. This makes scaling much more manageable in production systems.
The Architecture of a Sharded System
Now that we understand sharding strategies, let’s examine how these pieces fit together in a complete system architecture. A sharded database system isn’t just about splitting data—it’s about coordinating multiple components to work together seamlessly.
graph TB subgraph "Application Tier" A1[App Server 1] A2[App Server 2] A3[App Server 3] end subgraph "Routing Layer" R1[Shard Router 1] R2[Shard Router 2] CM[Config Manager] end subgraph "Data Tier" S1[(Shard 1<br/>Primary)] S1R[(Shard 1<br/>Replica)] S2[(Shard 2<br/>Primary)] S2R[(Shard 2<br/>Replica)] S3[(Shard 3<br/>Primary)] S3R[(Shard 3<br/>Replica)] end subgraph "Metadata Store" M[(Shard Mapping<br/>Database)] end A1 --> R1 A2 --> R1 A2 --> R2 A3 --> R2 R1 --> CM R2 --> CM CM --> M R1 --> S1 R1 --> S2 R1 --> S3 R2 --> S1 R2 --> S2 R2 --> S3 S1 -.-> S1R S2 -.-> S2R S3 -.-> S3R
The Routing Layer
The routing layer is the brain of your sharded system. It receives queries from the application, determines which shard(s) contain the relevant data, and routes the query accordingly. This isn’t just simple forwarding—the router often needs to handle complex scenarios:
go
package main
import (
"database/sql"
"fmt"
"sync"
)
<em>// ShardRouter handles query routing to appropriate shards</em>
type ShardRouter struct {
shards map[string]*sql.DB
shardConfig *ConsistentHashConfig
mu sync.RWMutex
}
<em>// ExecuteQuery routes a query to the appropriate shard</em>
func (sr *ShardRouter) ExecuteQuery(userID int64, query string, args ...interface{}) (*sql.Rows, error) {
<em>// Determine target shard</em>
shardName := sr.shardConfig.GetShard(userID)
sr.mu.RLock()
db, exists := sr.shards[shardName]
sr.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("shard %s not found", shardName)
}
<em>// Execute query on the specific shard</em>
return db.Query(query, args...)
}
<em>// ExecuteCrossShardQuery handles queries that span multiple shards</em>
func (sr *ShardRouter) ExecuteCrossShardQuery(query string, args ...interface{}) ([]Result, error) {
var wg sync.WaitGroup
results := make(chan Result, len(sr.shards))
errors := make(chan error, len(sr.shards))
<em>// Execute query on all shards in parallel</em>
for shardName, db := range sr.shards {
wg.Add(1)
go func(name string, database *sql.DB) {
defer wg.Done()
rows, err := database.Query(query, args...)
if err != nil {
errors <- fmt.Errorf("shard %s error: %v", name, err)
return
}
defer rows.Close()
<em>// Process results</em>
for rows.Next() {
var result Result
if err := rows.Scan(&result.Fields...); err != nil {
errors <- err
return
}
results <- result
}
}(shardName, db)
}
<em>// Wait for all queries to complete</em>
wg.Wait()
close(results)
close(errors)
<em>// Collect errors</em>
var errs []error
for err := range errors {
errs = append(errs, err)
}
if len(errs) > 0 {
return nil, fmt.Errorf("cross-shard query failed: %v", errs)
}
<em>// Collect and merge results</em>
var allResults []Result
for result := range results {
allResults = append(allResults, result)
}
return allResults, nil
}
Notice how cross-shard queries require parallel execution and result aggregation. This is one of the fundamental challenges of sharding—operations that were simple in a single database become complex distributed operations.
The Metadata Management Challenge
In a sharded system, metadata becomes critical. You need to track which shards exist, where they’re located, their health status, and the mapping between data and shards. This metadata must be highly available—if you can’t determine which shard to route to, your entire system grinds to a halt.
Most production systems store this metadata in a separate, highly-available database or service like ZooKeeper or etcd. The metadata service becomes the source of truth for your shard topology.
Common Pitfalls and Their Solutions
Let me share some hard-learned lessons from real-world sharding implementations. These are the issues that keep engineers up at night and the solutions that have emerged from painful experience.
The Cross-Shard Transaction Problem
In a single database, transactions give us ACID guarantees. But when data spans multiple shards, maintaining these guarantees becomes exponentially harder. Consider a simple money transfer between two users who happen to be on different shards:
<em># This looks simple but hides enormous complexity</em>
async def transfer_money(from_user_id, to_user_id, amount):
from_shard = get_shard(from_user_id)
to_shard = get_shard(to_user_id)
if from_shard == to_shard:
<em># Lucky case - both users on same shard</em>
return await execute_local_transaction(from_shard, from_user_id, to_user_id, amount)
else:
<em># Distributed transaction needed</em>
return await execute_distributed_transaction(from_shard, to_shard, from_user_id, to_user_id, amount)
The distributed transaction path opens a Pandora’s box of complexity. You might need to implement two-phase commit (2PC), deal with coordinator failures, handle partial rollbacks, and accept the performance penalties of distributed locking.
Many systems avoid this complexity by designing around it. Instead of direct transfers, they might use an event-driven architecture where each shard publishes events that others consume asynchronously. Or they might use the Saga pattern, breaking the transaction into a series of local transactions with compensating actions for failures.
The Hot Shard Problem
Even with careful planning, some shards inevitably become busier than others. Maybe a celebrity user’s profile lives on one shard, or a viral product causes one shard to get hammered with orders. I’ve seen this manifest in various ways:
The Celebrity Problem: When Taylor Swift joins your social media platform, whichever shard holds her account will melt under the load of millions of followers checking her updates.
The Temporal Hotspot: If you shard by time ranges, the “current” shard handling today’s data will always be the busiest while historical shards sit idle.
The Geographic Skew: Sharding by region seems logical until you realize 40% of your users are in one city.
Solutions require creativity. You might need to:
- Implement sub-sharding for hot entities
- Use read replicas aggressively for hot shards
- Cache hot data at the application layer
- Design special handling for known hot keys
Query Routing Complexity
As your sharded system grows, query routing becomes increasingly sophisticated. Simple key-based lookups are easy, but real applications need much more:
node
class SmartQueryRouter {
constructor(shardManager, queryAnalyzer) {
this.shardManager = shardManager;
this.queryAnalyzer = queryAnalyzer;
this.queryCache = new LRUCache(10000);
}
async routeQuery(sql, params) {
// Analyze query to understand its requirements
const analysis = this.queryAnalyzer.analyze(sql, params);
// Check if we can use cached results
const cacheKey = this.generateCacheKey(sql, params);
if (analysis.isCacheable && this.queryCache.has(cacheKey)) {
return this.queryCache.get(cacheKey);
}
// Determine routing strategy based on query type
let results;
switch (analysis.type) {
case 'SINGLE_KEY':
// Simple case - route to one shard
results = await this.executeSingleShardQuery(
analysis.shardKey,
sql,
params
);
break;
case 'MULTI_KEY':
// Need to query multiple specific shards
results = await this.executeMultiShardQuery(
analysis.shardKeys,
sql,
params
);
break;
case 'SCATTER_GATHER':
// Must query all shards and merge results
results = await this.executeScatterGatherQuery(
sql,
params,
analysis.aggregation
);
break;
case 'JOIN':
// Complex case - may need to fetch from multiple shards
// and perform join in application layer
results = await this.executeDistributedJoin(
analysis.joinPlan,
params
);
break;
default:
throw new Error(`Unsupported query type: ${analysis.type}`);
}
// Cache results if appropriate
if (analysis.isCacheable) {
this.queryCache.set(cacheKey, results, analysis.ttl);
}
return results;
}
async executeScatterGatherQuery(sql, params, aggregationType) {
// Get all active shards
const shards = await this.shardManager.getActiveShards();
// Execute query on all shards in parallel
const shardResults = await Promise.all(
shards.map(shard =>
this.executeQueryOnShard(shard, sql, params)
.catch(err => {
console.error(`Shard ${shard.id} query failed:`, err);
// Decide whether to fail entirely or continue with partial results
if (this.config.allowPartialResults) {
return null;
}
throw err;
})
)
);
// Filter out failed shards if allowing partial results
const validResults = shardResults.filter(r => r !== null);
// Merge and aggregate results based on query requirements
return this.mergeResults(validResults, aggregationType);
}
}
The router becomes a distributed query planner, deciding how to execute queries efficiently across shards while maintaining correctness.
Real-World Sharding Patterns
Let’s examine how major tech companies have solved sharding challenges in production. These patterns emerge from years of operational experience and billions of users.
Instagram’s User-ID Based Sharding
Instagram shards its massive photo database by user ID, but with a clever twist. They embed the shard ID directly into their photo IDs, making routing instant without lookups:
def generate_photo_id(shard_id, timestamp, sequence):
"""
Instagram-style ID generation
64-bit ID = timestamp (41 bits) + shard_id (13 bits) + sequence (10 bits)
"""
<em># Milliseconds since custom epoch (e.g., Jan 1, 2015)</em>
timestamp_bits = (timestamp - CUSTOM_EPOCH) << 23
<em># Shard ID (supports up to 8192 shards)</em>
shard_bits = shard_id << 10
<em># Sequence number for same millisecond (up to 1024 per ms per shard)</em>
sequence_bits = sequence
return timestamp_bits | shard_bits | sequence_bits
def extract_shard_id(photo_id):
"""Extract shard ID from photo ID - no lookup needed!"""
return (photo_id >> 10) & 0x1FFF <em># Extract 13 bits</em>
This approach eliminates the need for a separate mapping lookup—the shard location is encoded in the ID itself. Brilliant for read-heavy workloads where avoiding that extra lookup matters.
Discord’s Hybrid Sharding Strategy
Discord uses different sharding strategies for different data types, recognizing that one size doesn’t fit all:
- Messages: Sharded by channel ID (range-based) because messages in a channel are usually accessed together
- User Data: Sharded by user ID (hash-based) for even distribution
- Guild (Server) Data: Sharded by guild ID with special handling for large guilds that might need sub-sharding
This hybrid approach optimizes for different access patterns within the same system.
Notion’s Document-Based Sharding
Notion takes a different approach, sharding by workspace/document rather than user. This aligns with their collaboration model—users frequently access shared documents, so keeping related documents together minimizes cross-shard queries:
flowchart LR subgraph "Workspace A" D1[Doc 1] D2[Doc 2] D3[Doc 3] end subgraph "Workspace B" D4[Doc 4] D5[Doc 5] end subgraph "Shard 1" W1[Workspace A Data] U1[User Data for A] end subgraph "Shard 2" W2[Workspace B Data] U2[User Data for B] end D1 --> W1 D2 --> W1 D3 --> W1 D4 --> W2 D5 --> W2
Interview Deep Dive: Sharding Considerations
When discussing sharding in an interview, you need to demonstrate more than just technical knowledge. You need to show judgment about when to shard and what trade-offs you’re accepting.
When NOT to Shard
This is crucial: sharding isn’t always the answer. I’ve seen teams jump to sharding too early and create unnecessary complexity. Consider these alternatives first:
- Vertical Scaling: Modern cloud databases can scale to impressive sizes. Amazon RDS can handle instances with 128 vCPUs and 4TB of RAM. That’s often enough for years of growth.
- Read Replicas: If your bottleneck is read traffic, adding read replicas is far simpler than sharding.
- Caching: A well-designed caching layer can eliminate 90%+ of database load for read-heavy applications.
- Query Optimization: I’ve seen “sharding projects” canceled after someone added the right indexes or rewrote inefficient queries.
Interview Tip: Always start by asking about current scale and growth projections. If they’re handling 1000 requests per second and expect 10x growth, sharding might be premature. If they’re at 100k requests per second, sharding is likely necessary.
Sharding Decision Framework
Here’s how I approach the sharding decision in interviews:
flowchart TD A[Database Performance Issue] --> B{What's the bottleneck?} B -->|CPU/Memory| C{Can we scale vertically?} B -->|I/O| D{Read or Write heavy?} B -->|Storage| E[Consider Sharding] C -->|Yes| F[Scale Vertically First] C -->|No| G{Is data partitionable?} D -->|Read Heavy| H[Add Read Replicas] D -->|Write Heavy| G G -->|Yes| I[Design Sharding Strategy] G -->|No| J[Consider Alternative Architectures] I --> K{Choose Shard Key} K --> L[Plan Migration Strategy] L --> M[Implement Gradually]
The Shard Key Selection
Choosing the right shard key is perhaps the most critical decision. In interviews, I evaluate options systematically:
User ID Sharding:
- ✅ Even distribution (with hash-based routing)
- ✅ User queries stay on one shard
- ❌ Cross-user queries require scatter-gather
- ❌ Social features (following, messaging) often span shards
Time-Based Sharding:
- ✅ Recent data stays hot, old data can be archived
- ✅ Time-range queries are efficient
- ❌ Uneven load (current shard is always busiest)
- ❌ Historical queries might span many shards
Geographic Sharding:
- ✅ Data locality for regional users
- ✅ Compliance with data residency laws
- ❌ Uneven distribution (some regions are larger)
- ❌ Users traveling cause cross-region queries
Tenant-Based Sharding (for B2B):
- ✅ Complete isolation between customers
- ✅ Per-tenant scaling and customization
- ❌ Uneven sizes (enterprise vs. small customers)
- ❌ Shared data becomes complex
Pro Insight: In real systems, you often need compound shard keys. Slack shards by (workspace_id, channel_id), giving them both isolation and the ability to split large workspaces.
Common Mistakes to Avoid
Let me share some painful mistakes I’ve seen (and made) in sharding implementations:
Common Mistakes:
- Sharding Too Early: Adding sharding complexity before exhausting simpler solutions
- Ignoring Resharding: Not planning for what happens when you need to change shard count
- Forgetting About Joins: Discovering too late that critical queries need cross-shard joins
- Underestimating Operational Complexity: Backups, migrations, and monitoring become N times harder
- Not Testing Failure Scenarios: What happens when a shard goes down during peak traffic?
Advanced Sharding Techniques
For senior engineering roles, interviewers might explore advanced sharding concepts:
Dynamic Resharding
As your system grows, you’ll need to add shards. This isn’t trivial—you need to:
- Minimize downtime during migration
- Ensure data consistency during the transition
- Handle in-flight requests correctly
- Update all shard mappings atomically
Here’s a simplified approach using double-writing:
class ReshardingManager:
def __init__(self, old_router, new_router):
self.old_router = old_router
self.new_router = new_router
self.migration_state = {} <em># Track which ranges are migrated</em>
async def migrate_shard_range(self, key_range, source_shard, target_shard):
"""Migrate a range of keys between shards with zero downtime"""
<em># Step 1: Enable double-writing for this range</em>
self.enable_double_writing(key_range)
<em># Step 2: Copy historical data</em>
await self.copy_data(key_range, source_shard, target_shard)
<em># Step 3: Verify data consistency</em>
is_consistent = await self.verify_consistency(key_range, source_shard, target_shard)
if not is_consistent:
raise Exception("Consistency check failed")
<em># Step 4: Switch reads to new shard</em>
self.switch_reads(key_range, target_shard)
<em># Step 5: Stop writing to old shard</em>
self.disable_writes_to_old(key_range, source_shard)
<em># Step 6: Clean up old data (after safety period)</em>
await self.schedule_cleanup(key_range, source_shard)
Hierarchical Sharding
For massive scale, single-level sharding isn’t enough. Companies like Facebook use hierarchical sharding:
graph TD A[Global Router] --> B[Regional Router US] A --> C[Regional Router EU] A --> D[Regional Router ASIA] B --> E[Shard Group 1] B --> F[Shard Group 2] C --> G[Shard Group 3] C --> H[Shard Group 4] E --> I[Shard 1.1] E --> J[Shard 1.2] E --> K[Shard 1.3] F --> L[Shard 2.1] F --> M[Shard 2.2]
This allows for:
- Geographic distribution (regulatory compliance)
- Fault isolation (regional failures don’t affect global system)
- Flexible scaling (different regions can scale independently)
Conclusion
Database sharding is a powerful technique that enables horizontal scaling beyond the limits of a single server. But with great power comes great complexity. Successful sharding requires careful planning, the right shard key selection, and a deep understanding of your application’s access patterns.
Remember these key takeaways:
- Sharding is a last resort – Exhaust vertical scaling, caching, and query optimization first
- Shard key selection is critical – It determines your system’s performance characteristics and limitations
- Plan for operations from day one – Monitoring, backups, and migrations become complex in sharded systems
- Design around sharding limitations – Avoid cross-shard transactions and minimize scatter-gather queries
- Test failure scenarios – Shard failures will happen; make sure your system degrades gracefully
In interviews, demonstrating that you understand both the power and the pitfalls of sharding shows maturity as an engineer. Don’t just memorize sharding strategies—understand why each exists and when to apply them. Show that you can balance technical possibilities with operational realities.
The best sharding strategy is the one that aligns with your specific access patterns, growth trajectory, and operational capabilities. There’s no one-size-fits-all solution, and the ability to analyze trade-offs and make reasoned decisions is what separates senior engineers from the rest.
Now you’re ready to tackle sharding questions with confidence, whether you’re designing the next Instagram or optimizing a growing SaaS platform. Remember: great systems aren’t built by following recipes—they’re built by understanding principles and applying them thoughtfully to specific problems.