Build a URL Shortener API

Design and build a URL shortener with hashing, collision handling, and click tracking.

Introduction

30 Second Summary

Every time you click a short link on social media, a tiny service is doing real work behind the scenes. It maps a compact code to a full URL, redirects your browser, and often tracks that you clicked.

In this project, you will build a URL shortener API from scratch. You will design the database schema, choose a hashing strategy, handle collisions, add browser redirects, and track click analytics.

What You'll Build

You will paste a long URL into your running API, get back a short code, and open that code in your browser to watch it redirect to the original page.

By the end of this project, you'll have:

  • A working REST API where you POST a long URL and instantly get back a short code you can share with anyone.
  • Browser-testable HTTP redirects where visiting your short link takes you to the original page, with click counts incrementing each time someone uses it.
  • A real hash collision you trigger and fix live, proving you understand the tradeoffs that come up in system design interviews.
  • Secret Mission: Build a dedicated analytics endpoint that returns full stats for any short code.

Are there any prerequisites?

You should have basic Python knowledge (variables, functions, imports). You also need MySQL installed on your machine with MySQL Workbench ready to go.

If you have not installed MySQL yet, follow the official MySQL installer for your operating system before starting this project.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

A URL shortener is one of the most common system design interview questions because it looks simple on the surface but hides real engineering challenges underneath: hashing, collision handling, redirect semantics, and data tracking.

Set Up Your Project and Database

Before you can shorten a single URL, you need a foundation in place. A Python environment with the right packages, a MySQL database ready to store URLs, and a web framework serving requests.

In this step, you'll install FastAPI, SQLAlchemy, and PyMySQL, wire up a MySQL database, and confirm the whole stack connects with a health-check endpoint.

In this step, get ready to:
  • Install FastAPI, SQLAlchemy, and PyMySQL in a virtual environment.
  • Create a MySQL database and connect to it with SQLAlchemy.
  • Run the server and confirm the database connection works.
Create your project folder and install dependencies

Every project starts with a clean folder and an isolated Python environment. A virtual environment keeps your project's packages separate from your system Python.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
  • Create a Python virtual environment by running this command:

macOS

python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

Windows

python -m venv venv
  • Activate the virtual environment by running:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activate command (not just the venv creation command). On Windows, if you see a permissions error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell first, then try activation again.

Help me activate my virtual environment

  • Install the project dependencies by running:
pip install "fastapi[standard]" sqlalchemy pymysql

You should see download and install messages ending with Successfully installed followed by a list of package names.

What did you just install?

  • fastapi[standard] gives you the web framework plus uvicorn (the server that runs your API).
  • sqlalchemy is the database toolkit that lets you talk to MySQL from Python.
  • pymysql is the driver that SQLAlchemy uses to connect to MySQL.

Seeing an install error?

Make sure your virtual environment is active (you see (venv) in your prompt). If you see a permissions error, do not use sudo. Deactivate and recreate the venv instead.

Help me fix my pip install error

  • Open VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the url-shortener folder, and click Open.

You should see the url-shortener folder name in the VS Code Explorer sidebar with the venv folder listed inside it.

  • Create a new file by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS, Ctrl+N on Windows).
  • Name the file main.py and paste the following code into it:
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text

from database import engine, get_db, Base

# Create all database tables registered with Base on startup
Base.metadata.create_all(bind=engine)

app = FastAPI(title="URL Shortener")


@app.get("/health")
def health_check(db: Session = Depends(get_db)):
    # Run a simple query to verify the database is reachable
    db.execute(text("SELECT 1"))
    return {"status": "healthy", "database": "connected"}

What does this code do?

  • Base.metadata.create_all(bind=engine) tells SQLAlchemy to create any database tables registered with Base when the app starts. You have no models yet, so nothing is created for now.
  • The /health endpoint receives a database session via Depends(get_db). FastAPI's dependency injection calls get_db() automatically and passes the session in.
  • db.execute(text("SELECT 1")) fires a trivial query. If it succeeds, the database is connected. If not, the endpoint crashes with an error you can debug.
  • Save main.py (Cmd+S on macOS, Ctrl+S on Windows).

You should see main.py listed in the Explorer sidebar. It imports from a database module that does not exist yet. You will create that next.

Create the MySQL database and connect to it

Your API needs somewhere to store shortened URLs. You will create a MySQL database and then write the Python file that connects to it.

  • Open MySQL Workbench. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type MySQL Workbench and press Enter to open it.
  • Double-click your local connection (usually Local instance 3306) to connect to your MySQL server.
  • In the query editor tab, type the following SQL command and click the lightning bolt icon (or press Cmd+Enter on macOS, Ctrl+Enter on Windows) to run it:
CREATE DATABASE url_shortener;

In the Navigator panel on the left, click the refresh icon. You should see url_shortener appear in the SCHEMAS list.

Database already exists?

If you see an error saying the database already exists, that is fine. It means you ran this command before. You can continue to the next instruction.

Help me create my MySQL database

  • Switch back to VS Code.
  • Create a new file called database.py in the url-shortener folder and paste the following code:
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase

