# Build a URL Shortener (Python)

> Build a working URL shortener from scratch in Python - the data model, short-code generation, and lookup - running right in your browser, no setup.


---

# Build a URL Shortener (Python)

You've pasted one of those monstrous URLs into a chat before - the kind with forty characters of tracking junk after the actual address. Then someone sends you a tidy little `short.ly/aZ4` instead, and it lands you at the same place. That tidy link is a URL shortener at work, and you're about to build one.

This is a build-along. By the last phase you'll have a small Python program that takes any long URL, hands you back a short code, and resolves that code back to the original - the same core trick TinyURL and Bitly run on, minus the servers and the marketing.

## What you'll build

A URL shortener with three moving parts:

- A **store** that remembers which short code points to which long URL.
- A **code generator** that turns a counter into a compact, URL-safe string like `b` or `Cx`.
- Two functions - `shorten()` and `resolve()` - that tie it together: give it a long URL, get a short code; give it the code, get the URL back.

We'll finish with a tiny command loop so you can type URLs at it and watch it answer, plus a clear list of where to take it next.

## The stack

Plain Python, standard library only. No frameworks, no database, no `pip install`. The whole thing fits in one file and lives in memory while it runs. That's deliberate - the point is to see the mechanism naked, with nothing hiding it.

## This runs in your browser

Every code block in this project has a **Run** button. You don't install anything, you don't open a terminal - you read a few lines, press Run, and see the output right there on the page. Each block is self-contained, so you can run them in any order and re-run them as much as you like.

When you're ready to take it off the page and onto your own machine, the last phase shows you exactly how.

## Rough time

About 45 minutes to an hour if you run every block and read along. Less if you're quick; more if you stop to poke at the code, which I'd encourage.

## What you'll learn

- How a lookup table (a dictionary) becomes the heart of a real service.
- **Base62 encoding** - how to squeeze a plain number into a short string of letters and digits, and why that's how short codes stay short.
- Why a sequential counter beats random codes for a first version.
- How to handle the awkward cases: an unknown code, the same URL submitted twice.
- How to wrap working logic in a small interface people can actually use.

## How the phases fit together

```mermaid
graph LR
  A[Phase 1: data model] --> B[Phase 2: short codes]
  B --> C[Phase 3: shorten + resolve]
  C --> D[Phase 4: CLI + next steps]
```

Each phase ends with a working piece. By the end they're one program. Let's start with the simplest version of the idea that still earns the name.


---

# The Plan and the Data Model

Before we write a generator or a CLI, let's get crisp about what this thing is. Strip away the branding and a URL shortener does exactly one job:

> It remembers that a **short code** stands for a **long URL**, so it can give you the long one back later.

That's it. `aZ4` means `https://example.com/some/very/long/path?ref=newsletter`. When someone visits `short.ly/aZ4`, the service looks up `aZ4`, finds the long URL, and sends them there. The whole product is a memory with two operations: *store this pair*, and *fetch the URL for this code*.

## A map, not a math problem

People sometimes assume a shortener does something clever to the URL - compresses it, hashes it, encrypts it. It doesn't have to. The long URL isn't squeezed into the short code; it's **filed under** the short code.

Think of a coat check. You hand over your coat, you get a numbered ticket. The number doesn't contain your coat - it's a label pointing at a hook where the coat hangs. The short code is the ticket. The long URL is the coat.

```mermaid
graph LR
  T["ticket: aZ4"] --> H["hook: https://example.com/..."]
```

That framing tells us the data structure immediately. We need something that maps a key (the code) to a value (the URL) and lets us look the value up fast. In Python, that's a dictionary.

## The store

A Python dictionary is a set of key-to-value pairs with instant lookup. We'll use the short code as the key and the long URL as the value:

```python
store = {}
store["aZ4"] = "https://example.com/some/very/long/path"
```

Now `store["aZ4"]` hands back the long URL. That single dictionary is the heart of the entire service. Everything we build after this - the code generator, the two functions, the CLI - exists to feed and query this one dictionary.

Why a dictionary and not, say, a list? Because lookup needs to be by code, not by position. With a list you'd scan every entry asking "is this the one?" With a dictionary you ask for the key and get the value directly, no matter how many entries you've stored. For a service that might hold millions of links, that difference is the whole game.

