Build a URL Shortener: System Design
Build a URL shortener with FastAPI, PostgreSQL, and Redis caching.
Introduction
30 Second Summary
Every time you share a link, you're relying on a URL shortener. These tiny services power billions of redirects daily, and they're one of the most popular system design interview questions because they touch on databases, caching, encoding, and API design all at once.
In this project, you will build a fully functional URL shortener from scratch. You'll design the database, implement caching for fast redirects, and run everything locally using Docker containers.
What You'll Build
A multi-service URL shortener API that accepts long URLs, generates short codes, redirects visitors, and tracks click analytics.
By the end of this project, you'll have:
- A FastAPI backend with three REST endpoints for shortening, redirecting, and viewing stats.
- A PostgreSQL database storing URL mappings and a Redis cache layer for sub-millisecond redirect lookups.
- A Docker Compose setup orchestrating your multi-service architecture locally.
- A system design document explaining your architecture decisions and trade-offs.
- Secret Mission: Add a Redis-based rate limiter to protect your API from abuse.
Are there any prerequisites?
You should be comfortable with basic Python (variables, functions, dictionaries). No prior experience with Docker, databases, or API frameworks is needed.
Install Docker Desktop and Set Up the Project
Your URL shortener needs three services working together: the Python application, a PostgreSQL database for storage, and a Redis cache for fast lookups. Installing each service directly on your machine is complex and error-prone.
Docker solves this by running PostgreSQL and Redis in isolated containers that you can start and stop with a single command. In this step, you'll install Docker Desktop, create your project folder with a Python virtual environment, and spin up the database and cache containers.
In this step, get ready to:
- Install Docker Desktop and verify it's running.
- Create the project directory and install Python dependencies.
- Start PostgreSQL and Redis containers with Docker Compose.
Install Docker Desktop
Docker Desktop is the application that lets you run containers on your machine. It bundles the Docker engine, the docker CLI, and Docker Compose together in one install.
- Check if Docker is already installed by running this command in your terminal:
docker --version
✔️ I see a version number
Docker is ready on your machine. Now confirm Docker Compose is available by running:
docker compose version
You should see a version number starting with v2. If both commands succeed, skip ahead to the next substep.
ⓧ Command not found
Docker is not installed yet. Follow the instructions for your operating system below.
macOS
- Visit docker.com/products/docker-desktop and click the download button for Mac.
- Open the downloaded Docker.dmg file.
- Drag the Docker icon into your Applications folder.
- Open Docker Desktop from your Applications folder.
- Accept the terms when prompted. Wait for the Docker engine to start. You'll see a whale icon in your menu bar when it's ready.
How do I know Docker Desktop is ready?
Look for the whale icon in your macOS menu bar at the top of the screen. When the icon stops animating, Docker is running and ready to accept commands.
Windows
- Visit docker.com/products/docker-desktop and click the download button for Windows.
- Run the downloaded installer and follow the on-screen prompts.
- When asked about the backend, select WSL 2 (recommended).
- Restart your computer if the installer prompts you to.
- After restarting, open Docker Desktop from the Start menu or desktop shortcut. Wait for the engine to finish starting.
Windows requirements
Docker Desktop requires Windows 10 or Windows 11. The installer enables the WSL 2 feature automatically if it's not already active.
Once Docker Desktop is running, verify both commands work by running:
docker --version
docker compose version
You should see version numbers for both Docker and Compose.
Docker command still not found after installing?
- Make sure Docker Desktop is running (look for the whale icon in your menu bar or system tray).
- Close and reopen your terminal after installation. The terminal needs a fresh session to pick up the new PATH entries.
- On Windows, try opening a new PowerShell window instead of Command Prompt.
Help me troubleshoot my Docker Desktop installation.
Create the project directory and install dependencies
With Docker running, you need a project folder and a Python virtual environment to keep your dependencies isolated from other projects on your machine.
- 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
- Confirm the folder was created by running:
pwd
You should see a path ending in url-shortener.
- Create a Python virtual environment by running:
python3 -m venv venv
What is a virtual environment?
A virtual environment is an isolated Python installation specific to this project. Packages you install inside it won't conflict with other Python projects on your machine.
- Activate the virtual environment by running the command for your operating system:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
- Make sure you ran the activation command from inside the url-shortener folder.
- 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 Python virtual environment.
- Open the url-shortener folder in your code editor.
- Create a new file called requirements.txt in the root of the url-shortener folder.
- Paste the following dependency list into requirements.txt:
fastapi==0.136.3
uvicorn[standard]==0.34.0
sqlalchemy==2.0.49
psycopg2-binary==2.9.10
redis==7.4.0
pydantic==2.11.3
What are these packages?
- fastapi is the web framework for building your API endpoints.
- uvicorn is the server that runs your FastAPI application.
- sqlalchemy lets you interact with PostgreSQL using Python objects instead of raw SQL.
- psycopg2-binary is the database driver that connects Python to PostgreSQL.
- redis is the Python client for connecting to your Redis cache.
- pydantic handles data validation for incoming API requests.
- Save requirements.txt.
- Install all the dependencies by running:
pip install -r requirements.txt
You should see output showing each package being downloaded and installed, ending with a success message.
Installation failing?
- Make sure (venv) appears in your prompt. If it doesn't, reactivate the virtual environment first.
- If psycopg2-binary fails on macOS, try running xcode-select --install to install build tools, then retry.
Help me fix my pip install errors.
✔️ Awesome, I've got everything!
Great. Double check you've saved requirements.txt and the install completed without errors.
ⓧ I'd like to double check the full code
Here's the complete requirements.txt for comparison:
fastapi==0.136.3
uvicorn[standard]==0.34.0
sqlalchemy==2.0.49
psycopg2-binary==2.9.10
redis==7.4.0
pydantic==2.11.3
Create the Docker Compose configuration
Docker Compose lets you define multiple services in a single file and start them all together. You'll configure PostgreSQL and Redis here so they're ready when your application needs them.
- In your code editor, create a new file called docker-compose.yml in the root of the url-shortener folder.
- Paste the following configuration into docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
container_name: url-shortener-postgres
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d urlshortener"]
interval: 10s
timeout: 5s
retries: 3
redis:
image: redis:7-alpine
container_name: url-shortener-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
postgres_data:
What does this configuration do?
- postgres:16-alpine runs PostgreSQL 16 in a lightweight Alpine Linux container.
- redis:7-alpine runs Redis 7 in its own container.
- The ports section maps each container's internal port to your machine so your Python app can connect.
- The healthcheck blocks let Docker verify each service is actually responding before marking it as healthy.
- The volumes section persists PostgreSQL data so it survives container restarts.
- Save docker-compose.yml.
- Start both containers by running:
docker compose up -d
What does the -d flag do?
The -d flag runs the containers in detached mode, meaning they run in the background. Your terminal stays free for other commands.
The first run downloads the PostgreSQL and Redis images, which may take a minute or two.
- Verify both containers are running by executing:
docker compose ps
You should see two containers listed: url-shortener-postgres and url-shortener-redis, both with a status of running.
Containers not starting?
- Make sure Docker Desktop is still running (check for the whale icon).
- If you see a port conflict error, another service is already using port 5432 or 6379. Stop the conflicting service or change the port mapping in docker-compose.yml.
- Run docker compose logs to see detailed error output from the containers.
Help me fix my Docker Compose containers that won't start.
✔️ Awesome, I've got everything!
Great. Double check you've saved docker-compose.yml and both containers show as running.
ⓧ I'd like to double check the full code
Here's the complete docker-compose.yml for comparison:
services:
postgres:
image: postgres:16-alpine
container_name: url-shortener-postgres
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d urlshortener"]
interval: 10s
timeout: 5s
retries: 3
redis:
image: redis:7-alpine
container_name: url-shortener-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
postgres_data:
Your infrastructure is ready. Next up, you'll design the database schema and build the first API endpoint that actually shortens URLs.
Design the Database and Build the Shorten Endpoint
Your infrastructure is running. You have PostgreSQL and Redis containers ready to accept connections.
Now you need to answer the core system design question: how do you turn a long URL into a short one? You need a database schema to store the mappings, an encoding algorithm to generate short codes, and an API endpoint to tie it all together.
In this step, get ready to:
- Create the database connection layer and URL model with SQLAlchemy.
- Implement Base62 encoding to convert numeric IDs into short URL codes.
- Build the POST /shorten endpoint that stores a URL and returns a short code.
Create the database model and connection setup
Every URL shortener needs a place to store the mapping between short codes and original URLs. You will use SQLAlchemy to define this schema as a Python class that maps directly to a PostgreSQL table.
- Create the app/ package directory by running these commands:
mkdir app
touch app/__init__.py
What is a Python package?
The __init__.py file tells Python that the app/ directory is a package. This lets you import modules from it using from app.database import ... syntax.
- Confirm the files exist by running:
ls app/
You should see __init__.py listed in the output.
- Create app/database.py and paste this code:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/urlshortener"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
What does this code do?
DATABASE_URL points to the PostgreSQL container you started in Step 1. The credentials match the POSTGRES_USER and POSTGRES_PASSWORD from your docker-compose.yml.
DeclarativeBase is the modern SQLAlchemy 2.0 way to create a base class for your models. Every model you define will inherit from Base.
get_db() is a dependency that FastAPI calls to create a database session for each request, then closes it when the request finishes.
- Save app/database.py.
- Create app/models.py and paste this code:
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from app.database import Base
class ShortLink(Base):
__tablename__ = "short_links"
id = Column(Integer, primary_key=True, index=True)
short_code = Column(String(10), unique=True, index=True, nullable=False)
original_url = Column(String(2048), nullable=False)
clicks = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
What does the ShortLink model represent?
This is your database schema expressed as a Python class. Each attribute becomes a column in the short_links table:
- id is the auto-incrementing primary key. You will use this number to generate the short code.
- short_code is the encoded string (like a3Bc) that goes in the short URL. It has a unique index for fast lookups.
- original_url stores the full destination URL, up to 2048 characters.
- clicks tracks how many times the short link has been accessed.
- created_at records when the link was created.
- Save app/models.py.
Seeing an import error?
If your editor shows a red underline on from app.database import Base, make sure you created the __init__.py file inside the app/ directory. Without it, Python does not recognize app as a package.
Help me fix my import error in app/models.py
Implement Base62 encoding
Real URL shorteners like bit.ly do not use random strings. They convert the auto-increment database ID into a short, URL-safe string using Base62 encoding. This gives you collision-free codes that are guaranteed unique.
What is Base62 encoding?
Base62 uses 62 characters: digits (0-9), lowercase letters (a-z), and uppercase letters (A-Z). It converts a number into a compact string. For example, ID 1 becomes 1, ID 62 becomes 10, and ID 238328 becomes ZZZ.
With just 7 characters, Base62 can represent over 3.5 trillion unique codes. That is more than enough for any URL shortener.
Before writing the main application file, you need to create two supporting modules it depends on.
- Create app/schemas.py with the API request and response models:
from pydantic import BaseModel, HttpUrl
from datetime import datetime
class ShortenRequest(BaseModel):
url: HttpUrl
class ShortenResponse(BaseModel):
short_code: str
short_url: str
original_url: str
class StatsResponse(BaseModel):
short_code: str
original_url: str
clicks: int
created_at: datetime
What are Pydantic schemas?
Pydantic models define the shape of data your API accepts and returns. ShortenRequest validates that the incoming url field is a valid HTTP URL. ShortenResponse defines exactly what the API sends back.
FastAPI uses these schemas to automatically validate requests and generate API documentation.
- Save app/schemas.py.
- Create app/cache.py with the Redis cache helpers:
import redis
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
CACHE_TTL = 3600 # 1 hour
def get_cached_url(short_code: str) -> str | None:
return redis_client.get(f"url:{short_code}")
def set_cached_url(short_code: str, original_url: str) -> None:
redis_client.set(f"url:{short_code}", original_url, ex=CACHE_TTL)
What does the cache layer do?
This module connects to the Redis container you started in Step 1. It provides two helper functions: one to check if a short code is already cached, and one to store a URL in the cache.
CACHE_TTL = 3600 means cached entries expire after one hour. This is the cache-aside pattern: check the cache first, only hit the database on a cache miss, then backfill the cache.
- Save app/cache.py.
- Create app/main.py with the application setup and the Base62 encoding function:
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.database import engine, get_db, Base
from app.models import ShortLink
from app.schemas import ShortenRequest, ShortenResponse, StatsResponse
from app.cache import get_cached_url, set_cached_url
app = FastAPI(title="URL Shortener", version="1.0.0")
BASE_URL = "http://localhost:8000"
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode_base62(num: int) -> str:
if num == 0:
return ALPHABET[0]
result = []
while num > 0:
result.append(ALPHABET[num % 62])
num //= 62
return "".join(reversed(result))
@app.on_event("startup")
def on_startup():
Base.metadata.create_all(bind=engine)
How does encode_base62 work?
ALPHABET contains all 62 URL-safe characters. The function repeatedly divides the number by 62, collecting the remainder as a character each time. Then it reverses the collected characters to produce the final short code.
The on_startup handler creates the database tables when the server starts. This calls SQLAlchemy's create_all() which reads your model definitions and creates matching tables in PostgreSQL.
- Save app/main.py.
Editor showing unresolved imports?
If your editor underlines the from app. imports in red, make sure your virtual environment is activated and selected as the Python interpreter in your editor. The installed packages need to be visible to the linter.
Help me fix unresolved import errors in my FastAPI project
Build the POST /shorten endpoint
You have the database model, the encoding function, and the supporting modules. Now you will wire them together into an API endpoint that accepts a URL and returns a short code.
- In app/main.py, find the on_startup function. Add the following endpoint code below it:
@app.post("/shorten", response_model=ShortenResponse)
def shorten_url(request: ShortenRequest, db: Session = Depends(get_db)):
link = ShortLink(original_url=str(request.url), short_code="placeholder")
db.add(link)
db.flush()
short_code = encode_base62(link.id)
link.short_code = short_code
db.commit()
db.refresh(link)
set_cached_url(short_code, link.original_url)
return ShortenResponse(
short_code=short_code,
short_url=f"{BASE_URL}/{short_code}",
original_url=link.original_url,
)
What does the shorten endpoint do?
This endpoint uses a two-phase commit pattern:
- First, it inserts a row with a placeholder short_code and calls db.flush() to get the auto-generated id without committing.
- Then it converts that id into a Base62 short code, updates the row, and commits.
- Finally, it pre-populates the Redis cache so the first redirect is a cache hit.
This guarantees every short code is unique because it derives directly from the unique database ID.
Why flush before commit?
db.flush() sends the INSERT to PostgreSQL and populates link.id with the auto-incremented value. But the transaction stays open, so you can update short_code before committing. This avoids needing a second UPDATE query.
- Save app/main.py.
You will not see a change yet because the server is not running. You will start it and test all endpoints in the next step.
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files: database.py, models.py, schemas.py, cache.py, and main.py.
ⓧ I'd like to double check the full code
Here is the complete app/database.py:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/urlshortener"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Here is the complete app/models.py:
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from app.database import Base
class ShortLink(Base):
__tablename__ = "short_links"
id = Column(Integer, primary_key=True, index=True)
short_code = Column(String(10), unique=True, index=True, nullable=False)
original_url = Column(String(2048), nullable=False)
clicks = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
Here is the complete app/schemas.py:
from pydantic import BaseModel, HttpUrl
from datetime import datetime
class ShortenRequest(BaseModel):
url: HttpUrl
class ShortenResponse(BaseModel):
short_code: str
short_url: str
original_url: str
class StatsResponse(BaseModel):
short_code: str
original_url: str
clicks: int
created_at: datetime
Here is the complete app/cache.py:
import redis
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
CACHE_TTL = 3600 # 1 hour
def get_cached_url(short_code: str) -> str | None:
return redis_client.get(f"url:{short_code}")
def set_cached_url(short_code: str, original_url: str) -> None:
redis_client.set(f"url:{short_code}", original_url, ex=CACHE_TTL)
Here is the complete app/main.py:
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.database import engine, get_db, Base
from app.models import ShortLink
from app.schemas import ShortenRequest, ShortenResponse, StatsResponse
from app.cache import get_cached_url, set_cached_url
app = FastAPI(title="URL Shortener", version="1.0.0")
BASE_URL = "http://localhost:8000"
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode_base62(num: int) -> str:
if num == 0:
return ALPHABET[0]
result = []
while num > 0:
result.append(ALPHABET[num % 62])
num //= 62
return "".join(reversed(result))
@app.on_event("startup")
def on_startup():
Base.metadata.create_all(bind=engine)
@app.post("/shorten", response_model=ShortenResponse)
def shorten_url(request: ShortenRequest, db: Session = Depends(get_db)):
link = ShortLink(original_url=str(request.url), short_code="placeholder")
db.add(link)
db.flush()
short_code = encode_base62(link.id)
link.short_code = short_code
db.commit()
db.refresh(link)
set_cached_url(short_code, link.original_url)
return ShortenResponse(
short_code=short_code,
short_url=f"{BASE_URL}/{short_code}",
original_url=link.original_url,
)
Your database layer, encoding logic, and first endpoint are complete. Next up, you will add the redirect endpoint with Redis caching and the stats endpoint to see your URL shortener in action.
Add the Redirect Endpoint with Redis Caching
Your URL shortener can create short codes, but it can't actually redirect anyone yet. When someone visits a short URL like http://localhost:8000/1, the server needs to look up the original URL and send the browser there instantly.
URL shorteners are read-heavy systems. For every URL shortened, that short link gets clicked roughly 100 times. In this step, you'll add a Redis cache layer so redirects happen at memory speed instead of hitting the database every time.
In this step, get ready to:
- Build the redirect endpoint with the cache-aside pattern.
- Build the stats endpoint to see click counts.
- Understand how cache-aside optimizes read-heavy workloads.
Build the redirect endpoint
When a browser visits a short URL, your server needs to find the original URL and send the browser there with a 302 redirect. A 302 tells the browser "this resource is temporarily at another location" so the browser follows the redirect automatically.
- In app/main.py, find the closing line of the shorten_url function (the final return ShortenResponse(...) block).
- Below it, add the redirect endpoint by copying this code:
@app.get("/{short_code}")
def redirect_to_url(short_code: str, db: Session = Depends(get_db)):
# Check Redis cache first for fast lookups
cached_url = get_cached_url(short_code)
if cached_url:
# Cache hit: increment clicks in the database
db.query(ShortLink).filter(ShortLink.short_code == short_code).update(
{"clicks": ShortLink.clicks + 1}
)
db.commit()
return RedirectResponse(url=cached_url, status_code=302)
# Cache miss: query PostgreSQL for the original URL
link = db.query(ShortLink).filter(ShortLink.short_code == short_code).first()
if not link:
raise HTTPException(status_code=404, detail="Short link not found")
# Backfill the cache so the next request is fast
set_cached_url(short_code, link.original_url)
link.clicks += 1
db.commit()
return RedirectResponse(url=link.original_url, status_code=302)
What does this code do?
- get_cached_url(short_code) checks Redis for the original URL. If it's there (cache hit), the function redirects immediately without touching PostgreSQL.
- On a cache miss, the function queries the short_links table in PostgreSQL. If the code doesn't exist, it returns a 404 error.
- set_cached_url(short_code, link.original_url) backfills Redis so the next visitor gets a cache hit.
- RedirectResponse(url=..., status_code=302) sends the browser to the original URL with an HTTP 302 (Found) status.
Why 302 instead of 301?
A 301 (Permanent Redirect) tells the browser to cache the redirect forever. The browser will never hit your server again for that short URL, which means you can't track clicks accurately.
A 302 (Temporary Redirect) ensures the browser asks your server every time. This lets you count every click and change the destination URL later if needed.
- Save app/main.py.
Seeing a syntax error?
- Make sure the new function is at the same indentation level as shorten_url (no extra indent). Each @app decorator should start at column 0.
- Check that you have a blank line between the end of shorten_url and the new @app.get decorator.
Help me fix a syntax error in my redirect endpoint.
Trace through the cache-aside pattern
The redirect endpoint implements a pattern called cache-aside (also known as lazy-loading). This is one of the most commonly tested concepts in system design interviews.
What is cache-aside?
Cache-aside means the application manages the cache explicitly. The cache and database don't talk to each other directly.
On a read request, the application checks the cache first. If the data is there (a cache hit), it returns immediately. If the data is missing (a cache miss), it queries the database, stores the result in the cache for next time, and then returns.
This pattern works well for read-heavy workloads like URL shorteners, where the same short codes get accessed repeatedly. The first click is slightly slower (cache miss), but every click after that is served from Redis at memory speed.
Why not cache every URL at creation time?
Your POST /shorten endpoint already calls set_cached_url when a link is created. This pre-warms the cache for new links. The cache-aside logic in the redirect endpoint handles the case where a cached entry has expired (after the 1-hour TTL) or was evicted.
Build the stats endpoint
The last piece of the API is an endpoint that returns analytics for a short link. This lets you check how many times a URL has been clicked.
- In app/main.py, add the stats endpoint below the redirect_to_url function:
@app.get("/stats/{short_code}", response_model=StatsResponse)
def get_stats(short_code: str, db: Session = Depends(get_db)):
# Look up the link in the database
link = db.query(ShortLink).filter(ShortLink.short_code == short_code).first()
if not link:
raise HTTPException(status_code=404, detail="Short link not found")
# Return the stats including click count
return StatsResponse(
short_code=link.short_code,
original_url=link.original_url,
clicks=link.clicks,
created_at=link.created_at,
)
What does this code do?
- response_model=StatsResponse tells FastAPI to validate the response against your Pydantic schema and generate documentation automatically.
- The endpoint queries PostgreSQL directly (no caching needed here since stats requests are infrequent compared to redirects).
- It returns the short code, original URL, total click count, and creation timestamp.
- Save app/main.py.
Getting an import error?
Make sure StatsResponse is included in the import line at the top of app/main.py. It should read:
from app.schemas import ShortenRequest, ShortenResponse, StatsResponse
Help me fix an import error in my stats endpoint.
✔️ Awesome, I've got everything!
Great. Double check you've saved app/main.py and move on to the next step where you'll test all three endpoints.
ⓧ I'd like to double check the full code
Here's the complete app/main.py at this point:
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.database import engine, get_db, Base
from app.models import ShortLink
from app.schemas import ShortenRequest, ShortenResponse, StatsResponse
from app.cache import get_cached_url, set_cached_url
app = FastAPI(title="URL Shortener", version="1.0.0")
BASE_URL = "http://localhost:8000"
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode_base62(num: int) -> str:
if num == 0:
return ALPHABET[0]
result = []
while num > 0:
result.append(ALPHABET[num % 62])
num //= 62
return "".join(reversed(result))
@app.on_event("startup")
def on_startup():
Base.metadata.create_all(bind=engine)
@app.post("/shorten", response_model=ShortenResponse)
def shorten_url(request: ShortenRequest, db: Session = Depends(get_db)):
link = ShortLink(original_url=str(request.url), short_code="placeholder")
db.add(link)
db.flush()
short_code = encode_base62(link.id)
link.short_code = short_code
db.commit()
db.refresh(link)
set_cached_url(short_code, link.original_url)
return ShortenResponse(
short_code=short_code,
short_url=f"{BASE_URL}/{short_code}",
original_url=link.original_url,
)
@app.get("/{short_code}")
def redirect_to_url(short_code: str, db: Session = Depends(get_db)):
cached_url = get_cached_url(short_code)
if cached_url:
db.query(ShortLink).filter(ShortLink.short_code == short_code).update(
{"clicks": ShortLink.clicks + 1}
)
db.commit()
return RedirectResponse(url=cached_url, status_code=302)
link = db.query(ShortLink).filter(ShortLink.short_code == short_code).first()
if not link:
raise HTTPException(status_code=404, detail="Short link not found")
set_cached_url(short_code, link.original_url)
link.clicks += 1
db.commit()
return RedirectResponse(url=link.original_url, status_code=302)
@app.get("/stats/{short_code}", response_model=StatsResponse)
def get_stats(short_code: str, db: Session = Depends(get_db)):
link = db.query(ShortLink).filter(ShortLink.short_code == short_code).first()
if not link:
raise HTTPException(status_code=404, detail="Short link not found")
return StatsResponse(
short_code=link.short_code,
original_url=link.original_url,
clicks=link.clicks,
created_at=link.created_at,
)
Your API now has all three endpoints wired up. Next, you'll start the server and test the full flow end to end.
Test Your System and Document the Design
Your URL shortener has all three endpoints built and wired up to both PostgreSQL and Redis. Now it's time to see the whole system working together.
In this step, you'll start the server, test each endpoint through FastAPI's built-in Swagger UI, watch the cache in action, and document your architecture decisions.
In this step, get ready to:
- Start the FastAPI server and test all three endpoints.
- Observe Redis caching behavior in real time.
- Write a system design document covering architecture, trade-offs, and scaling.
Start the FastAPI server and test your endpoints
FastAPI automatically generates interactive API documentation at the /docs endpoint. You can test every endpoint directly in the browser without needing a separate tool.
- Start the FastAPI development server by running this command:
uvicorn app.main:app --reload
What does this command do?
- uvicorn is the ASGI server that runs your FastAPI app.
- app.main:app tells uvicorn to look inside the app/main.py file and find the variable named app.
- --reload makes the server restart automatically whenever you save a file.
- You should see output that includes Uvicorn running on http://127.0.0.1:8000.
Seeing a connection error?
- Make sure your PostgreSQL and Redis containers are still running. Run docker compose ps to check their status.
- Confirm your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
My server won't start and I'm seeing a database connection error.
Now let's test each endpoint using the Swagger UI.
- Open your browser and go to http://localhost:8000/docs.
- You should see an interactive documentation page titled URL Shortener with all three endpoints listed.
Test the shorten endpoint first.
- Click on the POST /shorten section to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{
"url": "https://www.wikipedia.org/wiki/URL_shortening"
}
- Click Execute.
- You should see a 200 response with a short_code, a short_url, and the original_url you submitted.
What is the short code?
The short_code is your Base62-encoded database ID. Your first shortened URL will have the code 1 (the Base62 encoding of ID 1). As you shorten more URLs, the codes get longer: 2, 3, all the way up to multi-character codes like 1a, 2B, etc.
Now test the redirect endpoint.
- Copy the short_code from the response (e.g. 1).
- Open a new browser tab and go to http://localhost:8000/1 (replace 1 with your actual short code).
- You should be redirected to the Wikipedia URL shortening page.
Finally, test the stats endpoint.
- Switch back to the Swagger UI tab.
- Click on the GET /stats/{short_code} section to expand it.
- Click Try it out.
- Enter your short code (e.g. 1) in the short_code field.
- Click Execute.
- You should see a 200 response showing clicks: 1 (from the redirect you just triggered).
Observe caching in action
The cache-aside pattern is invisible to the API consumer. Let's peek behind the scenes to confirm Redis is doing its job.
- Open a new terminal window (keep the uvicorn server running in the first one).
- Connect to the Redis container by running this command:
docker exec -it url-shortener-redis redis-cli
- You should see a 127.0.0.1:6379> prompt. This means you're inside the Redis CLI.
- List all cached URL keys by running:
KEYS url:*
- You should see at least one key like url:1 (matching the short code you created earlier).
- Check the cached value by running:
GET url:1
- You should see the original URL you shortened (e.g. https://www.wikipedia.org/wiki/URL_shortening).
Why does this matter?
When someone visits your short URL, Redis serves the redirect in microseconds without touching PostgreSQL. For a read-heavy system (URL shorteners typically see a 100:1 read-to-write ratio), this caching layer is what keeps response times fast under load.
- Now go back to your browser and visit your short URL one more time (http://localhost:8000/1).
- Return to the Swagger UI and check the stats endpoint again.
- The clicks count should now be 2.
Where did the click count increase?
Both the cache-hit path and the cache-miss path increment the click counter in PostgreSQL. Redis only caches the URL mapping, not the analytics. This keeps your stats accurate even when serving from cache.
- Type exit to leave the Redis CLI when you're finished exploring.
Write your system design document
A working system is only half the story. Documenting your design decisions is what separates a coding exercise from interview-ready knowledge.
- Create a new file called DESIGN.md in your project's root folder (the url-shortener directory).
Your design document should cover three sections. Use the template below as a starting point.
- Copy this template into your DESIGN.md file:
# URL Shortener - System Design Document
## Architecture Overview
- **Components**: FastAPI (API layer), PostgreSQL (persistent storage), Redis (cache layer)
- **Pattern**: Cache-aside (lazy-loading) for read-heavy optimization
- **Encoding**: Base62 from auto-increment database IDs for short code generation
## Trade-offs
### 302 vs 301 Redirects
- We chose 302 (temporary redirect) because browsers don't cache it.
- This means every visit hits our server, letting us track click analytics.
- 301 (permanent redirect) would be faster for repeat visitors but we'd lose visibility.
### Cache TTL (1 hour)
- Short enough that deleted/updated URLs become unreachable within an hour.
- Long enough that popular links stay cached through traffic spikes.
- Trade-off: stale cache for up to 1 hour vs database load from frequent cache misses.
### Base62 Encoding vs Hashing
- Base62 from sequential IDs is collision-free by design.
- Hashing (MD5/SHA-256 + truncation) introduces collision risk requiring resolution logic.
- Trade-off: Base62 is simpler and faster, but codes are predictable (sequential).
## Scaling Strategies
### If reads increase (more redirects)
- Add Redis replicas for read scaling.
- Introduce a CDN layer for the most popular short codes.
### If writes increase (more shortening requests)
- Partition the database by ID range.
- Pre-generate short code batches to avoid sequential bottlenecks.
### If the system goes global
- Deploy to multiple regions with a shared database or eventual consistency.
- Use a distributed ID generator (like Snowflake) instead of auto-increment.
What makes a good system design document?
Interviewers want to see that you understand why you made each choice. For every decision, name the alternative, explain the trade-off, and state which use case would flip your choice.
Feel free to expand any section with your own observations from testing. The template is a starting point, not the final answer.
- Save DESIGN.md.
- Read through each section and personalize it based on what you observed during testing.
Make it yours
Add a section about what surprised you during testing. Did the first request feel slower than subsequent ones? That's the cache-miss penalty in action, and noting it shows real understanding.
Secret mission
Add a Rate Limiter with Redis
Your URL shortener works, but what stops someone from spamming your POST /shorten endpoint with thousands of requests? In production, every public API needs a rate limiter. Use Redis to build one that limits each IP to 10 requests per minute and returns HTTP 429 when exceeded.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. The PostgreSQL and Redis containers use minimal disk space while running.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL container (url-shortener-postgres) running via Docker Desktop.
- Redis container (url-shortener-redis) running via Docker Desktop.
- Docker volume (postgres_data) storing your PostgreSQL data.
- Python virtual environment and project files on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep testing your URL shortener.
- Your PostgreSQL and Redis containers will continue running in Docker Desktop.
- You can restart the FastAPI server any time with uvicorn app.main:app --reload from your project folder.
- Docker Desktop will use some memory while running in the background.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your data and files stay intact for next time.
- Stop the FastAPI server by pressing Ctrl+C in the terminal where it's running.
- Stop the Docker containers by running this from your project folder:
docker compose stop
- Your database data is preserved in the Docker volume. When you're ready to resume, run docker compose start to bring everything back.
- You can also quit Docker Desktop entirely from the menu bar icon to free up more memory.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your containers, database data, and project files permanently.
- Stop the FastAPI server by pressing Ctrl+C in the terminal where it's running.
- Remove the Docker containers and volumes by running this from your project folder:
docker compose down -v
- Deactivate your Python virtual environment:
deactivate
- Delete the project folder from your Desktop:
cd ~/Desktop
rm -rf url-shortener
- If you no longer need Docker Desktop, open it, click the Troubleshoot icon (bug icon in the top-right), then click Uninstall. This removes Docker Desktop and all Docker data from your machine.
Nice Work!
Nice Work!
You just designed and built a production-style URL shortener from scratch. Your service takes long URLs, generates short codes, and redirects users with fast cached lookups across a multi-service architecture.
You've learned how to:
- Build a multi-service architecture with FastAPI, PostgreSQL, and Redis orchestrated by Docker Compose.
- Implement the cache-aside pattern with Redis to optimize a read-heavy system for sub-millisecond redirects.
- Use Base62 encoding to generate collision-free short codes from database IDs, the same approach used by real URL shorteners.
- Added a Redis-based rate limiter to protect your API from abuse using a sliding window counter.
Ready to quiz yourself?