# Connection string: dialect+driver://user:password@host/database
DATABASE_URL = "mysql+pymysql://root:your-password-here@localhost/url_shortener?charset=utf8mb4"

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


# Base class that all database models will inherit from
class Base(DeclarativeBase):
    pass


# Provides a fresh database session for each API request
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

What does this code do?

  • DATABASE_URL tells SQLAlchemy how to reach your MySQL server. The format is mysql+pymysql://user:password@host/database.
  • engine manages a pool of database connections so your app does not open a new connection for every single request.
  • SessionLocal is a factory that produces sessions. Each session is one transaction with the database.
  • get_db() is a FastAPI dependency. It opens a session, hands it to the endpoint, and guarantees the session closes when the request finishes.
  • Replace your-password-here in the DATABASE_URL string with your actual MySQL root password.
  • Save database.py (Cmd+S on macOS, Ctrl+S on Windows).

No password on your MySQL root?

If your MySQL root user has no password, change the connection string to mysql+pymysql://root:@localhost/url_shortener?charset=utf8mb4 (leave the password blank between the colon and the @).

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.py and database.py.

ⓧ I'd like to double check the full code

Here is the full database.py:

from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, DeclarativeBase

DATABASE_URL = "mysql+pymysql://root:your-password-here@localhost/url_shortener?charset=utf8mb4"

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    pass


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

And here is the full main.py:

from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text

from database import engine, get_db, Base

Base.metadata.create_all(bind=engine)

app = FastAPI(title="URL Shortener")


@app.get("/health")
def health_check(db: Session = Depends(get_db)):
    db.execute(text("SELECT 1"))
    return {"status": "healthy", "database": "connected"}
Run the server and verify everything connects

Both files are in place. Time to start the server and confirm your API can reach the database.

  • Switch back to your terminal (make sure you are still in the url-shortener folder with (venv) active).

Before you run this, what do you think you will see in the terminal if the database connection is configured correctly?

  • Start the FastAPI development server by running:
python -m uvicorn main:app --reload

You should see output including Uvicorn running on http://127.0.0.1:8000. The --reload flag means the server restarts automatically whenever you save a file.

Seeing a connection error on startup?

If you see OperationalError or Access denied, double-check the password in database.py matches your MySQL root password. Also confirm your MySQL server is running (open MySQL Workbench and check that you can connect).

Help me fix my database connection error

  • Open your browser and visit http://127.0.0.1:8000/docs.

You should see the FastAPI interactive documentation page titled URL Shortener. It lists your /health endpoint with a GET label.

  • Click the GET /health row to expand it.
  • Click Try it out.
  • Click Execute.

In the response body you should see:

{
  "status": "healthy",
  "database": "connected"
}

That response proves your FastAPI server is running, connected to MySQL, and successfully executing queries.

Getting a 500 Internal Server Error?

Check your terminal for the full error traceback. The most common causes are a wrong password in database.py or the url_shortener database not existing yet in MySQL.

Help me debug my 500 error

Your foundation is locked in. You have a running API server, a connected database, and proof that the whole stack works. Next up, you will build the actual URL shortening logic and get your first short code back.

Shorten a URL with Sequential Codes

Your URL shortener needs a way to turn long URLs into short codes. The fastest path to a working shortener is to use the database's auto-increment ID and convert it into a short string using Base62 encoding.

In this step, you'll define the database table that stores URLs, create the request and response schemas, and build an encoding function that turns integers into short alphanumeric strings. By the end, you'll POST a URL and get back a short code instantly.

In this step, get ready to:
  • Define the database model and auto-create the urls table.
  • Build the Base62 encoding function and /shorten endpoint.
  • Test the shortener with real URLs via the interactive docs.
Define the database model

Every shortened URL needs a home in the database. You need a table that stores the original URL, the short code, when it was created, and how many times it has been clicked.

  • Create a new file called models.py in your project folder and paste this code:
from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.sql import func

from database import Base


class URL(Base):
    __tablename__ = "urls"

    id = Column(Integer, primary_key=True, index=True)
    original_url = Column(Text, nullable=False)
    short_code = Column(String(10), unique=True, index=True, nullable=False)
    created_at = Column(DateTime, server_default=func.now())
    clicks = Column(Integer, default=0)

What does this code do?

This defines a SQLAlchemy model that maps to a urls table in your database.

  • id is an auto-incrementing primary key. Each new row gets the next number.
  • original_url stores the full URL the user submitted.
  • short_code is marked unique=True so no two URLs share the same code. It is also indexed for fast lookups.
  • clicks starts at 0 and will track how many times the short link is visited.
  • Save models.py.
  • In main.py, find this line:
from database import engine, get_db, Base
  • Add this import directly below it:
from models import URL
  • Save main.py. Check your terminal where the server is running.

The server restarts automatically. You should see no error messages. Because main.py already has Base.metadata.create_all(bind=engine), importing the URL model registers it with Base and the urls table is created in your database.