## Store one, fetch one

Let's make it real. The block below creates the store, files one URL under a code, and reads it back. Press **Run** and watch the long URL come out the other side.

```python runnable
# our entire "database": short code -> long URL
store = {}

# file a long URL under a short code
code = "aZ4"
long_url = "https://example.com/some/very/long/path?ref=newsletter"
store[code] = long_url

# later: someone visits short.ly/aZ4, we look it up
visited = "aZ4"
print("Looking up code:", visited)
print("Sends you to:   ", store[visited])

# proof it's just a dictionary
print("The whole store:", store)
```

Run that and you'll see the lookup resolve. The output shows the code, the URL it points to, and the raw dictionary underneath - three lines that, between them, contain the entire idea of the product.

## What happens if the code doesn't exist?

There's a sharp edge here. If you ask the dictionary for a code it's never seen, indexing it with `store["nope"]` raises a `KeyError` and your program crashes. A real shortener gets garbage requests constantly - typos, expired links, people guessing codes - so it can't crash on every miss.

The standard-library answer is `dict.get()`, which returns a fallback instead of exploding.

Before you run it, guess what prints for the code we've never stored. Then check.

```python runnable
store = {"aZ4": "https://example.com/some/very/long/path"}

# a code we know about
print("aZ4 ->", store.get("aZ4", "NOT FOUND"))

# a code we've never stored - no crash, just the fallback
print("zzz ->", store.get("zzz", "NOT FOUND"))
```

`store.get("zzz", "NOT FOUND")` checks for the key and, finding nothing, returns the fallback string instead of raising. That `get` pattern is how `resolve()` will stay calm when someone hands it a code that doesn't exist - we'll lean on it again in Phase 3.

## Where we are

You now have the data model: a dictionary that maps codes to URLs, with a safe way to read from it. That's a working store. What it can't do yet is *invent* the short codes - right now we typed `aZ4` by hand. A real shortener generates a fresh, unused code for every new URL, automatically.

That's the next piece. In Phase 2 we'll build a generator that turns a plain counter into compact codes like `a`, `b`, `c`, … `Z`, `ba`, and so on - and I'll show you why a counter is a better starting point than reaching for random strings.


---

# Making Short Codes

Last phase we typed `aZ4` by hand. That doesn't scale - a shortener has to mint a fresh code for every URL on its own, and every code has to be different from every other one. This phase builds the generator.

The trick is to count. We keep a number that goes up by one every time we add a URL: the first link is 1, the second is 2, the third is 3. A counter never repeats, so codes built from it never collide. The only problem is that `1000000` is a long, ugly code. We want something short.

## From a number to a short string

Here's the insight. The number `1000000` looks long because we're writing it with only ten symbols - the digits `0` through `9`. Give yourself more symbols per position and the same value gets shorter.

You already know this from hexadecimal: programmers write `255` as `ff` because base 16 packs more value into each character. We're going to push it further. Use **62** symbols - the digits `0-9`, the lowercase `a-z`, and the uppercase `A-Z` - and numbers collapse into very short strings. That's **base62**, and it's why short codes are short.

The reason for exactly these 62 characters: they're the letters and digits that are safe and unambiguous in a URL. No spaces, no slashes, no punctuation that a browser might mangle.

| Number | Base 10 | Base 16 (hex) | Base 62 |
|-------:|:--------|:--------------|:--------|
| 9      | `9`     | `9`           | `9`     |
| 61     | `61`    | `3d`          | `Z`     |
| 1000   | `1000`  | `3e8`        | `g8`    |
| 1000000| `1000000`| `f4240`     | `4c92`  |

A million links and the code is still four characters. That's the payoff.

## How base62 conversion works

We need a function, `encode(number)`, that turns a plain integer into a base62 string using the `ALPHABET` above. A few fixed points to aim for: `encode(0)` is `"0"`, `encode(61)` is `"Z"` (the last single-character code), and `encode(62)` is `"10"` (the first that needs two).

**Your turn.** This function is the point of the phase, so have a go before you read on. Fill it in and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)  # 62

