Skip to main content
Low-Level Design · lesson 9 of 18
Open the Low-Level Design page →
Low-Level Design intermediate Lesson 9 of 18

Also part of: System Design

Design a URL Shortener: Sequential Codes vs Real Hash Collisions

Two ways to shorten a URL — a base62 counter that never collides but leaks a pattern, and a hash that needs real collision handling.

By SkillByExample · Published

lld system-design python url-shortener

A URL shortener’s whole job is compressing a long string into a short one you can always map back — the interesting design decision is how you generate that short code.

Sequential ID, base62-encoded: never collides, always guessable

import string

ALPHABET = string.digits + string.ascii_lowercase + string.ascii_uppercase  # base62, 62 chars

def encode(n):
    if n == 0:
        return ALPHABET[0]
    chars = []
    base = len(ALPHABET)
    while n > 0:
        n, rem = divmod(n, base)
        chars.append(ALPHABET[rem])
    return "".join(reversed(chars))

class SequentialShortener:
    def __init__(self, start_id=1):
        self.next_id = start_id
        self.urls = {}

    def shorten(self, long_url):
        code = encode(self.next_id)
        self.urls[code] = long_url
        self.next_id += 1
        return code

shortener = SequentialShortener(start_id=60)
print(shortener.shorten("https://example.com/a"))
print(shortener.shorten("https://example.com/b"))
print(shortener.shorten("https://example.com/c"))
print(shortener.shorten("https://example.com/d"))
$ python sequential_shortener.py
Y
Z
10
11

Starting the counter at 60 (instead of 1) makes the carry visible immediately: 60 and 61 are still single base62 digits (Y, Z — the alphabet is digits, then lowercase, then uppercase, 62 symbols total), but 62 has to roll over into two digits, the same way 9 rolls over into 10 in decimal. Anyone who shortens two URLs a minute apart and gets back Y and Z has just learned the codes are sequential — incrementing either one finds the next real shortened URL.

Hash-based codes: unguessable, but collisions are mathematically guaranteed

import hashlib

def short_hash(url, salt=""):
    return hashlib.md5((url + salt).encode()).hexdigest()[:2]  # 2 hex chars = 256 possible codes

class HashShortener:
    def __init__(self):
        self.codes = {}  # code -> long_url

    def shorten(self, long_url):
        salt = ""
        attempt = 0
        while True:
            code = short_hash(long_url, salt)
            if code not in self.codes:
                self.codes[code] = long_url
                if attempt > 0:
                    print(f"  {long_url} -> {code} (needed {attempt} retry(s))")
                else:
                    print(f"  {long_url} -> {code}")
                return code
            if self.codes[code] == long_url:
                return code
            attempt += 1
            salt = str(attempt)
            print(f"  collision on {code!r} ({self.codes[code]!r} already has it) - retrying with salt {salt!r}")

shortener = HashShortener()
shortener.shorten("https://example.com/page-4")
shortener.shorten("https://example.com/page-11")
$ python hash_shortener.py
  https://example.com/page-4 -> 5c
  collision on '5c' ('https://example.com/page-4' already has it) - retrying with salt '1'
  https://example.com/page-11 -> 8c (needed 1 retry(s))

page-4 and page-11 are genuinely different URLs, but truncating MD5 to just 2 hex characters leaves only 256 possible codes — page-11 really does hash to the same 5c that page-4 already claimed. The fix isn’t to avoid collisions (with a small enough code space, you can’t); it’s to detect the collision and retry with a salted input until a free code turns up. A production system uses a longer code (6-7 base62 characters gives billions of values), which makes collisions rare rather than absent — the retry logic here is the same logic that code would still need, just triggered far less often.

Practice

1. Confirm the sequential shortener really is guessable: given only the code Z, predict the next code without calling shorten again.
import string
ALPHABET = string.digits + string.ascii_lowercase + string.ascii_uppercase

def encode(n):
    if n == 0:
        return ALPHABET[0]
    chars = []
    base = len(ALPHABET)
    while n > 0:
        n, rem = divmod(n, base)
        chars.append(ALPHABET[rem])
    return "".join(reversed(chars))

# Z is index 61 in ALPHABET (0-9, then a-z, then A-Z)
z_index = ALPHABET.index("Z")
print(f"Z's numeric ID: {z_index}")
print(f"Next code: {encode(z_index + 1)}")
$ python predict_next.py
Z's numeric ID: 61
Next code: 10