Seeing an import error?

  • Make sure models.py is saved in the same folder as main.py and database.py.
  • Check that the import line reads from models import URL exactly. A typo in the filename or class name causes a ModuleNotFoundError.
  • If you see a database connection error, confirm your MySQL server is still running.

Help me fix my import error in main.py

Create the request and response schemas

FastAPI uses Pydantic models to validate incoming requests and shape outgoing responses. You need a schema for what the user sends in and what they get back.

  • Create a new file called schemas.py and paste this code:
from pydantic import BaseModel, HttpUrl


class URLCreate(BaseModel):
    url: HttpUrl


class URLResponse(BaseModel):
    short_code: str
    short_url: str
    original_url: str
    clicks: int

What do these schemas define?

  • URLCreate validates that the incoming request has a url field containing a valid HTTP URL. Pydantic rejects anything that is not a proper URL.
  • URLResponse defines the shape of what the API sends back: the short code, the full short URL, the original URL, and the click count.
  • Save schemas.py.

✔️ Awesome, I've got everything!

Great. Make sure both models.py and schemas.py are saved.

ⓧ I'd like to double check the full code

Here is models.py:

from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.sql import func

from database import Base


class URL(Base):
    __tablename__ = "urls"

    id = Column(Integer, primary_key=True, index=True)
    original_url = Column(Text, nullable=False)
    short_code = Column(String(10), unique=True, index=True, nullable=False)
    created_at = Column(DateTime, server_default=func.now())
    clicks = Column(Integer, default=0)

Here is schemas.py:

from pydantic import BaseModel, HttpUrl


class URLCreate(BaseModel):
    url: HttpUrl


class URLResponse(BaseModel):
    short_code: str
    short_url: str
    original_url: str
    clicks: int
Build the Base62 encoder and /shorten endpoint

Base62 encoding converts an integer into a compact alphanumeric string using the characters a-z, A-Z, and 0-9. This gives you 62 possible characters per position, keeping codes short while staying URL-safe.

  • Create a new file called utils.py and paste this code:
import string

ALPHABET = string.ascii_letters + string.digits


