Build a URL Shortener API with Caching

Build a URL shortener API with PostgreSQL, Redis caching, and Base62 encoding.

Introduction

30 Second Summary

Every time you share a link, you probably paste a short URL instead of a massive string of characters. Behind that simple redirect is a backend system juggling databases, encoding algorithms, and caching layers.

In this project, you will build a URL shortener API from scratch that takes long URLs, generates short codes, stores mappings in a database, and speeds up repeated lookups with a cache.

What You'll Build

A REST API service built with FastAPI that shortens URLs using Base62 encoding, persists them in PostgreSQL, and caches lookups in Redis.

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

  • A FastAPI application with REST endpoints for creating short URLs and redirecting users to original URLs.
  • A Docker Compose setup orchestrating PostgreSQL and Redis containers on your local machine.
  • A cache-aside pattern implementation with Redis that demonstrably improves response times on repeated lookups.
  • Secret Mission: Add click analytics that track how many times each short URL has been accessed.

Are there any prerequisites?

You should have basic Python knowledge and familiarity with HTTP concepts like GET/POST requests and status codes. No prior experience with FastAPI, PostgreSQL, or Redis is required.

Before We Start

Before you can build your URL shortener, you need a project folder, an isolated Python environment, and the right packages installed.

In this step, you will set up the directory structure and install the four libraries that power your API: FastAPI for the web framework, asyncpg for PostgreSQL, redis for caching, and uvicorn as the server.

In this step, get ready to:
  • Create the project folder and Python virtual environment.
  • Install the project dependencies.
  • Set up the app package structure.
Create the project folder

Every project needs a home. You will create a dedicated folder on your Desktop so you can always find it easily.

  • 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
  • Confirm the folder was created by running:
ls

The folder is empty for now, so you will see no output. That is expected.

Set up the virtual environment and install dependencies

A virtual environment keeps your project's packages separate from your system Python. This prevents version conflicts between projects.

  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here only exist for this project, so they cannot break other projects on your machine.

  • Activate the virtual environment:

macOS

  • Activate by running:
source venv/bin/activate

Windows

  • Activate by running:
venv\Scripts\activate

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

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser in PowerShell, then try the activate command again.

On macOS, double check you typed source before the path. Without it, the shell tries to run the file directly instead of sourcing it into your session.

Having trouble activating your virtual environment?

Now you need to create a requirements.txt file that lists the exact package versions your project depends on.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the url-shortener folder, then click Open.
  • Create a new file by clicking the new file icon in the Explorer sidebar, or pressing Cmd+N (macOS) or Ctrl+N (Windows).
  • Name the file requirements.txt.
  • Paste the following content into the file:
fastapi[standard]==0.139.0
asyncpg==0.31.0
redis==8.0.1
uvicorn==0.34.0

What are these packages?

  • fastapi[standard] is the web framework that handles HTTP requests and auto-generates API documentation.
  • asyncpg is a fast, async PostgreSQL driver that lets your API talk to the database without blocking.
  • redis is the official Redis client for Python, with built-in async support.
  • uvicorn is the ASGI server that runs your FastAPI application.
  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Now install the packages from your requirements file.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on both macOS and Windows).
  • Install all four packages by running this command:
pip install -r requirements.txt

You should see pip downloading and installing each package along with their dependencies. The install may take 30-60 seconds.

Seeing a permissions error or pip not found?

Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, re-run the activation command from earlier.

If VS Code opened a new terminal, it may not have the virtual environment active. Close that terminal and open a new one, then activate again.

Help me fix my pip install error.

Create the app package

Python uses packages (folders with an __init__.py file) to organize related modules. Your URL shortener code will live inside an app package.

  • Create the app directory and its __init__.py file by running these commands in the VS Code terminal:
mkdir app
touch app/__init__.py

What does __init__.py do?

The __init__.py file tells Python that the app folder is a package it can import from. The file can be empty. Its presence is what matters.

  • Confirm your project structure by running:
ls

You should see app, requirements.txt, and venv listed in the output.

Missing the app folder or __init__.py?

On Windows, touch may not be available. Instead, right-click inside the app folder in the VS Code Explorer sidebar and select New File. Name it __init__.py and save it empty.

Help me create my app package structure.

✔️ Awesome, I've got everything!

Great. Double check you have saved requirements.txt and that your virtual environment is still active.

ⓧ I'd like to double check the full code

Here is what requirements.txt should look like:

fastapi[standard]==0.139.0
asyncpg==0.31.0
redis==8.0.1
uvicorn==0.34.0

And app/__init__.py should be an empty file (no content needed).

Your project skeleton is ready. Next up, you will build the database layer and the encoding algorithm that turns numeric IDs into short URL codes.

