Free lesson · System Design Interviews
Step 5: Database Design
This is where you show the interviewer you've thought beyond boxes and arrows. The database schema, the indexing strategy, and the short code generation algorithm are where the real engineering happens in a URL shortener. These are the decisions that determine whether the system works at scale or falls apart under load.
Let me walk through each decision the way I'd discuss it in an interview - starting with the schema, then the short code generation (which is the single most interesting design challenge in this system), and finally the sharding strategy for when a single database isn't enough.
The Schema
CREATE TABLE url_mappings (
id BIGSERIAL PRIMARY KEY,
short_code VARCHAR(20) NOT NULL UNIQUE,
long_url TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
click_count BIGINT NOT NULL DEFAULT 0,
creator_ip INET,
-- Indexes
INDEX idx_short_code (short_code), -- Primary lookup for redirects
INDEX idx_expires_at (expires_at) -- For cleanup job
);
Why These Columns
| Column | Purpose | Design Note |
|---|---|---|
id |
Internal primary key | BIGSERIAL for auto-increment. Never exposed to users. |
short_code |
The short URL identifier | UNIQUE constraint prevents duplicates. VARCHAR(20) supports custom codes up to 20 chars. |
long_url |
The destination URL | TEXT type - URLs can be up to 2,048 characters. |
created_at |
When the mapping was created | Used for analytics and auditing. |
expires_at |
When the URL expires | Enables TTL-based expiration. Indexed for the cleanup job. |
click_count |
Number of clicks | Updated asynchronously by the analytics worker. |
creator_ip |
IP of the creator | For abuse detection and rate limiting forensics. |
Why short_code Is NOT the Primary Key
You might wonder: why not use short_code as the primary key instead of a separate id column?
| Approach | Pro | Con |
|---|---|---|
short_code as PK |
One less column, natural key | Variable-length string PK → larger indexes, slower joins, can't change codes |
id as PK, short_code as UNIQUE |
Compact integer PK → efficient indexes | One extra column (8 bytes) |
Integer primary keys produce smaller, faster B-tree indexes. The short_code UNIQUE constraint creates a separate index that handles lookups. At 55 TB over 5 years, index efficiency matters.
Tip: Mention this in interviews if the interviewer asks about schema choices. "I'm using an integer primary key instead of the short code because integer B-tree indexes are more compact and faster than variable-length string indexes. The short_code column has its own UNIQUE index for the lookup path. At our scale of billions of rows, the index efficiency difference is measurable."
Short Code Generation - The Core Design Challenge
This is the question within the question. How do you generate 7-character codes that are unique, unpredictable (for auto-generated codes), and fast to generate at 1,000 per second?
There are three main approaches. Each has different trade-offs.
Approach 1: Hash + Truncate
Hash the long URL (or the long URL + a salt/timestamp) and take the first 7 characters as the short code.
import hashlib
import base64
def generate_short_code_hash(long_url):
# Hash the URL with a salt for uniqueness
salt = str(time.time()) + str(random.random())
hash_input = long_url + salt
# MD5 produces 128 bits → Base64 encode → take first 7 chars
hash_bytes = hashlib.md5(hash_input.encode()).digest()
base64_str = base64.urlsafe_b64encode(hash_bytes).decode()
# Replace non-URL-safe characters
short_code = base64_str[:7].replace('+', 'a').replace('/', 'b')
return short_code
Collision handling: Even with 3.5 trillion possible codes and only 182 billion URLs over 5 years (5% utilization), hash collisions are possible because hash functions don't distribute evenly into 7-character truncations. You must check the database for collisions and retry if one occurs.
def create_url_with_hash(long_url):
for attempt in range(5): # Max 5 collision retries
short_code = generate_short_code_hash(long_url)
try:
db.execute(
"INSERT INTO url_mappings (short_code, long_url, expires_at) "
"VALUES (%s, %s, NOW() + INTERVAL '5 years')",
short_code, long_url
)
return short_code # Success - no collision
except UniqueViolation:
continue # Collision - retry with different salt
raise Exception("Failed to generate unique code after 5 attempts")
flowchart TD
URL["Long URL + salt"] --> HASH["MD5/SHA256 hash"]
HASH --> TRUNCATE["Take first 7 chars<br/>(Base62 encoded)"]
TRUNCATE --> CHECK{"Exists in DB?"}
CHECK -->|"No"| INSERT["Insert into DB ✓"]
CHECK -->|"Yes (collision)"| RETRY["New salt, retry"]
RETRY --> HASH
Hash and truncate approach. The long URL plus a random salt is hashed using MD5 or SHA-256. The hash is Base62 encoded and truncated to 7 characters. The resulting code is checked against the database for uniqueness. If it's unique, it's inserted. If there's a collision, a new salt is generated and the process retries. At 5 percent code space utilization, collisions are rare but must be handled.
Approach 2: Counter + Base62 Conversion
Use a global auto-incrementing counter. Convert the counter value to Base62 to get the short code.
def int_to_base62(num):
"""Convert an integer to a Base62 string."""
chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
if num == 0:
return chars[0]
result = []
while num > 0:
result.append(chars[num % 62])
num //= 62
return ''.join(reversed(result))
# Counter: 1 → "1"
# Counter: 100000 → "q0U"
# Counter: 1000000000 → "15FTGg"
# Counter: 10000000000 → "aUKYOz"
Where does the counter live? Options:
| Counter Source | Pros | Cons |
|---|---|---|
| Database auto-increment | Simple, built-in | Single DB is a bottleneck for distributed servers |
| Redis INCR | Atomic, fast (100K ops/sec) | Redis crash loses counter state (mitigated by persistence) |
| ZooKeeper/etcd | Distributed consensus, reliable | Higher latency (~5-10ms per increment) |
| Snowflake-style ID generator | Distributed, no coordination | Produces 64-bit IDs → codes longer than 7 chars |
| Pre-allocated counter ranges | Distributed, no coordination per request | Complexity of range management |
The pre-allocated range approach is the best for interviews:
flowchart TB
subgraph RANGE_SERVER["Range Allocation Service"]
COUNTER["Global counter<br/>(in database or ZooKeeper)"]
end
subgraph SERVER1["Write Server 1"]
R1["Local range: 1,000,000 - 1,999,999<br/>Next: 1,000,042"]
end
subgraph SERVER2["Write Server 2"]
R2["Local range: 2,000,000 - 2,999,999<br/>Next: 2,000,017"]
end
subgraph SERVER3["Write Server 3"]
R3["Local range: 3,000,000 - 3,999,999<br/>Next: 3,000,103"]
end
COUNTER -->|"Allocate range<br/>of 1 million"| SERVER1
COUNTER -->|"Allocate range<br/>of 1 million"| SERVER2
COUNTER -->|"Allocate range<br/>of 1 million"| SERVER3
Pre-allocated counter ranges. A central range allocation service manages a global counter. Each write server requests a range of 1 million counter values. Server 1 gets the range 1 million to 1,999,999. Server 2 gets 2 million to 2,999,999. Server 3 gets 3 million to 3,999,999. Each server increments its local counter without any coordination - no database call, no lock, no network request per URL creation. When a server exhausts its range, it requests a new one. If a server crashes, the unused portion of its range is wasted - but that's a tiny fraction of the 3.5 trillion code space.
Why this works: Each server increments its local counter with zero coordination. No database call per URL creation. No distributed lock. The only coordination is when a server needs a new range (every 1 million URLs - roughly every 16 minutes at 1,000 QPS).
Interview: What to say: "For code generation, I'd use pre-allocated counter ranges. A central service assigns each write server a block of 1 million counter values. Each server increments its local counter and converts to Base62 - this is a pure in-memory operation with zero coordination. When the range is exhausted, the server requests a new block. The trade-off is that if a server crashes, the unused portion of its range is wasted - but losing a few thousand codes out of 3.5 trillion is insignificant."
Approach 3: Random Generation
Simply generate a random 7-character Base62 string and check for uniqueness.
import random
import string
def generate_random_code():
chars = string.ascii_letters + string.digits # a-z, A-Z, 0-9
return ''.join(random.choices(chars, k=7))
This is the simplest approach but requires a database uniqueness check for every creation. At 5% code space utilization, collision probability per attempt is ~5% - so roughly 1 in 20 attempts collides and needs retry. At higher utilization, collisions become more frequent.
Comparison of All Three Approaches
| Factor | Hash + Truncate | Counter + Base62 | Random |
|---|---|---|---|
| Uniqueness guarantee | No (need collision check) | Yes (counter never repeats) | No (need collision check) |
| DB call per creation | Yes (collision check) | No (local counter) | Yes (collision check) |
| Predictability | Low (hash is opaque) | High (sequential - next code is guessable) | Low (random) |
| Code distribution | Semi-random | Sequential | Fully random |
| Complexity | Medium | Medium (range management) | Low |
| Best for | Simple systems | High-throughput production | Prototypes |
Warning: Counter-based codes are sequential and guessable. If code
a3Xb9kexists, an attacker can guess thata3Xb9l(the next counter value in Base62) also exists. This enables enumeration attacks - an attacker can scan through codes and discover all URLs. Mitigate by shuffling the counter bits or by adding a random component after the Base62 conversion. Mention this security concern in your interview.
Handling Custom Short Codes
When a user requests a custom code like my-brand, the flow is different:
def create_custom_url(long_url, custom_code):
# 1. Validate the custom code
if len(custom_code) < 3 or len(custom_code) > 20:
raise ValidationError("Code must be 3-20 characters")
if not re.match(r'^[a-zA-Z0-9-]+