def encode(number):
    # Turn `number` into a base62 string using ALPHABET.
    #   encode(0) is ALPHABET[0]        -> "0"
    #   encode(61) is "Z"               -> the last single-character code
    #   encode(62) is "10"              -> the first that needs two characters
    # Bigger numbers use more characters, most significant first.
    pass


# --- checks: fix your function until this prints "All good." ---
assert encode(0) == "0", f"encode(0) should be '0', got {encode(0)!r}"
assert encode(9) == "9", f"encode(9) should be '9', got {encode(9)!r}"
assert encode(61) == "Z", f"encode(61) should be 'Z', got {encode(61)!r}"
assert encode(62) == "10", f"encode(62) should be '10', got {encode(62)!r}"
assert encode(1000000) == "4c92", f"encode(1000000) should be '4c92', got {encode(1000000)!r}"
print("All good.")
```

Stuck? Try dividing the number by 62 and looking at what's left over - then think about what to do with what's left.

### One way to write it

Converting a number to base62 is repeated division. You divide by 62, the remainder picks one character, then you divide the quotient by 62 again, and so on until there's nothing left. Each remainder is an index into our 62-character alphabet.

```mermaid
graph LR
  N["number"] --> D["divide by 62"]
  D --> R["remainder -> a char"]
  D --> Q["quotient"]
  Q --> D
```

The remainders come out in reverse order - least significant first - so we build the string and flip it at the end. Here it is in code:

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)  # 62

def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

# turn the first several counter values into codes
for n in [0, 1, 2, 10, 61, 62, 1000, 1000000]:
    print(f"{n:>8}  ->  {encode(n)}")
```

`divmod(number, BASE)` does the division and grabs the remainder in one step - it returns both the quotient and the remainder together. Run the block and you'll see `0 -> 0`, `61 -> Z` (the last single character), then `62 -> 10` (the first that needs two), all the way up to a million landing at four characters.

## Wiring the counter to the generator

The encoder turns a number into a code. The counter supplies the numbers. Put them together and you have an automatic code factory: bump the counter, encode it, hand back the code.

Guess which five codes come out before you run it.

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)

def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

# the counter starts at zero and climbs
counter = 0
print("Minting codes for five new links:")
for _ in range(5):
    code = encode(counter)
    print("  link", counter + 1, "gets code", repr(code))
    counter += 1

print("Counter is now at:", counter)
```

Every call gives a code that no earlier call produced, because the counter never hands out the same number twice. No bookkeeping, no checking "is this code taken?" - uniqueness is free, baked into the counting.

## Why not random codes?

It's tempting to skip the counter and generate random strings instead - pick six random characters and call it a code. For a weekend build, the counter is the better choice, and here's why.

A random generator can produce the same code twice. The chance is small, but "small" isn't "never," and a collision means one link silently overwrites another - a real bug that's miserable to track down. To use random codes safely you'd have to generate one, check the store to see if it's already taken, and retry if so. That's more code and more failure modes than counting.

The counter sidesteps all of it. Sequential numbers are unique by construction, so there's nothing to check and nothing to retry.

Random codes do have a genuine upside: they're unguessable. With sequential codes, anyone who has `b` can try `c` and `d` and walk through your links. If that matters - private links, anything sensitive - you'd add randomness later. We'll list that under "where to take it" in the final phase. For now, counting gives us correct, unique, short codes with the least code, which is exactly what a first version wants.

## Where we are

You've got a generator that turns counter values into short base62 codes, guaranteed unique. Phase 1 gave you a store; this phase gives you the codes to fill it with. In Phase 3 we connect them: `shorten()` will take a long URL, mint the next code, file the pair, and return the code - and `resolve()` will turn a code back into its URL, handling the misses safely with the `get` trick from Phase 1.


---

# Store and Resolve

You've got the two halves now: a dictionary store (Phase 1) and a base62 code generator (Phase 2). This phase joins them into the two functions a shortener lives or dies by - `shorten()` and `resolve()` - and deals with the messy real-world cases that a naive version gets wrong.

## The two functions

`shorten(long_url)` takes a long URL, mints the next code, files the pair, and returns the code.

`resolve(code)` takes a code and returns the long URL it points to - or tells you it doesn't know that code, without crashing.

Here's the first real version. Guess the three codes that come back, then run it end to end:

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)

def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

# state: the store, plus the counter that feeds the generator
store = {}
counter = 0

def shorten(long_url):
    global counter
    code = encode(counter)
    store[code] = long_url
    counter += 1
    return code

def resolve(code):
    return store.get(code, None)   # None instead of a crash on a miss

# take three URLs for a spin
a = shorten("https://example.com/articles/the-long-one")
b = shorten("https://docs.python.org/3/library/index.html")
c = shorten("https://example.com/pricing")

print("Codes minted:", a, b, c)
print("Resolve", a, "->", resolve(a))
print("Resolve", b, "->", resolve(b))
print("Resolve", c, "->", resolve(c))
```