Set Up Docker Services

Your project directory and Python dependencies are ready. Now you need the infrastructure services that your API will talk to.

A URL shortener needs two backend services: a database to permanently store URL mappings, and a cache to speed up repeated lookups. In this step, you will spin up both services locally using Docker Compose.

In this step, get ready to:
  • Create a Docker Compose configuration defining PostgreSQL and Redis services.
  • Start the containers and verify both services are running and accessible.
Create the Docker Compose configuration

Docker Compose lets you define multiple services in a single YAML file and start them all with one command. Your URL shortener needs two services: PostgreSQL for persistent storage and Redis for caching.

  • In VS Code, create a new file called docker-compose.yml in your project root (the same folder that contains requirements.txt).
  • Paste the following configuration into docker-compose.yml:
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: urlshortener
      POSTGRES_PASSWORD: urlshortener
      POSTGRES_DB: urlshortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:8
    ports:
      - "6379:6379"

volumes:
  pgdata:

What does this configuration do?

  • postgres pulls the official PostgreSQL 17 image and creates a database called urlshortener with matching credentials. Port 5432 is mapped so your Python app can connect from outside the container.
  • redis pulls the official Redis 8 image and maps port 6379 for the same reason.
  • pgdata is a named volume that persists your database data between container restarts. Without it, your stored URLs would vanish every time you stop the container.

Why Docker Compose instead of installing PostgreSQL and Redis directly?

Docker Compose gives you reproducible, disposable infrastructure with zero system-level configuration. You can start fresh by deleting a container instead of hunting down config files.

It also mirrors how backend services run in production. Understanding container orchestration is essential for backend engineering roles.

  • Save docker-compose.yml.
  • Confirm the file exists by running:
ls docker-compose.yml

You should see docker-compose.yml in the output.

Don't see the file?

  • Make sure you created the file in your project root, not inside the app/ folder.
  • Check for typos in the filename. It must be exactly docker-compose.yml (with a hyphen, not an underscore).

Why isn't my docker-compose.yml file showing up?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: urlshortener
      POSTGRES_PASSWORD: urlshortener
      POSTGRES_DB: urlshortener
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:8
    ports:
      - "6379:6379"

volumes:
  pgdata:
Start and verify the Docker containers

With the configuration file saved, you can now bring both services online with a single command.

  • Start the PostgreSQL and Redis containers in detached mode by running:
docker compose up -d

What does this command do?

docker compose up reads your docker-compose.yml and starts all defined services. The -d flag runs them in the background so you get your terminal back.

Docker will pull the images (this may take a minute on first run), then start both containers.

  • Verify both containers are running by checking their status:
docker compose ps

You should see two services listed with a status of running.

  • Verify PostgreSQL is accepting connections by running:
docker compose exec postgres pg_isready

You should see a message ending in accepting connections.

  • Verify Redis is responding by running:
docker compose exec redis redis-cli ping

You should see PONG printed in the terminal.

Containers not starting?

  • Make sure Docker Desktop is running. Look for the Docker whale icon in your system tray (Windows) or menu bar (macOS).
  • If you see a port conflict error, another process is already using port 5432 or 6379. Stop the conflicting process or change the host port in docker-compose.yml.
  • Run docker compose logs to see detailed error messages from the containers.

Help me troubleshoot my Docker containers not starting.

Your infrastructure is live. Both PostgreSQL and Redis are running in Docker containers, ready to accept connections from your Python application. Next up, you will build the database module and the encoding algorithm that turns numeric IDs into short URL codes.

Build the Database and Encoding Layer

Your environment is ready with Docker containers running. Now you need the two core building blocks that power every URL shortener: a database to store URLs and an encoding algorithm to generate short codes.

In this step you will write the PostgreSQL connection layer using asyncpg, create a Base62 encoder that turns database IDs into compact alphanumeric strings, and wire them together with a FastAPI lifespan that boots the database on startup.

In this step, get ready to:
  • Write the database module with connection pooling and URL storage functions.
  • Write the Base62 encoder that converts numeric IDs to short codes.
  • Test the database connection by starting the FastAPI app.
Write the database module

The database module handles everything related to PostgreSQL: connecting via a connection pool, creating the table schema, and providing async functions to insert and look up URLs.

  • Create a new file called app/database.py in VS Code.
  • Add the connection setup and schema initialization by pasting this code:
import asyncpg

DATABASE_URL = "postgresql://urlshortener:urlshortener@localhost:5432/urlshortener"

pool: asyncpg.Pool | None = None