ALPHABET.index("Z") recovers the exact integer that produced Z, and the shortener’s next_id is always exactly one more than whatever the previous call returned — so anyone holding one valid code can compute the next one without ever touching the service’s database.

2. Widen the truncated hash to 3 hex characters (4096 possible codes) and confirm the same two URLs from the lesson no longer collide.
import hashlib

def short_hash(url, length=3):
    return hashlib.md5(url.encode()).hexdigest()[:length]

for url in ["https://example.com/page-4", "https://example.com/page-11"]:
    print(f"{url} -> {short_hash(url)}")
$ python wider_hash.py
https://example.com/page-4 -> 5c6
https://example.com/page-11 -> 5c3

With 3 hex characters instead of 2, page-4 and page-11 land on 5c6 and 5c3 — they still share the same first two characters (5c, which is exactly why they collided at length=2), but as full 3-character codes they’re different, so no collision this time. The collision in the main example wasn’t a special property of those two URLs; it was a property of how small (256 values) the truncated hash space was.

3. Find a different pair of URLs (from a range of "https://example.com/item-N") that collide in the 2-hex-character space, without hardcoding the answer.
import hashlib

def short_hash(url):
    return hashlib.md5(url.encode()).hexdigest()[:2]

seen = {}
for i in range(1, 500):
    url = f"https://example.com/item-{i}"
    code = short_hash(url)
    if code in seen:
        print(f"Collision: {seen[code]!r} and {url!r} both hash to {code!r}")
        break
    seen[code] = url
$ python find_another_collision.py
Collision: 'https://example.com/item-6' and 'https://example.com/item-15' both hash to '92'

A different URL pattern (item-N instead of page-N) collides on a completely different pair, at a different point — confirming the earlier collision wasn’t cherry-picked to be the only one; a 256-value code space collides quickly and repeatedly no matter which real strings feed it.

4. Shorten the same URL twice through HashShortener and confirm it returns the same code both times instead of treating it as a fresh collision.
import hashlib

def short_hash(url, salt=""):
    return hashlib.md5((url + salt).encode()).hexdigest()[:2]

class HashShortener:
    def __init__(self):
        self.codes = {}

    def shorten(self, long_url):
        salt = ""
        attempt = 0
        while True:
            code = short_hash(long_url, salt)
            if code not in self.codes:
                self.codes[code] = long_url
                return code
            if self.codes[code] == long_url:
                return code
            attempt += 1
            salt = str(attempt)

shortener = HashShortener()
first = shortener.shorten("https://example.com/page-4")
second = shortener.shorten("https://example.com/page-4")
print(f"first call: {first}, second call: {second}, same code: {first == second}")
$ python idempotent_shorten.py
first call: 5c, second call: 5c, same code: True

The if self.codes[code] == long_url: return code check is what makes shortening the same URL twice idempotent — without it, the second call would see its own code already taken by itself, treat that as a real collision, and hand back a different code for the identical URL.

Frequently Asked Questions

Why does the sequential approach never collide, but the hash-based one can?
The sequential approach hands out a strictly increasing ID and encodes it — two different URLs are always assigned two different IDs, so there's no way for their codes to match. The hash-based approach compresses a URL into a fixed-size digest, and a fixed-size digest has finitely many possible values, so two different inputs are mathematically guaranteed to collide eventually (the pigeonhole principle) — it's only a question of how soon.
Why is a guessable short code actually a problem?
If code N+1 always follows code N, anyone can enumerate every shortened URL ever created just by incrementing — including ones the owner never intended to be discoverable, like an unlisted document link shared only with one person. This is a real, documented failure mode, not a theoretical one.
Why truncate the hash to just 2 hex characters in this lesson instead of using the full MD5 digest?
A full 128-bit MD5 digest collides so rarely that this lesson couldn't demonstrate a real collision without hashing billions of URLs. Truncating to 2 hex characters (256 possible codes) makes a real collision appear after only a handful of inputs, so the retry logic actually has something to do — a real production system would use a longer code and see collisions far less often, but the retry mechanism is identical.
Which approach do real URL shorteners use?
Bit.ly and similar services generally use a sequential ID (often from a distributed ID generator, not a single database counter) encoded in base62 or base58 — it guarantees no collisions and no retry loop under load. Hash-based codes are more common when the code must be derivable from the content alone (e.g. a content-addressed cache key), not for public URL shortening.