Run it. Three URLs go in, three short codes come back (`0`, `1`, `2`), and each code resolves to the right URL. That's a working shortener. The `global counter` line lets `shorten()` advance the shared counter that lives outside the function - without it, Python would treat `counter` as a brand-new local and the codes would never move past `0`.

## Edge case one: an unknown code

Someone will hand you a code you never minted - a typo, a guess, a link you've since dropped. `resolve()` already handles it: `store.get(code, None)` returns `None` on a miss instead of raising `KeyError`. But returning `None` and *acting* on it are different things. The caller needs to notice the miss and say something useful.

```python runnable
store = {"0": "https://example.com/real-link"}

def resolve(code):
    return store.get(code, None)

for code in ["0", "zzz", "99"]:
    url = resolve(code)
    if url is None:
        print(f"{code!r}: unknown code - no such link")
    else:
        print(f"{code!r}: -> {url}")
```

Run it. The real code resolves; the two bogus ones report "unknown code" and the program keeps running. That `if url is None` check is the difference between a service that shrugs off bad input and one that 500s on it.

## Edge case two: the same URL twice

Here's the one people miss. Submit the same long URL twice and the naive version mints two different codes for it:

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)
def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

store, counter = {}, 0
def shorten(long_url):
    global counter
    code = encode(counter); store[code] = long_url; counter += 1
    return code

first  = shorten("https://example.com/pricing")
second = shorten("https://example.com/pricing")   # exact same URL
print("First time: ", first)
print("Second time:", second)
print("Two codes for one URL?", first != second)
```

Run it and you'll see two different codes - `0` and `1` - for the identical URL. Whether that's a bug depends on what you want. It's harmless (both codes resolve correctly), but it wastes codes and means you can't tell a user "you already shortened this." Most real shorteners return the *existing* code when they recognize a URL.

**Your turn.** Fix `shorten()` so a repeated URL returns the same code instead of minting a new one. This is the point of the phase, so have a real go before you read on. My version is in the next block whenever you want it.

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)
def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

code_to_url = {}
url_to_code = {}
counter = 0

def shorten(long_url):
    # Mint a code for `long_url` and remember it both ways.
    #   - If `long_url` has already been shortened, return the SAME
    #     code again instead of minting a new one.
    #   - Otherwise: encode the counter, store code -> url and url -> code,
    #     advance the counter, and return the new code.
    global counter
    pass

def resolve(code):
    return code_to_url.get(code, None)


# --- checks: fix shorten() until this prints "All good." ---
first = shorten("https://example.com/pricing")
assert first == "0", f"first code should be '0', got {first!r}"

second = shorten("https://example.com/pricing")
assert second == first, f"shortening the same URL twice should return the same code, got {second!r} vs {first!r}"

other = shorten("https://example.com/about")
assert other == "1", f"a genuinely new URL should get the next code, got {other!r}"

assert resolve(first) == "https://example.com/pricing", f"resolve should find the URL back, got {resolve(first)!r}"
print("All good.")
```

Stuck? You need a second lookup - URL back to code - so you can check "have I seen this URL before?" before minting anything.

### One way to write it

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)
def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

code_to_url = {}   # code -> url   (for resolve)
url_to_code = {}   # url  -> code  (for dedup)
counter = 0

def shorten(long_url):
    global counter
    if long_url in url_to_code:        # seen it? hand back the old code
        return url_to_code[long_url]
    code = encode(counter)
    code_to_url[code] = long_url
    url_to_code[long_url] = code
    counter += 1
    return code