async def init_db():
    global pool
    pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
    async with pool.acquire() as conn:
        await conn.execute("""
            CREATE TABLE IF NOT EXISTS urls (
                id BIGSERIAL PRIMARY KEY,
                short_code VARCHAR(10) UNIQUE,
                original_url TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT NOW()
            )
        """)
        await conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code)
        """)

What does this code do?

DATABASE_URL points to the PostgreSQL container you started in Step 1. The credentials (urlshortener/urlshortener) match the docker-compose.yml environment variables.

asyncpg.create_pool opens a reusable pool of database connections (between 2 and 10). Pooling avoids the overhead of opening a fresh connection for every request.

The urls table stores each URL with an auto-incrementing id, a short_code column (filled after encoding the ID), the original URL, and a timestamp. The index on short_code makes lookups fast.

You won't see a change yet. The next chunk adds the query functions that use this pool.

  • Below the init_db function in app/database.py, add the remaining database operations:

async def close_db():
    global pool
    if pool:
        await pool.close()


async def insert_url(original_url: str) -> int:
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "INSERT INTO urls (original_url) VALUES ($1) RETURNING id",
            original_url,
        )
        return row["id"]


async def update_short_code(url_id: int, short_code: str):
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE urls SET short_code = $1 WHERE id = $2",
            short_code,
            url_id,
        )


async def get_url_by_code(short_code: str) -> str | None:
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT original_url FROM urls WHERE short_code = $1",
            short_code,
        )
        return row["original_url"] if row else None

What do these functions do?

close_db() gracefully shuts down the connection pool when the app stops.

insert_url() adds a new URL and returns the auto-generated id. The $1 placeholder prevents SQL injection.

update_short_code() saves the generated short code back to the row after encoding. get_url_by_code() looks up the original URL by its short code for redirection.

  • Save app/database.py.

Seeing a syntax error?

Make sure the triple-quoted SQL strings (""") have matching opening and closing quotes. Each async def should be at the top level of the file, not nested inside another function.

help me fix a syntax error in my database.py file

✔️ Awesome, I've got everything!

Great. Double check you have saved app/database.py.

ⓧ I'd like to double check the full code

import asyncpg

DATABASE_URL = "postgresql://urlshortener:urlshortener@localhost:5432/urlshortener"

pool: asyncpg.Pool | None = None