def encode_base62(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    result = ""
    while num > 0:
        result = ALPHABET[num % 62] + result
        num //= 62
    return result

How does Base62 encoding work?

ALPHABET is a string of 62 characters: all lowercase letters, uppercase letters, and digits. The function repeatedly divides the number by 62, using the remainder to pick a character from the alphabet.

For example, the number 1 becomes b (index 1 in the alphabet). The number 62 becomes ba. Small database IDs (1, 2, 3) produce single characters. This is the sequential pattern you will observe shortly.

  • Save utils.py.
  • In main.py, find the from models import URL line you added earlier. Add these two imports directly below it:
from schemas import URLCreate, URLResponse
from utils import encode_base62
  • Below your /health endpoint, add the /shorten endpoint:
@app.post("/shorten", response_model=URLResponse)
def shorten_url(request: URLCreate, db: Session = Depends(get_db)):
    original_url = str(request.url)
    new_url = URL(original_url=original_url, short_code="temp")
    db.add(new_url)
    db.commit()
    db.refresh(new_url)
    short_code = encode_base62(new_url.id)
    new_url.short_code = short_code
    db.commit()
    db.refresh(new_url)
    return URLResponse(
        short_code=new_url.short_code,
        short_url=f"http://127.0.0.1:8000/{new_url.short_code}",
        original_url=new_url.original_url,
        clicks=new_url.clicks,
    )

What does this endpoint do?

The endpoint uses a two-step insert strategy because the short code depends on the auto-increment ID, which does not exist until after the row is committed.

  • It inserts the URL with a temporary short_code="temp" placeholder and commits to get the auto-generated id.
  • It encodes that id into a Base62 string and updates the row with the real short code.
  • It returns a URLResponse with the short code, the full clickable short URL, and the original URL.
  • Save main.py. Check your terminal to confirm the server restarts without errors.

Server not restarting?

  • If you see IndentationError, make sure the endpoint body is indented with 4 spaces consistently.
  • If you see ImportError: cannot import name 'URLCreate', confirm schemas.py is saved in the same folder and the class name matches exactly.
  • If you see ImportError: cannot import name 'encode_base62', confirm utils.py is saved and the function name matches.

Help me fix my FastAPI import errors

✔️ Awesome, I've got everything!

Great. Double check that utils.py and main.py are both saved.

ⓧ I'd like to double check the full code

Here is utils.py:

import string

ALPHABET = string.ascii_letters + string.digits


def encode_base62(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    result = ""
    while num > 0:
        result = ALPHABET[num % 62] + result
        num //= 62
    return result

For main.py, confirm you have these sections in order:

  • Imports at the top: FastAPI, Depends, Session, text, then database, models, schemas, and utils.
  • Base.metadata.create_all(bind=engine) and app = FastAPI(title="URL Shortener").
  • The /health GET endpoint.
  • The /shorten POST endpoint with the two-step insert logic.
Test the shortener via the interactive docs

Your endpoint is live. Before you test it, think about what you expect the short codes to look like. The first row in the database gets id = 1. What would encode_base62(1) return?

  • Open your browser and go to http://127.0.0.1:8000/docs.
  • Click on the POST /shorten endpoint to expand it.
  • Click Try it out.
  • Replace the request body with:
{
  "url": "https://www.google.com"
}
  • Click Execute.

You should see a response with a short_code, short_url, original_url, and clicks: 0. The short code is a single character like b.

  • Shorten a second URL (try https://www.github.com) and then a third (try https://www.youtube.com).

Notice the pattern. The codes come back as sequential single characters: b, c, d. Each code directly corresponds to the row's auto-increment ID encoded as Base62.

Why are the codes sequential?

Database IDs increment by 1 each time: 1, 2, 3. The Base62 encoding of those small numbers produces single characters from the alphabet. ID 1 becomes b, ID 2 becomes c, ID 3 becomes d.

This works, but it means anyone can guess the next code. You'll discover why this is a problem in the next step when you try visiting these codes in a browser.

Getting a 422 Unprocessable Entity?

  • Make sure your request body is valid JSON with a url field containing a full URL including https://.
  • Pydantic requires a properly formatted URL. Something like google.com without the protocol is rejected.

Help me fix my 422 error on /shorten

Your shortener is producing codes. Next up, you'll make those codes actually work by building the redirect endpoint. When someone visits a short code in their browser, they should be whisked away to the original URL.

Add Redirect and Spot the Design Flaw

You have short codes stored in the database, but they do nothing yet. Visiting http://127.0.0.1:8000/b returns an error because no route handles that path.

In this step, you will add the redirect endpoint that makes short links actually work. Once it's live, you will uncover a serious design flaw hiding inside the sequential code approach.

In this step, get ready to:
  • Add a redirect endpoint that sends visitors to the original URL.
  • Discover why sequential codes are a predictability and privacy problem.
Add the redirect endpoint

The redirect endpoint is the core of any URL shortener. When someone visits a short link, the server looks up the code in the database and sends the browser to the original destination.

  • In main.py, find the import line at the very top of your file:
from fastapi import FastAPI, Depends
  • Replace it with these two lines, which add HTTPException for error handling and RedirectResponse for sending visitors to the original URL:
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import RedirectResponse

Now add the redirect endpoint itself. This function captures any path as a short code, looks it up, and redirects the visitor.

  • Scroll to the very bottom of main.py (below the shorten_url function) and add this endpoint:
@app.get("/{short_code}")
def redirect_to_url(short_code: str, db: Session = Depends(get_db)):
    # Look up the short code in the database
    url_entry = db.query(URL).filter(URL.short_code == short_code).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")
    # Redirect the visitor to the original URL
    return RedirectResponse(url=url_entry.original_url, status_code=302)

What does this code do?

  • @app.get("/{short_code}") creates a catch-all GET route. Any path like /b or /abc123 gets captured into the short_code variable.
  • The query filters the urls table for a matching short_code column. If nothing is found, a 404 tells the visitor the link does not exist.
  • RedirectResponse sends the browser an HTTP 302 response with the original URL. The browser automatically follows it.

Why 302 instead of 301?

A 301 means "moved permanently." Browsers cache 301 redirects aggressively, so future visits skip the server entirely. That kills any chance of tracking clicks.

A 302 means "found" (temporary redirect). Every visit hits the server, so you can count each click. Real shorteners like bit.ly use 301 for speed, but 302 preserves analytics capability. This is a genuine system design tradeoff you can discuss in interviews.

  • Save main.py.
  • Check your terminal where the server is running. You should see a log line confirming the server reloaded without errors.

Seeing an import error or server crash?

  • Make sure HTTPException is on the same line as FastAPI and Depends, separated by commas.
  • The RedirectResponse import must be from fastapi.responses import RedirectResponse on its own line. It is NOT part of the main fastapi import.
  • Check that the new endpoint is outside and below the shorten_url function, not nested inside it.

Why is my server crashing after adding the redirect endpoint?

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and your server reloaded cleanly.

ⓧ I'd like to double check the full code

Your main.py is now longer than 30 lines, so confirm each section is present in order:

  • Imports: FastAPI, Depends, HTTPException from fastapi. RedirectResponse from fastapi.responses. Session from sqlalchemy.orm. text from sqlalchemy. Local imports for engine, get_db, Base, URL, URLCreate, URLResponse, encode_base62.
  • Base.metadata.create_all(bind=engine) and app = FastAPI(title="URL Shortener") setup lines.
  • The /health GET endpoint (unchanged from before).
  • The /shorten POST endpoint (unchanged from before).
  • The new /{short_code} GET endpoint at the very bottom.
Spot the predictability flaw

The redirect works. Now it's time to look at what the sequential approach reveals about your data.

  • Open your browser (Chrome, Firefox, or any browser).
  • Type http://127.0.0.1:8000/b into the URL bar and press Enter.

Before you look at the result, what do you expect to happen? The code b maps to one of the URLs you shortened in the previous step.

Your browser navigates to the original URL you shortened. The address bar now shows the full destination URL. The redirect worked.

  • Now change the URL in your browser to http://127.0.0.1:8000/c and press Enter.
  • Try http://127.0.0.1:8000/d as well.

Every single URL you stored is accessible by guessing the next character in sequence. Anyone can enumerate your entire database just by incrementing the code.

Why is this a system design problem?

Sequential codes are predictable. An attacker (or even a curious user) can write a simple loop that tries a, b, c, and so on, scraping every URL in your database. This exposes private links that people assumed were secret.

In a system design interview, you would flag this as a privacy and security concern. The fix is to generate random, non-guessable codes using a hash function. That is exactly what you will build next.

Your redirect endpoint works perfectly. But now you have seen the problem: anyone can guess your codes and access every stored URL. In the next step, you will replace sequential codes with random hash-based codes to eliminate this predictability.

Switch to Hash-Based Codes and Hit a Collision

In the last step, you discovered that sequential codes let anyone guess and enumerate every stored URL. The fix is straightforward: generate codes from a hash of the URL itself, producing random-looking strings that reveal nothing about the database order.

Hashing solves the predictability problem, but it introduces a new one. When you truncate a hash to a short length, the number of possible codes shrinks. Eventually, two different URLs will produce the same short code. That is the collision problem, and you are about to hit it firsthand.

In this step, get ready to:
  • Replace sequential encoding with a hash-based code generator.
  • Run a collision test script and observe it crash.
Add a hash-based code generator to utils.py

Instead of converting a database ID to Base62, you will hash the URL with MD5, convert part of the hash to an integer, then encode that integer as Base62. The result is a deterministic but unpredictable-looking code.

  • Open utils.py in VS Code.
  • Add the following import hashlib line at the very top of the file, above the existing import string line:
import hashlib
  • Add the generate_short_code function at the bottom of utils.py, below the existing encode_base62 function:
def generate_short_code(url: str, length: int = 4) -> str:
    # Hash the URL to get a deterministic, random-looking digest
    hash_digest = hashlib.md5(url.encode()).hexdigest()
    # Take the first 10 hex characters and convert to an integer
    num = int(hash_digest[:10], 16)
    # Encode that integer as Base62
    code = encode_base62(num)
    # Truncate to the desired length
    return code[:length]

What does this code do?

The function takes any URL string and produces a short code from it. Because MD5 always returns the same hash for the same input, the same URL always maps to the same code.

  • hashlib.md5(url.encode()).hexdigest() produces a 32-character hex string from the URL.
  • int(hash_digest[:10], 16) grabs the first 10 hex chars and converts them to a large integer.
  • encode_base62(num) converts that integer into a compact alphanumeric string.
  • code[:length] truncates to 4 characters. This keeps codes short but makes the code space small (only 62^4 possible codes).
  • Save utils.py. Check your terminal where the server is running. It should auto-reload without errors.

Seeing a syntax error in the terminal?

Check that import hashlib is on its own line at the top of the file, above import string. Also confirm the function is not indented inside encode_base62.

Help me debug a syntax error in utils.py

Update the /shorten endpoint to use hash-based codes

The current /shorten endpoint inserts a row with a temporary code, then overwrites it with a sequential Base62 value. The new approach generates the code upfront from the URL itself, so the insert is clean and the code no longer depends on the database ID.

  • In main.py, find this import line near the top:
from utils import encode_base62
  • Replace it with:
from utils import generate_short_code
  • Now find the entire shorten_url function body (everything below @app.post("/shorten", response_model=URLResponse)) and replace it with:
@app.post("/shorten", response_model=URLResponse)
def shorten_url(request: URLCreate, db: Session = Depends(get_db)):
    original_url = str(request.url)
    # Generate a hash-based short code directly from the URL
    short_code = generate_short_code(original_url)
    new_url = URL(original_url=original_url, short_code=short_code)
    db.add(new_url)
    db.commit()
    db.refresh(new_url)
    return URLResponse(
        short_code=new_url.short_code,
        short_url=f"http://127.0.0.1:8000/{new_url.short_code}",
        original_url=new_url.original_url,
        clicks=new_url.clicks,
    )

What changed?

The old version inserted a placeholder code, then updated it after learning the row's ID. The new version generates the code from the URL before inserting. One clean INSERT, no update needed.

This also means the code no longer leaks your database's row count. Two URLs shortened seconds apart get unrelated codes.

  • Save main.py. Your server auto-reloads.
  • Switch back to your browser and go to http://127.0.0.1:8000/docs.
  • Expand the POST /shorten endpoint, click Try it out, and submit this body:
{"url": "https://www.github.com"}

The short_code in the response should be a 4-character alphanumeric string (something like Kx9p) instead of a predictable single character.

Getting a 500 Internal Server Error?

If you previously shortened the same URL with the old sequential approach, your database still has that row. The new hash-based code might conflict with existing data. Open MySQL Workbench and run TRUNCATE TABLE urls; to clear the table, then try again.

Help me debug a 500 error when shortening a URL

Run a collision test and watch it crash

Your short codes are now 4 characters long. That means only 62^4 (about 14.7 million) possible codes exist. When you shorten enough URLs, eventually two different URLs will produce the same truncated hash. The database's UNIQUE constraint on short_code will reject the duplicate, crashing the request.

  • Install the requests library (needed for the test script) by running this in your terminal:
pip install requests

Pip command not found?

Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt. If not, activate it again with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Help me fix pip not being found in my terminal

  • Create a new file called test_collision.py in your project folder and paste the following code:
import requests

BASE_URL = "http://127.0.0.1:8000"

# Shorten many URLs to force a hash collision
print("Shortening 50 URLs to test for collisions...")
errors = 0
for i in range(50):
    url = f"https://example.com/page/{i}?ref={i * 7}"
    response = requests.post(f"{BASE_URL}/shorten", json={"url": url})
    if response.status_code != 200:
        print(f"  ERROR on URL #{i}: {response.status_code} - {response.text}")
        errors += 1
    else:
        data = response.json()
        print(f"  #{i}: {data['short_code']}")

print(f"\nDone! {50 - errors}/50 succeeded, {errors} errors.")

What does this script do?

It generates 50 unique URLs and shortens each one by calling your API. If two URLs happen to hash to the same 4-character code, the second INSERT will fail because the short_code column has a UNIQUE constraint.

With only 4 characters of code space, collisions become likely as more URLs accumulate. This is the birthday problem in action.

  • Save test_collision.py.
  • Make sure your FastAPI server is still running in its original terminal.

Before you run this, what do you think will happen? Will all 50 URLs shorten successfully, or will some fail? Why?

  • Open a second terminal (keep the server running in the first one) and run the collision test:
python test_collision.py

You should see some URLs succeed with their short codes printed, then ERROR lines showing a 500 status with a message about a duplicate entry. This is the collision. Your naive hash approach just crashed under real usage.

Why does this matter for system design?

Every URL shortener faces this problem. Truncating a hash to keep codes short guarantees collisions will eventually happen. The question is not whether they happen, but how your system handles them when they do.

Right now, your system crashes. In the next step, you will fix this with retry logic and a salt mechanism.

All 50 URLs succeeded without error?

Collisions are probabilistic. With 4-character codes and only 50 URLs, you might get lucky and avoid one. Try increasing the count. In test_collision.py, change range(50) to range(500) and run it again.

If collisions still do not appear, temporarily change length=4 to length=2 in the generate_short_code function in utils.py. With only 62^2 = 3,844 possible codes, a collision is almost guaranteed. Change it back to length=4 after you observe the crash.

Help me trigger a hash collision in my URL shortener

✔️ Awesome, I've got everything!

Great. Double check you have saved both utils.py and main.py.

ⓧ I'd like to double check the full code

Here is utils.py with both functions:

import hashlib
import string

ALPHABET = string.ascii_letters + string.digits


def encode_base62(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    result = ""
    while num > 0:
        result = ALPHABET[num % 62] + result
        num //= 62
    return result


def generate_short_code(url: str, length: int = 4) -> str:
    hash_digest = hashlib.md5(url.encode()).hexdigest()
    num = int(hash_digest[:10], 16)
    code = encode_base62(num)
    return code[:length]

Here is the top half of main.py with the updated import:

from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import text

from database import engine, get_db, Base
from models import URL
from schemas import URLCreate, URLResponse
from utils import generate_short_code

Base.metadata.create_all(bind=engine)

app = FastAPI(title="URL Shortener")


@app.get("/health")
def health_check(db: Session = Depends(get_db)):
    db.execute(text("SELECT 1"))
    return {"status": "healthy", "database": "connected"}

Here is the bottom half of main.py with both endpoints:

@app.post("/shorten", response_model=URLResponse)
def shorten_url(request: URLCreate, db: Session = Depends(get_db)):
    original_url = str(request.url)
    short_code = generate_short_code(original_url)
    new_url = URL(original_url=original_url, short_code=short_code)
    db.add(new_url)
    db.commit()
    db.refresh(new_url)
    return URLResponse(
        short_code=new_url.short_code,
        short_url=f"http://127.0.0.1:8000/{new_url.short_code}",
        original_url=new_url.original_url,
        clicks=new_url.clicks,
    )


@app.get("/{short_code}")
def redirect_to_url(short_code: str, db: Session = Depends(get_db)):
    url_entry = db.query(URL).filter(URL.short_code == short_code).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=url_entry.original_url, status_code=302)

Here is test_collision.py:

import requests

BASE_URL = "http://127.0.0.1:8000"

print("Shortening 50 URLs to test for collisions...")
errors = 0
for i in range(50):
    url = f"https://example.com/page/{i}?ref={i * 7}"
    response = requests.post(f"{BASE_URL}/shorten", json={"url": url})
    if response.status_code != 200:
        print(f"  ERROR on URL #{i}: {response.status_code} - {response.text}")
        errors += 1
    else:
        data = response.json()
        print(f"  #{i}: {data['short_code']}")

print(f"\nDone! {50 - errors}/50 succeeded, {errors} errors.")

You have proven that the naive hash approach breaks under load. The collision is not a bug in your code. It is a fundamental property of mapping a large input space to a small output space. In the next step, you will fix this with retry logic and add click tracking.

Fix Collisions and Add Click Tracking

In the previous step, your collision test script crashed with an IntegrityError. Two different URLs produced the same 4-character short code, and the database rejected the duplicate.

The fix is a two-part strategy: increase the code length to reduce collision probability, and add a retry mechanism that generates a new code when a collision does occur. While you are touching the endpoint, you will also add click tracking so every redirect increments a counter.

In this step, get ready to:
  • Add idempotency and collision retry logic to the shorten endpoint.
  • Add click tracking to the redirect endpoint.
  • Verify the full flow from shortening to clicking to analytics.
Increase code length and handle duplicate URLs

With only 4 characters, the code space is too small. Increasing to 6 characters gives you 62^6 (about 56.8 billion) possible codes. You will also add an idempotency check so that shortening the same URL twice returns the existing entry instead of creating a duplicate.

  • In utils.py, find the generate_short_code function signature.
  • Find this line:
def generate_short_code(url: str, length: int = 4) -> str:
  • Change 4 to 6, so the line now looks like this:
def generate_short_code(url: str, length: int = 6) -> str:
  • Save utils.py.

Now add an idempotency check to the shorten endpoint. If someone submits a URL that was already shortened, the API should return the existing entry instead of trying to create a new one.

  • In main.py, find the shorten_url function. Locate the line original_url = str(request.url).
  • Add the following code directly below that line, above short_code = generate_short_code(original_url):
    # Return existing entry if this URL was already shortened
    existing = db.query(URL).filter(URL.original_url == original_url).first()
    if existing:
        return URLResponse(
            short_code=existing.short_code,
            short_url=f"http://127.0.0.1:8000/{existing.short_code}",
            original_url=existing.original_url,
            clicks=existing.clicks,
        )

What does this code do?

This is an idempotency check. It queries the database for a row matching the submitted URL.

If the URL already exists, the endpoint returns the existing short code immediately. No new row is created, and the response includes the current click count.

  • Save main.py.
  • Switch to the interactive docs at http://127.0.0.1:8000/docs.
  • Use the /shorten endpoint to shorten https://www.wikipedia.org.
  • Submit the exact same URL again.

The second request returns the same short code as the first, with no new row created. That is idempotency in action.

Getting an IntegrityError on the first request?

If the URL you are testing was already stored during a previous step, the idempotency check should catch it. If you see a crash, make sure you added the new code above the existing short_code = generate_short_code(original_url) line, not below it.

help me debug my idempotency check in the /shorten endpoint

Add collision retry logic

The idempotency check handles duplicate URLs. But what about two different URLs that happen to produce the same short code? You need a retry mechanism that generates a new code by adding a salt (the attempt number) to the input.

  • In main.py, find the remaining lines of the shorten_url function after your new idempotency check. Find this block:
    short_code = generate_short_code(original_url)
    new_url = URL(original_url=original_url, short_code=short_code)
    db.add(new_url)
    db.commit()
    db.refresh(new_url)
    return URLResponse(
        short_code=new_url.short_code,
        short_url=f"http://127.0.0.1:8000/{new_url.short_code}",
        original_url=new_url.original_url,
        clicks=new_url.clicks,
    )
  • Replace that entire block with the following code:
    short_code = generate_short_code(original_url)

    # Retry with a salt if the code already exists for a different URL
    max_retries = 5
    for attempt in range(max_retries):
        collision = db.query(URL).filter(URL.short_code == short_code).first()
        if collision is None:
            break
        short_code = generate_short_code(original_url + str(attempt))
    else:
        raise HTTPException(
            status_code=500, detail="Failed to generate unique short code"
        )

    # Save the new shortened URL
    new_url = URL(original_url=original_url, short_code=short_code)
    db.add(new_url)
    db.commit()
    db.refresh(new_url)

    return URLResponse(
        short_code=new_url.short_code,
        short_url=f"http://127.0.0.1:8000/{new_url.short_code}",
        original_url=new_url.original_url,
        clicks=new_url.clicks,
    )

What does this retry loop do?

  • It generates a short code from the URL, then checks if that code already exists in the database.
  • If no collision exists, it breaks out of the loop and proceeds to insert.
  • If a collision is found, it regenerates the code by appending the attempt number as a salt. This produces a completely different hash.
  • The for...else pattern means the else block only runs if the loop finishes without ever hitting break. If all 5 retries fail, the API returns a 500 error.
  • Save main.py.

Before you run the collision test again, what do you expect will happen now that codes are 6 characters long and the retry loop catches collisions?

  • Re-run the collision test script by running this command in your terminal:
python test_collision.py

You should see all 50 URLs shortened successfully with zero errors. Each short code is now 6 characters long.

Still seeing errors?

  • Make sure your FastAPI server is still running. Check the terminal where you started it for any import errors after saving main.py.
  • Confirm you changed length: int = 4 to length: int = 6 in utils.py and saved the file.
  • Check that the retry loop replaced the old insert code completely. There should be no leftover lines from the old version.

help me debug my collision retry logic

Add click tracking and test the full flow

Your shortener creates links and handles collisions. But you have no idea how popular each link is. Every time someone visits a short link, the redirect endpoint should increment a click counter before sending them to the destination.

  • In main.py, find the redirect_to_url function. Locate the line:
    return RedirectResponse(url=url_entry.original_url, status_code=302)
  • Add the following two lines directly above that return statement:
    # Track each visit before redirecting
    url_entry.clicks += 1
    db.commit()

What does this do?

Every time someone visits the short link, the clicks counter increments by one and the change is saved to the database. This happens before the redirect, so the count is always up to date.

This is why you chose a 302 redirect instead of 301. A 301 tells browsers to cache the redirect permanently. Future visits would skip your server entirely, and clicks would never be counted.

  • Save main.py.

Time to test the complete flow. Before you begin, what do you think happens when you shorten the same URL a second time after visiting it three times?

  • Go to http://127.0.0.1:8000/docs and use /shorten to shorten a fresh URL (something you have not shortened before, like https://www.github.com).
  • Copy the short code from the response.
  • Open http://127.0.0.1:8000/ followed by your short code in a new browser tab. It redirects you to the original URL.
  • Go back and visit the same short link two more times (3 visits total).
  • Return to /docs and POST the same URL to /shorten again.

The response shows clicks: 3. The idempotency check returned the existing entry, and its click count reflects your three visits.

Click count showing 0?

  • Make sure you visit the short link by pasting it in the browser URL bar, not by using the /docs interface. The GET /{short_code} endpoint triggers the redirect and increments the counter.
  • Confirm you added url_entry.clicks += 1 and db.commit() above the return RedirectResponse line, not below it.
  • Check that you saved main.py and that the server reloaded without errors.

help me debug my click tracking

✔️ Awesome, I've got everything!

Double check you have saved both utils.py and main.py.

ⓧ I'd like to double check the full code

Here is the complete utils.py for comparison:

import hashlib
import string

ALPHABET = string.ascii_letters + string.digits


def encode_base62(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    result = ""
    while num > 0:
        result = ALPHABET[num % 62] + result
        num //= 62
    return result


def generate_short_code(url: str, length: int = 6) -> str:
    hash_digest = hashlib.md5(url.encode()).hexdigest()
    num = int(hash_digest[:10], 16)
    code = encode_base62(num)
    return code[:length]

For main.py, confirm these sections are present in order by scrolling through your file:

  • Imports and Base.metadata.create_all(bind=engine) at the top.
  • The /health endpoint.
  • The /shorten endpoint with the idempotency check (early return for existing URLs) followed by the collision retry loop (for attempt in range(max_retries)).
  • The GET /{short_code} redirect endpoint with url_entry.clicks += 1 and db.commit() before the RedirectResponse.

Your URL shortener now handles collisions gracefully, avoids duplicates, and tracks every click. Next up, you will take on a secret mission to add a dedicated analytics endpoint.

Secret mission

Build an Analytics Endpoint

Real URL shorteners like Bitly expose analytics for every short link. Can you add a /stats endpoint that returns the creation time, total clicks, and original URL for any short code? You will need to design the response schema, handle edge cases, and solve a sneaky route-ordering problem.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A MySQL database called url_shortener with a urls table.
  • A project folder on your Desktop with Python files and a virtual environment.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment with the API further.

  • Your MySQL database persists between reboots. The data stays until you explicitly drop it.
  • Your project folder and virtual environment remain on your Desktop, ready to use any time.
  • To restart the server later, activate your virtual environment and run python -m uvicorn main:app --reload from inside the project folder.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your database and files stay intact for next time.

  • Press Ctrl+C in the terminal where the FastAPI server is running to stop it.
  • Your MySQL database keeps all your shortened URLs and click data. Nothing is lost.
  • To resume later, open the project folder in VS Code, activate your virtual environment, and start the server again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your database, shortened URLs, and all project files.

  • Press Ctrl+C in the terminal to stop the FastAPI server if it is still running.
  • Open MySQL Workbench and run this query to drop the database:
DROP DATABASE url_shortener;
  • Delete the project folder from your Desktop. In your terminal, run:

macOS

cd ~/Desktop
rm -rf url-shortener

Windows

cd ~\Desktop
rmdir /s /q url-shortener

Your machine is now clean. No project files or database remain.

Nice Work!

Nice Work!

Nice work! You just built a fully functional URL shortener API from scratch, making real system design decisions along the way.

You've learned how to:

  • Design and build a REST API with FastAPI that shortens URLs using Base62 encoding and MD5 hashing, with a database schema indexed for fast lookups.
  • Hit a real hash collision and fix it with a retry-and-salt strategy that you can explain in a system design interview.
  • Implement HTTP 302 redirects that work in any browser and click tracking that increments every time someone visits a short link.
  • Secret Mission: Built a dedicated /stats analytics endpoint that returns creation time, total clicks, and the original URL for any short code.

Ready to quiz yourself?