def resolve(code):
    return code_to_url.get(code, None)

first  = shorten("https://example.com/pricing")
second = shorten("https://example.com/pricing")   # same URL again
other  = shorten("https://example.com/about")

print("First time: ", first)
print("Second time:", second, "(same as first?", first == second, ")")
print("Different URL:", other)
print("Resolve", first, "->", resolve(first))
```

Run it. The repeated URL now comes back with the *same* code both times, while a genuinely new URL gets a fresh one. The `if long_url in url_to_code` check is the whole fix - one membership test before minting.

## Where we are

`shorten()` and `resolve()` work together, unknown codes are handled without crashing, and the same URL twice returns one code. That's a complete shortener - the logic is done. What it lacks is a way for a person to *use* it without editing the source. In Phase 4 we wrap it in a small command loop so you can type URLs at it and get codes back, then map out where to take the project once it's off the page.


---

# A Tiny CLI, and Where to Take It

The logic is finished. `shorten()` and `resolve()` do everything a URL shortener does. But right now the only way to use it is to edit the source code, which is no way to hand it to a friend. This phase wraps it in a small command interface - type a command, get an answer - and then lays out the paths from this toy to something real.

## A command loop

A command-line tool is a loop: read what the user typed, figure out which command it is, do the thing, print the result, repeat. We'll support two commands:

- `shorten <url>` - mint a code for a URL and print it.
- `get <code>` - resolve a code back to its URL.

On your own machine you'd read commands with Python's `input()` in a `while True:` loop. Here in the browser there's no keyboard prompt, so we'll feed the loop a fixed list of commands and process them the same way `input()` would. The command-parsing logic is identical - only the source of the lines changes.

**Your turn.** `handle()` is the piece that ties everything together, so it's the point of this phase. Fill it in and hit Run: the checks underneath tell you whether it works. My version - plus the full command transcript - is in the next block whenever you want it.

```python runnable
import io, contextlib

ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)

def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

code_to_url = {}
url_to_code = {}
counter = 0

def shorten(long_url):
    global counter
    if long_url in url_to_code:
        return url_to_code[long_url]
    code = encode(counter)
    code_to_url[code] = long_url
    url_to_code[long_url] = code
    counter += 1
    return code

def resolve(code):
    return code_to_url.get(code, None)

def handle(line):
    # Parse `line` into a command and act on it.
    #   "shorten <url>" -> print "  shortened -> <code>"
    #   "get <code>"    -> print "  <code> -> <url>"
    #                      or   "  <code> -> unknown code" if resolve() finds nothing
    #   anything else   -> print "  ? unknown command: <line>"
    # An empty line does nothing.
    pass


# --- checks: fix handle() until this prints "All good." ---
def capture(line):
    buf = io.StringIO()
    with contextlib.redirect_stdout(buf):
        handle(line)
    return buf.getvalue()

out = capture("shorten https://example.com/pricing")
assert out == "  shortened -> 0\n", f"got: {out!r}"

out = capture("shorten https://example.com/pricing")
assert out == "  shortened -> 0\n", f"repeating a URL should return the same code, got: {out!r}"

out = capture("get 0")
assert out == "  0 -> https://example.com/pricing\n", f"got: {out!r}"

out = capture("get zzz")
assert out == "  zzz -> unknown code\n", f"got: {out!r}"

out = capture("frobnicate now")
assert "unknown command" in out, f"got: {out!r}"

print("All good.")
```

Stuck? `line.split(maxsplit=1)` splits the command word from everything after it in one step - `"shorten https://x".split(maxsplit=1)` gives `["shorten", "https://x"]`.

### One way to write it

```python runnable
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)

def encode(number):
    if number == 0:
        return ALPHABET[0]
    chars = []
    while number > 0:
        number, remainder = divmod(number, BASE)
        chars.append(ALPHABET[remainder])
    return "".join(reversed(chars))

code_to_url = {}
url_to_code = {}
counter = 0

def shorten(long_url):
    global counter
    if long_url in url_to_code:
        return url_to_code[long_url]
    code = encode(counter)
    code_to_url[code] = long_url
    url_to_code[long_url] = code
    counter += 1
    return code