async def init_db():
    global pool
    pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
    async with pool.acquire() as conn:
        await conn.execute("""
            CREATE TABLE IF NOT EXISTS urls (
                id BIGSERIAL PRIMARY KEY,
                short_code VARCHAR(10) UNIQUE,
                original_url TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT NOW()
            )
        """)
        await conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code)
        """)


async def close_db():
    global pool
    if pool:
        await pool.close()


async def insert_url(original_url: str) -> int:
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "INSERT INTO urls (original_url) VALUES ($1) RETURNING id",
            original_url,
        )
        return row["id"]


async def update_short_code(url_id: int, short_code: str):
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE urls SET short_code = $1 WHERE id = $2",
            short_code,
            url_id,
        )


async def get_url_by_code(short_code: str) -> str | None:
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT original_url FROM urls WHERE short_code = $1",
            short_code,
        )
        return row["original_url"] if row else None
Write the Base62 encoder

URL shorteners need a way to turn a numeric database ID (like 1, 2, 1000) into a compact string (like 1, 2, g8). Base62 encoding uses digits, lowercase letters, and uppercase letters to represent numbers in base 62.

  • Create a new file called app/encoder.py in VS Code.
  • Add the Base62 encoding and decoding functions by pasting this code:
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE = len(ALPHABET)


def encode(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    chars = []
    while num > 0:
        chars.append(ALPHABET[num % BASE])
        num //= BASE
    return "".join(reversed(chars))


def decode(short_code: str) -> int:
    num = 0
    for char in short_code:
        num = num * BASE + ALPHABET.index(char)
    return num

How does Base62 encoding work?

The ALPHABET string contains 62 characters: 0-9, a-z, and A-Z. Each character represents a digit in base 62 (just like 0-9 are digits in base 10).

encode() repeatedly divides the number by 62 and maps the remainder to a character. For example, database ID 1000 becomes g8 (16*62 + 8 = 1000).

decode() does the reverse: it walks each character and accumulates the numeric value. This lets the API recover the original database ID from a short code.

  • Save app/encoder.py.

You won't see output yet. The encoder is a pure function that the API endpoints will use in the next step.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/encoder.py.

ⓧ I'd like to double check the full code

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


def encode(num: int) -> str:
    if num == 0:
        return ALPHABET[0]
    chars = []
    while num > 0:
        chars.append(ALPHABET[num % BASE])
        num //= BASE
    return "".join(reversed(chars))


def decode(short_code: str) -> int:
    num = 0
    for char in short_code:
        num = num * BASE + ALPHABET.index(char)
    return num

Why Base62 instead of hashing?

Hash-based approaches (like MD5 or SHA) produce long strings that must be truncated, which introduces collision risk and requires retry logic. Base62 encoding of auto-increment IDs produces short, predictable, collision-free codes with zero retries.

This is the approach most production URL shorteners use. It maps directly to system design interviews where you would whiteboard this exact algorithm.

Test the database connection

Now you need to connect the database module to the FastAPI app so it initializes the connection pool when the server starts and closes it on shutdown.

  • Create a new file called app/main.py in VS Code.
  • Add the FastAPI app with a lifespan that boots the database by pasting this code:
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app import database


@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.init_db()
    yield
    await database.close_db()


app = FastAPI(title="URL Shortener", lifespan=lifespan)

What does the lifespan do?

The lifespan context manager runs init_db() before the app starts accepting requests and close_db() when the app shuts down. Everything before yield is startup logic. Everything after yield is shutdown logic.

This pattern replaces the deprecated on_event("startup") and on_event("shutdown") decorators from older versions of FastAPI.

  • Save app/main.py.
  • Start the development server by running this command in your terminal:
fastapi dev app/main.py
  • You should see output ending with Application startup complete. and a line showing Uvicorn running on http://127.0.0.1:8000. This means the database pool connected and the urls table was created successfully.
  • Press Ctrl+C to stop the server.

Seeing a connection refused error?

This means the PostgreSQL container is not running. Switch back to a separate terminal and run docker compose up -d to restart the containers. Then try the fastapi dev command again.

Also check that the DATABASE_URL in app/database.py matches the credentials in your docker-compose.yml (username, password, and database name should all be urlshortener).

help me fix a connection refused error when starting my FastAPI URL shortener app

Your database and encoding layers are solid. Next up, you will build the actual API endpoints that use these modules to shorten URLs and redirect users.

Build the API Endpoints

Your database layer stores URLs and your encoder converts IDs to short codes. Now it is time to wire these together with actual HTTP endpoints that clients can call.

In this step, you will define the API contract using Pydantic schemas, then build two endpoints: one to shorten a URL and one to redirect visitors to the original.

In this step, get ready to:
  • Create Pydantic request and response schemas for URL shortening.
  • Build a POST endpoint that shortens URLs and stores them in PostgreSQL.
  • Build a GET endpoint that redirects short codes to original URLs.
Create the request and response schemas

Before writing endpoint logic, you need to define the shape of data flowing in and out of your API. FastAPI uses Pydantic models for automatic request validation and response serialization.

  • Create a new file called app/schemas.py in your app/ folder, then add the following code:
from pydantic import BaseModel, HttpUrl


# Defines the expected JSON body for the POST /shorten endpoint
class ShortenRequest(BaseModel):
    url: HttpUrl


# Defines the JSON response shape returned after shortening
class ShortenResponse(BaseModel):
    short_url: str
    short_code: str
    original_url: str

What does this code do?

ShortenRequest accepts a single field called url typed as HttpUrl. Pydantic will automatically reject any request body where the URL is missing or malformed.

ShortenResponse defines the three fields that get returned to the client: the full shortened URL, just the short code, and the original URL for confirmation.

  • Save app/schemas.py.

Import error when you run later?

Make sure the file is saved inside the app/ directory (not the project root). The app/__init__.py file must also exist for Python to treat the folder as a package.

help me debug an import error with my schemas module

Build the POST /shorten endpoint

The POST endpoint is the core of a URL shortener. It receives a long URL, stores it in the database, generates a short code, and returns the shortened URL to the caller.

  • In app/main.py, find the import section at the top of the file:
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app import database
  • Replace those imports with the following expanded version:
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse

from app import database, encoder
from app.schemas import ShortenRequest, ShortenResponse

What changed in the imports?

You added HTTPException for returning error responses, RedirectResponse for HTTP redirects, and the encoder module to convert database IDs into short codes. The schemas you just created are also imported here.

  • Now add the BASE_URL constant and the POST endpoint. Find the line app = FastAPI(title="URL Shortener", lifespan=lifespan) and paste the following code below it:
BASE_URL = "http://localhost:8000"


@app.post("/shorten", response_model=ShortenResponse)
async def shorten_url(request: ShortenRequest):
    # Convert the validated URL object to a plain string
    original_url = str(request.url)
    # Insert the URL and get back the auto-generated database ID
    url_id = await database.insert_url(original_url)
    # Encode the numeric ID into a short alphanumeric code
    short_code = encoder.encode(url_id)
    # Store the short code alongside the URL in the database
    await database.update_short_code(url_id, short_code)
    return ShortenResponse(
        short_url=f"{BASE_URL}/{short_code}",
        short_code=short_code,
        original_url=original_url,
    )

What does this endpoint do?

  • BASE_URL defines where your API is running. The shortened URL is built by combining this base with the generated code.
  • The endpoint first inserts the URL into PostgreSQL, which returns an auto-incrementing ID.
  • That numeric ID gets encoded into a short code using your Base62 encoder (e.g., ID 1 becomes 1, ID 62 becomes 10).
  • Finally, the short code is saved back to the database row so you can look it up by code later.
  • Save app/main.py.
  • Restart your FastAPI dev server by running:
fastapi dev app/main.py
  • Open http://localhost:8000/docs in your browser. You should see the Swagger UI with a POST /shorten endpoint listed.

Seeing an import error on startup?

Check that your import lines match exactly. A common mistake is importing from app.schema (missing the s) instead of from app.schemas.

Also confirm your virtual environment is active. If you see module-not-found errors for fastapi or pydantic, reactivate it.

help me fix this import error in my FastAPI app

Build the GET redirect endpoint

The second endpoint handles the other side of URL shortening. When someone visits the short URL, this endpoint looks up the original URL and redirects the visitor there.

  • In app/main.py, add the following endpoint below the shorten_url function:
@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    # Look up the original URL from PostgreSQL
    original_url = await database.get_url_by_code(short_code)
    if not original_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    # Send a 307 temporary redirect to the original URL
    return RedirectResponse(url=original_url, status_code=307)

Why a 307 redirect?

A 307 Temporary Redirect tells the browser to go to the original URL while preserving the HTTP method. This is the standard choice for URL shorteners because it signals the redirect is not permanent.

If the short code does not exist in the database, the endpoint raises a 404 error with a clear message.

  • Save app/main.py.
  • Restart the dev server if it did not auto-reload, then refresh http://localhost:8000/docs. You should now see both POST /shorten and GET /{short_code} listed in the Swagger UI.

Endpoint not showing in Swagger?

Make sure the @app.get decorator is at the module level (not indented inside another function). The endpoint must be below the app = FastAPI(...) line.

Also check for syntax errors. A missing colon or unmatched parenthesis will prevent the module from loading.

help me figure out why my endpoint is not appearing

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/schemas.py and app/main.py.

ⓧ I'd like to double check the full code

Here is what app/schemas.py should look like:

from pydantic import BaseModel, HttpUrl


class ShortenRequest(BaseModel):
    url: HttpUrl


class ShortenResponse(BaseModel):
    short_url: str
    short_code: str
    original_url: str

Here is what app/main.py should look like:

from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse

from app import database, encoder
from app.schemas import ShortenRequest, ShortenResponse


@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.init_db()
    yield
    await database.close_db()


app = FastAPI(title="URL Shortener", lifespan=lifespan)

BASE_URL = "http://localhost:8000"


@app.post("/shorten", response_model=ShortenResponse)
async def shorten_url(request: ShortenRequest):
    original_url = str(request.url)
    url_id = await database.insert_url(original_url)
    short_code = encoder.encode(url_id)
    await database.update_short_code(url_id, short_code)
    return ShortenResponse(
        short_url=f"{BASE_URL}/{short_code}",
        short_code=short_code,
        original_url=original_url,
    )


@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    original_url = await database.get_url_by_code(short_code)
    if not original_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=original_url, status_code=307)

Your API now has two working endpoints. Next up, you will add a Redis caching layer so that repeated lookups skip the database entirely and return instantly.

Add Redis Caching

Your API can shorten URLs and redirect users. Every redirect currently hits PostgreSQL, even when the same short URL is requested hundreds of times in a row.

In this step, you will add a Redis cache in front of the database. The pattern you will implement is called cache-aside: check the cache first, and only query the database on a cache miss. This dramatically reduces database load for repeated lookups.

In this step, get ready to:
  • Write a cache module that connects to Redis and provides async get/set operations with a TTL.
  • Integrate the cache-aside pattern into your redirect endpoint.
  • Add observable cache headers and confirm the caching behavior with curl.
Write the cache module

Your cache module will wrap the Redis async client behind simple helper functions. This keeps your API code clean and makes it easy to swap or disable the cache layer later.

  • Create a new file called app/cache.py and add the following code:
import redis.asyncio as aioredis

REDIS_URL = "redis://localhost:6379"
CACHE_TTL_SECONDS = 3600

client: aioredis.Redis | None = None


async def init_cache():
    global client
    client = aioredis.Redis.from_url(REDIS_URL, decode_responses=True)


async def close_cache():
    global client
    if client:
        await client.aclose()


async def get_cached_url(short_code: str) -> str | None:
    if client is None:
        return None
    return await client.get(f"url:{short_code}")


async def set_cached_url(short_code: str, original_url: str):
    if client is None:
        return
    await client.set(f"url:{short_code}", original_url, ex=CACHE_TTL_SECONDS)

What does this code do?

  • import redis.asyncio as aioredis imports the async Redis client from the official redis-py package. This gives you non-blocking Redis operations that work with FastAPI's async handlers.
  • CACHE_TTL_SECONDS = 3600 sets cached entries to expire after one hour. This prevents stale URLs from living in the cache forever.
  • get_cached_url and set_cached_url use the key pattern url:{short_code} to namespace cache entries. The ex parameter sets the TTL in seconds.
  • Both functions gracefully handle a None client by returning early. This means your app still works even if Redis is unavailable.
  • Save app/cache.py.

You won't see a change yet. The next substep connects this module to your API.

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/cache.py before moving on.

ⓧ I'd like to double check the full code

import redis.asyncio as aioredis

REDIS_URL = "redis://localhost:6379"
CACHE_TTL_SECONDS = 3600

client: aioredis.Redis | None = None


async def init_cache():
    global client
    client = aioredis.Redis.from_url(REDIS_URL, decode_responses=True)


async def close_cache():
    global client
    if client:
        await client.aclose()


async def get_cached_url(short_code: str) -> str | None:
    if client is None:
        return None
    return await client.get(f"url:{short_code}")


async def set_cached_url(short_code: str, original_url: str):
    if client is None:
        return
    await client.set(f"url:{short_code}", original_url, ex=CACHE_TTL_SECONDS)
Integrate caching into the redirect endpoint

Now you will wire the cache module into your FastAPI app. The redirect endpoint will check Redis before querying PostgreSQL.

  • In app/main.py, find the import block at the top of the file:
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse

from app import database, encoder
from app.schemas import ShortenRequest, ShortenResponse
  • Replace the entire import block with this updated version that adds the cache import and a Response import:
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from starlette.responses import Response

from app import database, cache, encoder
from app.schemas import ShortenRequest, ShortenResponse
  • Next, find the lifespan function:
@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.init_db()
    yield
    await database.close_db()
  • Replace it with this version that initializes and closes the cache alongside the database:
@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.init_db()
    await cache.init_cache()
    yield
    await cache.close_cache()
    await database.close_db()

Why close cache before the database?

The lifespan shuts down services in reverse startup order. Cache closes first because it depends on the app being able to fall back to the database. If the database closed first, any in-flight cache misses would have nowhere to go.

Now for the main change. You will replace the GET endpoint with a version that implements the cache-aside pattern.

  • In app/main.py, find the existing redirect_to_url function and replace it with the following:
@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    # Check the cache first
    cached = await cache.get_cached_url(short_code)
    if cached:
        response = RedirectResponse(url=cached, status_code=307)
        response.headers["X-Cache"] = "HIT"
        return response

    # Cache miss - query the database
    original_url = await database.get_url_by_code(short_code)
    if not original_url:
        raise HTTPException(status_code=404, detail="Short URL not found")

    # Store in cache for next time
    await cache.set_cached_url(short_code, original_url)
    response = RedirectResponse(url=original_url, status_code=307)
    response.headers["X-Cache"] = "MISS"
    return response

What is the cache-aside pattern?

Cache-aside means the application manages the cache explicitly. On every read request, the code checks the cache first. If the data is there (a HIT), it returns immediately without touching the database.

If the data is not in the cache (a MISS), the code queries the database, stores the result in the cache for future requests, then returns the response. The X-Cache header makes this behavior observable from the outside.

  • Save app/main.py. Your dev server should auto-reload with the changes.

Seeing an import error on reload?

  • Make sure app/cache.py is saved. The from app import database, cache, encoder line will fail if the file does not exist yet.
  • Check that app/__init__.py exists (it can be empty). Python needs this file to treat app/ as a package.

If you are stuck, help me debug my import error in main.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/main.py and that the dev server reloaded without errors.

ⓧ I'd like to double check the full code

from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from starlette.responses import Response

from app import database, cache, encoder
from app.schemas import ShortenRequest, ShortenResponse


@asynccontextmanager
async def lifespan(app: FastAPI):
    await database.init_db()
    await cache.init_cache()
    yield
    await cache.close_cache()
    await database.close_db()


app = FastAPI(title="URL Shortener", lifespan=lifespan)

BASE_URL = "http://localhost:8000"


@app.post("/shorten", response_model=ShortenResponse)
async def shorten_url(request: ShortenRequest):
    original_url = str(request.url)
    url_id = await database.insert_url(original_url)
    short_code = encoder.encode(url_id)
    await database.update_short_code(url_id, short_code)
    return ShortenResponse(
        short_url=f"{BASE_URL}/{short_code}",
        short_code=short_code,
        original_url=original_url,
    )


@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    cached = await cache.get_cached_url(short_code)
    if cached:
        response = RedirectResponse(url=cached, status_code=307)
        response.headers["X-Cache"] = "HIT"
        return response

    original_url = await database.get_url_by_code(short_code)
    if not original_url:
        raise HTTPException(status_code=404, detail="Short URL not found")

    await cache.set_cached_url(short_code, original_url)
    response = RedirectResponse(url=original_url, status_code=307)
    response.headers["X-Cache"] = "MISS"
    return response
Test the cache headers

The X-Cache header lets you prove the cache is working without guessing. You will hit the same short URL twice and watch the header flip from MISS to HIT.

  • First, create a short URL to test with by running this command in a new terminal:
curl -X POST http://localhost:8000/shorten -H "Content-Type: application/json" -d '{"url": "https://www.example.com"}'

You should see a JSON response containing a short_code value (like 1 or 2 depending on how many URLs you have created so far).

  • Now request that short URL with the -i flag to see response headers. Replace 1 with your actual short code:
curl -i http://localhost:8000/1

You should see a 307 Temporary Redirect response with X-Cache: MISS in the headers. This means the URL was fetched from PostgreSQL and then stored in Redis.

  • Run the exact same command again:
curl -i http://localhost:8000/1

This time you should see X-Cache: HIT in the headers. The URL was served directly from Redis without touching PostgreSQL.

Why does this matter?

In production, a popular short URL might be requested thousands of times per second. Without caching, every single request would query the database. With cache-aside, only the first request hits the database. Every subsequent request within the TTL window is served from Redis, which operates entirely in memory.

Both requests showing MISS?

  • Check that your Redis container is running by running docker compose ps in your terminal. You should see the redis container in a running state.
  • Verify that init_cache() is called in your lifespan function. Without it, the client variable stays None and every cache read returns None.
  • Make sure you saved both app/cache.py and app/main.py and that the dev server reloaded successfully.

If you are still stuck, help me figure out why my Redis cache is not working.

Your URL shortener now has a proper caching layer. Next up, you will test the full system end-to-end and observe how gracefully the app handles a Redis outage.

Test the Full System

Your URL shortener has all the pieces in place: a database layer, a Base62 encoder, API endpoints, and a caching layer.

Now it is time to run the full system end-to-end and watch all the components work together. You will create short URLs, test redirection, and observe the cache-aside pattern in action through response headers.

In this step, get ready to:
  • Start the full system with Docker Compose and the FastAPI dev server.
  • Create short URLs and verify redirection works.
  • Confirm caching behavior by inspecting response headers.
Start the full system

You need two things running: the Docker containers (PostgreSQL and Redis) and the FastAPI development server.

  • Confirm your Docker containers are running by executing this command in your terminal:
docker compose up -d

What does this command do?

The -d flag runs the containers in detached mode (in the background). If they are already running from a previous step, Docker Compose will report that they are up to date.

  • You should see output confirming both the postgres and redis containers are running or already started.
  • Start the FastAPI development server by running:
fastapi dev app/main.py

You should see output showing the server is running at http://localhost:8000.

Seeing a connection error on startup?

If the app fails to connect to PostgreSQL or Redis, make sure the Docker containers are running. Run docker compose ps to check their status.

If a container shows as exited, run docker compose up -d again to restart it.

Help me debug a connection error when starting my FastAPI URL shortener app.

Create short URLs and verify redirection

With the server running, open a second terminal window to send requests. You will use curl to interact with the API directly.

  • Create your first short URL by running this command:
curl -X POST http://localhost:8000/shorten -H "Content-Type: application/json" -d '{"url": "https://www.example.com"}'

You should see a JSON response containing the short URL, the short code, and the original URL.

What should the response look like?

The response will be a JSON object with three fields: short_url (the full shortened link), short_code (the Base62-encoded ID), and original_url (the URL you submitted).

For your first URL, the short code will be 1 because encode(1) in Base62 is just the character 1.

  • Test the redirect endpoint by running this command (replace 1 with the short code from your response if it differs):
curl -i http://localhost:8000/1

You should see a 307 Temporary Redirect response with a location header pointing to https://www.example.com.

Why 307 instead of 301?

A 307 redirect preserves the original HTTP method. A 301 (permanent redirect) tells the browser to cache the redirect forever, which would make analytics tracking impossible later. Using 307 ensures every request reaches your server so you can track it.

Use the Swagger UI instead

If you prefer a visual interface, open http://localhost:8000/docs in your browser. FastAPI auto-generates interactive documentation where you can test both endpoints directly.

  • Create a few more short URLs to confirm the system handles multiple entries:
curl -X POST http://localhost:8000/shorten -H "Content-Type: application/json" -d '{"url": "https://github.com"}'
curl -X POST http://localhost:8000/shorten -H "Content-Type: application/json" -d '{"url": "https://fastapi.tiangolo.com"}'

Each response should return a unique short code (2, 3, etc.) with incrementing Base62 values.

Getting a 422 validation error?

Make sure your JSON body uses the key url (lowercase) and the value is a full URL including https://. Pydantic's HttpUrl validator rejects URLs without a scheme.

Help me fix a 422 validation error on my /shorten endpoint.

Verify caching by checking response headers

The real payoff of this project is seeing the cache-aside pattern work. Your X-Cache response header tells you exactly whether each request hit Redis or went all the way to PostgreSQL.

  • Send your first request to a short URL and check the headers:
curl -i http://localhost:8000/1
  • Look at the response headers. You should see X-Cache: MISS because this is the first lookup since the cache was empty.
  • Immediately send the same request again:
curl -i http://localhost:8000/1
  • This time you should see X-Cache: HIT in the response headers. Redis served the URL directly without touching PostgreSQL.

What just happened?

On the first request, the app checked Redis (empty), then queried PostgreSQL, stored the result in Redis, and returned the redirect with X-Cache: MISS.

On the second request, the app found the URL in Redis and returned immediately with X-Cache: HIT. The database was never touched. This is the cache-aside pattern in action.

Test graceful degradation

To see what happens without Redis, stop just the Redis container:

docker compose stop redis

Now hit the same short URL again. The app still works because get_cached_url returns None when Redis is unavailable, falling back to PostgreSQL. Start Redis again with docker compose start redis when you are done.

Both requests showing MISS?

If you used curl without the -i flag, you will not see the headers. Make sure you include -i to display response headers in the output.

If both are genuinely showing MISS, check that your Redis container is running with docker compose ps.

Help me debug why my Redis cache is not returning HITs.

Your URL shortener is fully operational. You have a working API that creates short codes, redirects users, and speeds up repeat lookups with Redis caching. Next up: the Secret Mission will challenge you to add click analytics on top of this system.

Secret mission

Add Click Analytics

Your URL shortener works, but you have no idea which links are popular. In this secret mission, you will add click tracking that counts every redirect and expose a stats endpoint so you can see how many times each short URL has been accessed.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Docker containers for PostgreSQL and Redis (running on your machine).
  • PostgreSQL database data (stored in the pgdata Docker volume).
  • Project files and Python virtual environment on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the API.

  • Your Docker containers consume minimal CPU and memory while idle.
  • The PostgreSQL and Redis containers will restart automatically if Docker Desktop is running.
  • Your shortened URLs and click data will persist in the pgdata volume.

Pause - I'll come back to this later

Shut down the running containers to free up memory, but keep your data and project files intact.

  • Open your terminal and navigate to the folder containing your docker-compose.yml file.
  • Stop the containers by running:
docker compose stop
  • This stops PostgreSQL and Redis without deleting any data.
  • When you're ready to resume, run docker compose start from the same folder to bring them back up.

Delete - I don't want to use this again

Remove all Docker containers, volumes, and project files completely.

  • Open your terminal and navigate to the folder containing your docker-compose.yml file.
  • Remove the containers and all associated data by running:
docker compose down -v
  • The -v flag removes the pgdata volume, deleting all stored URL and click data.
  • Delete the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf url-shortener

Windows

cd ~\Desktop
rmdir /s /q url-shortener
  • This removes all project files, including your Python virtual environment and application code.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-style URL shortener API from scratch, complete with database persistence, Base62 encoding, and a Redis caching layer.

You've learned how to:

  • Built a REST API with FastAPI featuring request validation, proper HTTP status codes, and auto-generated Swagger documentation.
  • Orchestrated multiple services (PostgreSQL and Redis) using Docker Compose, and implemented Base62 encoding to convert numeric IDs into short URL codes.
  • Implemented the cache-aside pattern with Redis to improve read performance, and observed the real difference between cached and uncached responses using the X-Cache header.
  • Secret Mission: Added click tracking analytics with a fire-and-forget counter and a dedicated stats endpoint.

Ready to quiz yourself?