def resolve(code):
    return code_to_url.get(code, None)

def handle(line):
    parts = line.split(maxsplit=1)         # ["shorten", "https://..."]
    if not parts:
        return
    command = parts[0]
    if command == "shorten" and len(parts) == 2:
        print(f"  shortened -> {shorten(parts[1])}")
    elif command == "get" and len(parts) == 2:
        url = resolve(parts[1])
        print(f"  {parts[1]} -> {url if url else 'unknown code'}")
    else:
        print(f"  ? unknown command: {line!r}")

# on your machine this would be: while True: handle(input("> "))
# here we feed it a script of commands instead
session = [
    "shorten https://example.com/pricing",
    "shorten https://docs.python.org/3/",
    "shorten https://example.com/pricing",   # repeat -> same code
    "get 0",
    "get 1",
    "get zzz",                               # unknown -> handled
    "frobnicate now",                        # garbage -> handled
]

for line in session:
    print(f"> {line}")
    handle(line)
```

Run it and read the transcript. Each `>` line is a typed command; the indented line under it is the response. The repeated URL gets the same code, an unknown code reports cleanly, and even nonsense input gets a tidy "unknown command" instead of a crash. `line.split(maxsplit=1)` is what splits `shorten https://...` into the command and everything after it, keeping the URL whole even though URLs can contain spaces in odd cases.

That's a complete, usable URL shortener in well under 50 lines. You built it from a dictionary, a counter, and two functions.

## Taking it to your own machine

To run this for real, copy the code above into a file called `shortener.py`, and swap the scripted `session` loop for a live one:

```python
# replace the `session` block with this:
while True:
    line = input("> ")
    if line.strip() == "quit":
        break
    handle(line)
```

Then run it from a terminal:

```bash
python shortener.py
```

Now it prompts you with `>` and waits for commands until you type `quit`. Same logic, real keyboard.

## Where to take it

Here are the next steps in roughly increasing order of effort. Each one is a real, satisfying upgrade.

| Upgrade | What it adds | Where to start |
|---|---|---|
| **File persistence** | Codes survive restarts instead of vanishing | Save the two dicts to a JSON file on each change, load them on startup |
| **Custom aliases** | `shorten <url> <alias>` so users pick `/launch` | Add an optional third part to the command; reject it if the alias is taken |
| **A real web server** | Actual `http://localhost/aZ4` links that redirect | The stdlib `http.server`, or step up to Flask/FastAPI |
| **Unguessable codes** | Codes no one can walk through sequentially | Mix randomness into the counter, or hash + base62 the count |
| **Click counts** | Track how many times each link is followed | A third dict, `code -> count`, bumped in `resolve()` |

**File persistence** is the most rewarding first step - right now everything evaporates when the program stops. The standard-library `json` module turns your two dictionaries into a file and back:

```python
import json

def save(path="links.json"):
    with open(path, "w") as f:
        json.dump({"code_to_url": code_to_url, "url_to_code": url_to_code,
                   "counter": counter}, f)

def load(path="links.json"):
    global code_to_url, url_to_code, counter
    with open(path) as f:
        data = json.load(f)
    code_to_url = data["code_to_url"]
    url_to_code = data["url_to_code"]
    counter = data["counter"]
```

Call `load()` at startup (wrapped in a `try`/`except FileNotFoundError` for the first run) and `save()` after each `shorten()`. Now your links persist across restarts - the leap from a toy to something you'd actually keep.

**A real web server** is the upgrade that makes it feel like the real product. With `http.server` from the standard library you can answer `GET /aZ4` by looking up the code and returning an HTTP redirect to the long URL - at which point clicking your short link in a browser genuinely sends you somewhere. That's the moment it stops being a script and starts being a service.

## What you built

You started with a single sentence - a map from short code to long URL - and ended with a working program: a dictionary store, a base62 generator fed by a counter, `shorten()` and `resolve()` that handle the awkward cases, and a command loop to drive it. The same architecture, scaled up with a database and a web server, is what runs behind every short link you've ever clicked.

The mechanism was never the hard part. Now you've seen it bare, and you know exactly which dials to turn to take it further.
