Build a URL Shortener API with Analytics

Build a REST API that shortens URLs and tracks click analytics.

Introduction

30 Second Summary

Every time you share a link, you're relying on a system that maps short codes to long URLs. Services like bit.ly handle billions of these redirects daily using the same core principles you're about to build yourself.

In this project, you will build a URL shortener API from scratch using Python and FastAPI. You'll design the database, implement the encoding algorithm, and create endpoints that shorten URLs, redirect visitors, and track click analytics.

What You'll Build

A locally-running REST API that converts long URLs into short codes, redirects visitors to the original destination, and records every click for analytics.

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

  • A FastAPI application with four REST endpoints for shortening, redirecting, health checks, and analytics.
  • A SQLite database with two related tables storing URL mappings and click events.
  • A Base62 encoder that converts numeric IDs into compact alphanumeric short codes.
  • Interactive API documentation auto-generated at /docs where you can test all endpoints live.
  • Secret Mission: Add custom alias support so users can choose their own memorable short codes.

Are there any prerequisites?

You should be comfortable with Python fundamentals including functions, classes, dictionaries, and type hints. No prior experience with web frameworks or APIs is needed.

Set Up Your Project

To build the URL shortener API, you need a clean Python project with the right framework installed.

In this step, you'll create a dedicated project folder, set up an isolated Python environment, and get FastAPI running with a simple health check endpoint.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install FastAPI.
  • Create and run a minimal API server.
Create your project folder and virtual environment

A virtual environment keeps your project's packages isolated from the rest of your system. This prevents version conflicts between different projects on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (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 and activate it by running the commands for your operating system:

macOS

python3 -m venv venv
source venv/bin/activate

Windows

python -m venv venv
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 python3 -m venv venv part). On Windows, if you get a permissions error, try running PowerShell as Administrator.

help me activate my Python virtual environment

Install FastAPI

FastAPI is the Python framework you'll use to build your API endpoints. The [standard] extra installs everything you need to run the development server.

  • Install FastAPI and its dependencies by running:
pip install "fastapi[standard]"

You should see pip downloading and installing several packages. The install is complete when you see your terminal prompt return.

  • Verify FastAPI installed correctly by running:
python -c "import fastapi; print(fastapi.__version__)"

You should see a version number printed (such as 0.136.1 or newer).

Seeing ModuleNotFoundError?

Make sure (venv) appears in your prompt before running pip install. If you installed outside the virtual environment, activate it first, then re-run the install command.

help me install FastAPI in my virtual environment

Create a minimal FastAPI app and run it

Before building the full URL shortener, you'll confirm your setup works with a simple health check endpoint. This gives you a running server to build on in the next steps.

  • Open your code editor.
  • Open the url-shortener folder you just created on your Desktop.
  • Create a new file called main.py inside the url-shortener folder.
  • Copy this code into main.py:
from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
)


# Health check endpoint to confirm the server is running
@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}

What does this code do?

  • FastAPI(...) creates your API application with a title and description that appear in the auto-generated docs.
  • @app.get("/") decorates a function to handle GET requests at the root URL path.
  • The health_check function returns a JSON response confirming the server is alive.
  • Save main.py.
  • Back in your terminal, start the development server by running:
fastapi dev main.py

You should see output in your terminal indicating that Uvicorn is running and serving your app at http://127.0.0.1:8000.

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

You should see a JSON response:

{"status":"ok","message":"URL Shortener API is running"}
  • Now visit http://127.0.0.1:8000/docs in your browser.

You should see FastAPI's interactive Swagger UI documentation page. This page is auto-generated from your code and lets you test your API endpoints directly in the browser.

Page not loading in the browser?

Check your terminal for error messages. Make sure you saved main.py before running the server.

If you see a port already in use error, another process is using port 8000. Close other terminal windows or servers that might be running.

help me get my FastAPI server running

Why interactive docs?

FastAPI generates Swagger UI docs automatically from your code. You'll use this page throughout the project to test each endpoint without needing a separate tool like Postman or curl.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and keep your server running in the terminal.

ⓧ I'd like to double check the full code

Here's the complete main.py you should have so far:

from fastapi import FastAPI

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
)


@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}

Your development environment is ready and your server is running. Next up, you'll design the database that stores shortened URLs and click data.

Design the Database

Your FastAPI app is running and responding to requests. Now you need somewhere to actually store the URL mappings.

In this step, you'll design a SQLite database with two tables. One table stores the URL mappings (original URL to short code). The other table records every click on a shortened link for analytics.

In this step, get ready to:
  • Create a database connection helper and define the schema for two tables.
  • Write a startup function that automatically creates the tables when the app launches.
  • Confirm the database file appears on disk after restarting the server.
Set up the database connection

SQLite is a database that lives in a single file on disk. It comes built into Python's standard library, so there's nothing extra to install.

  • In your code editor, open main.py.
  • Replace the entire contents of the file with the following code:
import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone

from fastapi import FastAPI

# --- Database ---

DATABASE = "url_shortener.db"


def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn

What does this code do?

The imports bring in Python's built-in sqlite3 module, plus asynccontextmanager and datetime which you'll use shortly.

DATABASE is a constant that names the database file. get_db() opens a connection to that file and sets row_factory = sqlite3.Row so you can access columns by name (like a dictionary) instead of by index number.

Don't save just yet. You'll add the table definitions next.

Define the database tables

Your URL shortener needs two tables. The urls table maps short codes to original URLs. The clicks table records every time someone uses a short link, giving you analytics data.

  • Add the create_tables() function directly below get_db() by pasting this code:

def create_tables():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE,
            original_url TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS clicks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url_id INTEGER NOT NULL,
            clicked_at TEXT NOT NULL,
            FOREIGN KEY (url_id) REFERENCES urls (id)
        )
    """)
    conn.commit()
    conn.close()

What does this schema do?

The urls table has an auto-incrementing id (which you'll convert into the short code later), the generated short_code, the original_url the user submitted, and a created_at timestamp.

The clicks table uses a foreign key (url_id) to link each click back to the URL it belongs to. This normalized design lets you query click counts and timestamps per URL efficiently.

Why two separate tables instead of one?

You could add a click_count column to the urls table instead. But then you'd lose the individual timestamps. A separate clicks table lets you answer questions like "when was the last click?" or "how many clicks happened today?" without losing any data.

Don't save yet. You still need to connect this function to the app's startup so the tables get created automatically.

Wire the database to the app startup

FastAPI uses lifespan events to run code when the app starts up or shuts down. You'll wrap create_tables() in a lifespan function so the database is ready before any requests arrive.

  • Add the lifespan function below create_tables(), then replace the existing app = FastAPI(...) block with the updated version below:

# --- App Lifespan ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_tables()
    yield


# --- FastAPI App ---

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
    lifespan=lifespan,
)


@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}

What does the lifespan function do?

The @asynccontextmanager decorator turns lifespan into a context manager. Code before yield runs when the app starts. Code after yield would run on shutdown (you don't need any shutdown logic here).

Passing lifespan=lifespan to FastAPI() tells the framework to call your function every time the server starts. This guarantees the tables exist before your endpoints try to query them.

  • Save main.py.

Seeing an import error or syntax error?

  • Make sure the lifespan function is defined above the app = FastAPI(...) line. Python reads top to bottom, so the function must exist before it's referenced.
  • Check that from contextlib import asynccontextmanager appears at the top of the file.
  • Confirm there is a yield inside the lifespan function. Without it, FastAPI will raise an error on startup.

Help me debug a lifespan or import error in my FastAPI app

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py before moving on.

ⓧ I'd like to double check the full code

Here's the complete main.py at this point. Compare yours against it:

import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone

from fastapi import FastAPI

# --- Database ---

DATABASE = "url_shortener.db"


def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def create_tables():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE,
            original_url TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS clicks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url_id INTEGER NOT NULL,
            clicked_at TEXT NOT NULL,
            FOREIGN KEY (url_id) REFERENCES urls (id)
        )
    """)
    conn.commit()
    conn.close()


# --- App Lifespan ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_tables()
    yield


# --- FastAPI App ---

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
    lifespan=lifespan,
)


@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}
Test that the database file is created

If your development server is still running from Step 1, it will auto-reload when you save. If you stopped it, start it again now.

  • If your server is not running, start it by running this command in your terminal (make sure you're in the url-shortener project folder):
fastapi dev main.py
  • Confirm the database file was created by running this in a new terminal tab:
ls

You should see url_shortener.db listed alongside main.py in your project folder. That file is your SQLite database, created automatically by the lifespan function when the server started.

Don't see url_shortener.db?

  • Check your terminal for error messages. A typo in the create_tables() SQL will prevent the file from being created.
  • Make sure ls is running in the same folder as main.py. If you're in a different directory, the file won't appear.
  • Try stopping the server with Ctrl+C and running fastapi dev main.py again. Watch for errors on startup.

My url_shortener.db file isn't being created when I start my FastAPI server

  • Visit http://127.0.0.1:8000 in your browser to confirm the app still responds with the health check message.

Your database is designed and creating itself on startup. Next up, you'll build the Base62 encoder that turns numeric database IDs into compact short codes.

Build the Base62 Encoder

Your database is ready and waiting for URL records. Now you need a way to turn those numeric database IDs into short, shareable codes.

This is where Base62 encoding comes in. It converts a number like 1000 into a compact string like g8. In this step, you'll build the encoder function that powers your entire shortening system.

In this step, get ready to:
  • Understand how Base62 encoding compresses numbers into short strings.
  • Write the encoder function that converts database IDs into short codes.
  • Test the encoder with sample IDs to confirm it works.
Understand the Base62 math

You already know how decimal works. The number 1000 uses four digits from a set of 10 characters (0-9). Base62 uses a larger set of 62 characters, so it can represent the same number with fewer characters.

What is Base62 encoding?

Base62 uses 62 characters: the digits 0-9, lowercase letters a-z, and uppercase letters A-Z. Because it has more characters to work with than decimal (base 10), it can represent the same numbers in a shorter form.

For example, the decimal number 1000 becomes g8 in Base62. That's only 2 characters instead of 4. Seven Base62 characters can represent over 3.5 trillion unique values.

The conversion works just like converting decimal to binary, but dividing by 62 instead of 2. You repeatedly divide the number by 62 and map each remainder to a character.

Write the encode_base62 function
  • In main.py, find this line at the top of your imports:
import sqlite3
  • Add import string directly below it, so your first two imports look like this:
import sqlite3
import string
  • Now add the Base62 encoding section between your imports and the # --- Database --- comment. Copy this code below the from fastapi import FastAPI line:
# --- Base62 Encoding ---

BASE62_CHARS = string.digits + string.ascii_lowercase + string.ascii_uppercase


def encode_base62(num: int) -> str:
    if num == 0:
        return BASE62_CHARS[0]
    result = []
    while num > 0:
        num, remainder = divmod(num, 62)
        result.append(BASE62_CHARS[remainder])
    return "".join(reversed(result))

What does this code do?

  • BASE62_CHARS builds a string of all 62 characters by combining digits, lowercase, and uppercase letters.
  • encode_base62 takes a number (like a database ID) and repeatedly divides it by 62 using divmod.
  • Each remainder maps to one character in the Base62 alphabet. The results are collected in reverse order, then flipped back at the end.
  • The if num == 0 check handles the edge case where the input is zero, returning the first character (0).
  • Save main.py.

Seeing an import error?

Make sure import string is on its own line directly below import sqlite3. The string module is part of Python's standard library, so it does not need to be installed separately.

Help me fix an import error in my Base62 encoder code.

Test the encoder with sample IDs

Before wiring the encoder into your API, confirm it produces the expected output by testing it directly in Python.

  • Open a new terminal window (keep your FastAPI server running in the original one).
  • Activate your virtual environment, then start a Python session by running:
python3 -c "import string; BASE62_CHARS = string.digits + string.ascii_lowercase + string.ascii_uppercase; exec('''\ndef encode_base62(num):\n    if num == 0: return BASE62_CHARS[0]\n    result = []\n    while num > 0:\n        num, remainder = divmod(num, 62)\n        result.append(BASE62_CHARS[remainder])\n    return \"\".join(reversed(result))\nprint(encode_base62(1))\nprint(encode_base62(100))\nprint(encode_base62(1000))\n''')"
  • You should see this output:
1
1C
g8

What do these results mean?

  • ID 1 becomes 1. Single-digit IDs stay short.
  • ID 100 becomes 1C. Two characters instead of three.
  • ID 1000 becomes g8. Two characters instead of four. This is the compression power of Base62.

Getting different output?

Double check that your BASE62_CHARS string is built in the exact order: digits first, then lowercase, then uppercase. A different order changes which character maps to which number.

My Base62 encoder output doesn't match the expected values. Help me debug.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py before moving on.

ⓧ I'd like to double check the full code

Here's the full main.py as it should look at the end of this step:

import sqlite3
import string
from contextlib import asynccontextmanager
from datetime import datetime, timezone

from fastapi import FastAPI

# --- Base62 Encoding ---

BASE62_CHARS = string.digits + string.ascii_lowercase + string.ascii_uppercase


def encode_base62(num: int) -> str:
    if num == 0:
        return BASE62_CHARS[0]
    result = []
    while num > 0:
        num, remainder = divmod(num, 62)
        result.append(BASE62_CHARS[remainder])
    return "".join(reversed(result))


# --- Database ---

DATABASE = "url_shortener.db"


def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def create_tables():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE,
            original_url TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS clicks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url_id INTEGER NOT NULL,
            clicked_at TEXT NOT NULL,
            FOREIGN KEY (url_id) REFERENCES urls (id)
        )
    """)
    conn.commit()
    conn.close()


# --- App Lifespan ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_tables()
    yield


# --- FastAPI App ---

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
    lifespan=lifespan,
)


@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}

Your encoder is ready to turn database IDs into short codes. Next up, you'll create the API endpoint that accepts a long URL, saves it to the database, and hands back a freshly encoded short link.

Create the Shorten Endpoint

Your database is ready and the Base62 encoder is working. Now it's time to connect them together with the core feature of any URL shortener: the endpoint that accepts a long URL and returns a short code.

This endpoint needs to validate the incoming request, store the URL in the database, generate a short code from the auto-incremented ID, and return the result. FastAPI makes this straightforward with Pydantic models that define the exact shape of request and response data.

In this step, get ready to:
  • Define Pydantic models for request validation and response structure.
  • Create the POST /shorten endpoint that generates short codes.
  • Test the endpoint in the interactive Swagger UI.
Define a Pydantic model for request validation

Pydantic models tell FastAPI what shape incoming JSON data should have. If a request doesn't match the model, FastAPI automatically returns a helpful error message to the caller.

  • In main.py, find this import line near the top of the file:
from fastapi import FastAPI
  • Replace it with these two import lines:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

What do these imports add?

HTTPException lets you return proper HTTP error codes (like 404 Not Found) from your endpoints. You'll use it in the next step.

BaseModel from Pydantic is the foundation for defining data shapes. Any class that inherits from it becomes a validation schema.

  • Now find the @app.get("/") line (the health check endpoint) and add this code above it:
# --- Pydantic Models ---

# Validates the incoming request body
class URLRequest(BaseModel):
    url: str

# Shape of the response when a URL is shortened
class URLResponse(BaseModel):
    short_code: str
    short_url: str
    original_url: str

# Shape of the analytics response (used in a later step)
class AnalyticsResponse(BaseModel):
    short_code: str
    original_url: str
    total_clicks: int
    clicks: list[str]


# --- Endpoints ---

What do these models do?

URLRequest defines what the caller must send: a JSON body with a url field. FastAPI rejects anything that doesn't match.

URLResponse defines what the API sends back: the short code, the full short URL, and the original URL. FastAPI uses this to generate accurate documentation in Swagger UI.

AnalyticsResponse is defined now but used later. It structures the click analytics data you'll build in Step 6.

  • Save main.py.
  • Your server auto-reloads on save. Check the terminal to confirm there are no syntax errors.

Seeing a syntax error in the terminal?

Make sure the # --- Endpoints --- comment sits on its own line directly above @app.get("/"). If you accidentally placed the models inside the app = FastAPI(...) call, Python will throw an indentation or syntax error.

Help me fix a syntax error after adding Pydantic models to my FastAPI app

Create the POST /shorten endpoint

This is the main feature of your API. When someone sends a long URL, this endpoint stores it, generates a short code using Base62, and returns the result.

  • In main.py, add the shorten endpoint below the health_check function by pasting this code at the bottom of the file:
@app.post("/shorten", response_model=URLResponse, status_code=201)
async def shorten_url(request: URLRequest):
    conn = get_db()
    now = datetime.now(timezone.utc).isoformat()

    # Insert the URL and get its auto-incremented ID
    cursor = conn.execute(
        "INSERT INTO urls (original_url, created_at) VALUES (?, ?)",
        (request.url, now),
    )
    url_id = cursor.lastrowid
    # Convert the numeric ID into a short Base62 code
    short_code = encode_base62(url_id)

    # Store the short code back in the same row
    conn.execute(
        "UPDATE urls SET short_code = ? WHERE id = ?",
        (short_code, url_id),
    )
    conn.commit()
    conn.close()

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

What does this endpoint do?

The @app.post("/shorten") decorator registers this function as a POST endpoint. The response_model=URLResponse tells FastAPI to validate and document the response shape. The status_code=201 returns HTTP 201 Created (the proper code for creating a new resource).

Inside the function, the flow is: insert the URL into the database, grab the auto-incremented id via cursor.lastrowid, convert that number to Base62, then update the same row with the generated short code.

This two-step insert-then-update pattern is the counter-based approach. The database guarantees each id is unique, so there are zero collisions.

  • Save main.py.
  • Check your terminal. The server should reload without errors.

Server not reloading or showing an error?

Make sure the new function is at the same indentation level as health_check (no extra indentation). Also check that the f"http://127.0.0.1:8000/{short_code}" line uses an f prefix before the opening quote.

Help me debug my shorten_url endpoint that's causing an error on reload

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the server reloaded without errors.

ⓧ I'd like to double check the full code

Here's what your complete main.py should look like at this point:

import sqlite3
import string
from contextlib import asynccontextmanager
from datetime import datetime, timezone

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

# --- Base62 Encoding ---

BASE62_CHARS = string.digits + string.ascii_lowercase + string.ascii_uppercase


def encode_base62(num: int) -> str:
    if num == 0:
        return BASE62_CHARS[0]
    result = []
    while num > 0:
        num, remainder = divmod(num, 62)
        result.append(BASE62_CHARS[remainder])
    return "".join(reversed(result))


# --- Database ---

DATABASE = "url_shortener.db"


def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def create_tables():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE,
            original_url TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS clicks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url_id INTEGER NOT NULL,
            clicked_at TEXT NOT NULL,
            FOREIGN KEY (url_id) REFERENCES urls (id)
        )
    """)
    conn.commit()
    conn.close()


# --- App Lifespan ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_tables()
    yield


# --- FastAPI App ---

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
    lifespan=lifespan,
)


# --- Pydantic Models ---

class URLRequest(BaseModel):
    url: str


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


class AnalyticsResponse(BaseModel):
    short_code: str
    original_url: str
    total_clicks: int
    clicks: list[str]


# --- Endpoints ---

@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}


@app.post("/shorten", response_model=URLResponse, status_code=201)
async def shorten_url(request: URLRequest):
    conn = get_db()
    now = datetime.now(timezone.utc).isoformat()

    cursor = conn.execute(
        "INSERT INTO urls (original_url, created_at) VALUES (?, ?)",
        (request.url, now),
    )
    url_id = cursor.lastrowid
    short_code = encode_base62(url_id)

    conn.execute(
        "UPDATE urls SET short_code = ? WHERE id = ?",
        (short_code, url_id),
    )
    conn.commit()
    conn.close()

    return URLResponse(
        short_code=short_code,
        short_url=f"http://127.0.0.1:8000/{short_code}",
        original_url=request.url,
    )
Test the endpoint in Swagger UI

FastAPI auto-generates interactive documentation where you can test every endpoint directly in your browser. No need for a separate tool like Postman or curl.

  • Open your browser and go to http://127.0.0.1:8000/docs.
  • Find the POST /shorten endpoint in the list and click on it to expand it.
  • Click the Try it out button on the right.
  • Replace the example JSON in the request body with a real URL:
{
  "url": "https://www.example.com/some/very/long/path/that/needs/shortening"
}
  • Click Execute.

You should see a response with status code 201 and a JSON body containing your short code, the full short URL, and the original URL.

What should I see?

The response body should look something like this:

{
  "short_code": "1",
  "short_url": "http://127.0.0.1:8000/1",
  "original_url": "https://www.example.com/some/very/long/path/that/needs/shortening"
}

The first URL you shorten gets ID 1, which encodes to "1" in Base62. If you shorten a second URL, you'll get "2". The codes get more interesting at higher numbers (ID 1000 becomes "g8").

Getting a 422 Unprocessable Entity error?

This means the request body doesn't match what URLRequest expects. Make sure your JSON has a "url" key (not "link" or "URL"). The key is case-sensitive.

Help me fix a 422 error when testing my /shorten endpoint

Your API can now shorten URLs. Next up, you'll build the redirect endpoint so that visiting a short code actually sends you to the original URL.

Create the Redirect Endpoint

In the last step, you built the shorten endpoint that converts long URLs into short codes. Now it's time to make those short codes actually do something.

When someone visits a short URL, your API needs to look up the original URL and send the visitor there instantly. This is the core feature of any URL shortener, and you'll also record each visit so you can build analytics later.

In this step, get ready to:
  • Create a GET endpoint that catches any short code in the URL path.
  • Look up the original URL and redirect the visitor with HTTP 307.
  • Record each click in the database for analytics tracking.
Add the RedirectResponse import

Your redirect endpoint needs to return a special response type that tells the browser to go to a different URL. FastAPI provides RedirectResponse for exactly this purpose.

  • In main.py, find this import line near the top of the file:
from fastapi import FastAPI, HTTPException
  • Add a new line directly below it so both lines look like this:
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
Build the redirect endpoint

The redirect endpoint is a GET route that uses a path parameter. When someone visits http://127.0.0.1:8000/g8, FastAPI extracts g8 as the short_code parameter and passes it to your function.

  • Add the following endpoint at the bottom of main.py, below the shorten_url function:
@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    conn = get_db()

    # Look up the original URL by short code
    row = conn.execute(
        "SELECT id, original_url FROM urls WHERE short_code = ?",
        (short_code,),
    ).fetchone()

    # Return 404 if the short code doesn't exist
    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Short URL not found")

    # Record this click for analytics
    now = datetime.now(timezone.utc).isoformat()
    conn.execute(
        "INSERT INTO clicks (url_id, clicked_at) VALUES (?, ?)",
        (row["id"], now),
    )
    conn.commit()
    conn.close()

    # Send the visitor to the original URL
    return RedirectResponse(url=row["original_url"], status_code=307)

What does this code do?

  • The @app.get("/{short_code}") decorator creates a catch-all GET route. Any path like /abc or /g8 gets captured as the short_code parameter.
  • The SQL query looks up the short_code in the urls table. If nothing matches, we return a 404 error.
  • Before redirecting, we insert a row into the clicks table with the url_id and current timestamp. This is how analytics get tracked.
  • RedirectResponse with status_code=307 tells the browser to go to the original URL. Status 307 means temporary redirect.

Why 307 instead of 301?

A 301 redirect tells browsers the move is permanent. Browsers cache 301 redirects and skip your server on future visits. That would break your analytics because clicks would never reach your API.

A 307 redirect means temporary. The browser always hits your server, giving you the chance to record the click every single time.

  • Save main.py.
  • Check your terminal where the server is running. You should see a message indicating the server reloaded automatically.

Server showing an error?

  • Make sure the from fastapi.responses import RedirectResponse line is directly below from fastapi import FastAPI, HTTPException with no typos.
  • Check that the new endpoint is pasted after the closing return statement of shorten_url, not inside it. Indentation matters.
  • Confirm you have two blank lines between the end of shorten_url and the @app.get decorator.

Still stuck? Help me debug a FastAPI import or indentation error in my redirect endpoint

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the server reloaded without errors.

ⓧ I'd like to double check the full code

Here's the full main.py up to this point:

import sqlite3
import string
from contextlib import asynccontextmanager
from datetime import datetime, timezone

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

# --- Base62 Encoding ---

BASE62_CHARS = string.digits + string.ascii_lowercase + string.ascii_uppercase


def encode_base62(num: int) -> str:
    if num == 0:
        return BASE62_CHARS[0]
    result = []
    while num > 0:
        num, remainder = divmod(num, 62)
        result.append(BASE62_CHARS[remainder])
    return "".join(reversed(result))


# --- Database ---

DATABASE = "url_shortener.db"


def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def create_tables():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE,
            original_url TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS clicks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url_id INTEGER NOT NULL,
            clicked_at TEXT NOT NULL,
            FOREIGN KEY (url_id) REFERENCES urls (id)
        )
    """)
    conn.commit()
    conn.close()


# --- App Lifespan ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_tables()
    yield


# --- FastAPI App ---

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
    lifespan=lifespan,
)


# --- Pydantic Models ---

class URLRequest(BaseModel):
    url: str


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


class AnalyticsResponse(BaseModel):
    short_code: str
    original_url: str
    total_clicks: int
    clicks: list[str]


# --- Endpoints ---

@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}


@app.post("/shorten", response_model=URLResponse, status_code=201)
async def shorten_url(request: URLRequest):
    conn = get_db()
    now = datetime.now(timezone.utc).isoformat()

    cursor = conn.execute(
        "INSERT INTO urls (original_url, created_at) VALUES (?, ?)",
        (request.url, now),
    )
    url_id = cursor.lastrowid
    short_code = encode_base62(url_id)

    conn.execute(
        "UPDATE urls SET short_code = ? WHERE id = ?",
        (short_code, url_id),
    )
    conn.commit()
    conn.close()

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


@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    conn = get_db()

    row = conn.execute(
        "SELECT id, original_url FROM urls WHERE short_code = ?",
        (short_code,),
    ).fetchone()

    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Short URL not found")

    now = datetime.now(timezone.utc).isoformat()
    conn.execute(
        "INSERT INTO clicks (url_id, clicked_at) VALUES (?, ?)",
        (row["id"], now),
    )
    conn.commit()
    conn.close()

    return RedirectResponse(url=row["original_url"], status_code=307)
Test the redirect in your browser

Time to see the redirect in action. You'll visit a short URL directly in your browser and watch it bounce you to the original site.

  • First, you need a short code to test with. If you don't have one from earlier, switch to the Swagger UI at http://127.0.0.1:8000/docs and use the POST /shorten endpoint to shorten any URL (for example, https://www.wikipedia.org).
  • Copy the short_code value from the response (something like 1 or 2).
  • Open a new browser tab and visit http://127.0.0.1:8000/ followed by your short code (for example, http://127.0.0.1:8000/1).
  • Your browser should redirect you to the original URL you shortened.

What should I see?

Your browser briefly hits your local server, then lands on the original website (like Wikipedia). The address bar shows the original URL, not your short one. This confirms the 307 redirect is working.

  • Try visiting the same short URL 2-3 more times. Each visit records a new click in the database that you'll query in the next step.

Not getting redirected?

  • Make sure your server is still running. Check the terminal for errors.
  • Verify you're using a short code that actually exists. If you deleted url_shortener.db or restarted the database, you'll need to shorten a new URL first.
  • If you see {"detail":"Short URL not found"}, the short code doesn't match any row in the database. Try creating a fresh short URL at /docs.

Still stuck? Help me debug why my short URL isn't redirecting

Your URL shortener now handles the full loop. You shorten a URL, get a code, and visiting that code takes you to the original site. Next up, you'll build an analytics endpoint to see how many times each short URL has been clicked.

Build the Analytics Endpoint

Your URL shortener can now shorten links and redirect visitors. Every redirect silently records a click in the database. The missing piece is a way to actually see that data.

In this step, you'll build an analytics endpoint that queries the clicks table and returns usage statistics for any shortened URL. This is the read-heavy side of the system: URLs are created once, but their analytics get checked repeatedly.

In this step, get ready to:
  • Create the analytics endpoint that queries the clicks table.
  • Return a JSON response with the original URL, click count, and timestamps.
  • Test the full flow from shortening to clicking to viewing analytics.
Create the analytics endpoint

The analytics endpoint follows the same pattern as the redirect endpoint: look up the short code, then query related data. Instead of redirecting the user, it returns a JSON summary of all recorded clicks.

  • In main.py, find the redirect_to_url function (the last function in your file).
  • Add the following analytics endpoint below it:
@app.get("/analytics/{short_code}", response_model=AnalyticsResponse)
async def get_analytics(short_code: str):
    conn = get_db()

    # Look up the URL record by its short code
    row = conn.execute(
        "SELECT id, original_url FROM urls WHERE short_code = ?",
        (short_code,),
    ).fetchone()

    # Return 404 if the short code doesn't exist
    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Short URL not found")

    # Fetch all click timestamps for this URL, newest first
    clicks = conn.execute(
        "SELECT clicked_at FROM clicks WHERE url_id = ? ORDER BY clicked_at DESC",
        (row["id"],),
    ).fetchall()
    conn.close()

    # Return analytics data with total count and individual timestamps
    return AnalyticsResponse(
        short_code=short_code,
        original_url=row["original_url"],
        total_clicks=len(clicks),
        clicks=[click["clicked_at"] for click in clicks],
    )

What does this code do?

  • The route /analytics/{short_code} uses a path parameter so you can check analytics for any shortened URL.
  • It queries the urls table first to confirm the short code exists. If not, it raises a 404 error.
  • The second query fetches every row from the clicks table where url_id matches, ordered newest-first with ORDER BY clicked_at DESC.
  • The response uses len(clicks) for the total count and a list comprehension to extract just the timestamp strings. The AnalyticsResponse model (defined in a previous step) validates and serializes the output.

Why is this endpoint a GET request?

Analytics is a read-only operation. The endpoint doesn't create or modify any data. In REST API design, GET requests are for retrieving data, while POST requests are for creating new resources (like the shorten endpoint).

  • Save main.py.
  • Check your terminal where the server is running. It should auto-reload and show no errors.

Seeing an error in the terminal?

  • Check that the new function is placed after the redirect_to_url function, not inside it. Both functions should be at the same indentation level.
  • Verify the decorator line starts with @app.get (not @app.post).
  • Make sure response_model=AnalyticsResponse references the class name exactly as defined earlier in the file.

Why is my analytics endpoint throwing an error?

Test the full flow

Now for the satisfying part. You'll shorten a URL, visit it a few times to generate clicks, then check the analytics to confirm everything is being tracked.

  • Open your browser and go to http://127.0.0.1:8000/docs.
  • Expand the POST /shorten section and click Try it out.
  • Enter a test URL in the request body (for example, https://www.example.com) and click Execute.
  • Copy the short_code value from the response (something like 1 or 2 depending on your test data).

Now generate some clicks by visiting the short URL a few times.

  • Open a new browser tab and go to http://127.0.0.1:8000/ followed by your short code (for example, http://127.0.0.1:8000/2).
  • You should be redirected to the original URL.
  • Go back and visit the short URL 2-3 more times to record multiple clicks.

Finally, check the analytics.

  • Return to http://127.0.0.1:8000/docs.
  • Expand the GET /analytics/{short_code} section and click Try it out.
  • Enter the same short code you used above and click Execute.

You should see a JSON response containing your original URL, the total click count matching the number of times you visited the link, and a list of timestamps for each individual click.

What should the response look like?

The response body will look something like this (your timestamps and short code will differ):

{
  "short_code": "2",
  "original_url": "https://www.example.com",
  "total_clicks": 3,
  "clicks": [
    "2026-05-22T10:05:30.123456+00:00",
    "2026-05-22T10:05:25.654321+00:00",
    "2026-05-22T10:05:20.987654+00:00"
  ]
}

Notice the clicks are ordered newest-first. The total_clicks count matches the length of the timestamps list.

Getting a 404 response?

  • Double-check that the short code you entered in the analytics request matches exactly what the shorten endpoint returned. Codes are case-sensitive.
  • If you deleted url_shortener.db and restarted the server, your old short codes no longer exist. Shorten a new URL first.

Why is my analytics endpoint returning 404 even though I shortened a URL?

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and your server is still running without errors.

ⓧ I'd like to double check the full code

import sqlite3
import string
from contextlib import asynccontextmanager
from datetime import datetime, timezone

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

# --- Base62 Encoding ---

BASE62_CHARS = string.digits + string.ascii_lowercase + string.ascii_uppercase


def encode_base62(num: int) -> str:
    if num == 0:
        return BASE62_CHARS[0]
    result = []
    while num > 0:
        num, remainder = divmod(num, 62)
        result.append(BASE62_CHARS[remainder])
    return "".join(reversed(result))


# --- Database ---

DATABASE = "url_shortener.db"


def get_db():
    conn = sqlite3.connect(DATABASE)
    conn.row_factory = sqlite3.Row
    return conn


def create_tables():
    conn = get_db()
    conn.execute("""
        CREATE TABLE IF NOT EXISTS urls (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            short_code TEXT UNIQUE,
            original_url TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS clicks (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            url_id INTEGER NOT NULL,
            clicked_at TEXT NOT NULL,
            FOREIGN KEY (url_id) REFERENCES urls (id)
        )
    """)
    conn.commit()
    conn.close()


# --- App Lifespan ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    create_tables()
    yield


# --- FastAPI App ---

app = FastAPI(
    title="URL Shortener",
    description="A URL shortener API demonstrating system design principles",
    version="1.0.0",
    lifespan=lifespan,
)


# --- Pydantic Models ---

class URLRequest(BaseModel):
    url: str


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


class AnalyticsResponse(BaseModel):
    short_code: str
    original_url: str
    total_clicks: int
    clicks: list[str]


# --- Endpoints ---

@app.get("/")
async def health_check():
    return {"status": "ok", "message": "URL Shortener API is running"}


@app.post("/shorten", response_model=URLResponse, status_code=201)
async def shorten_url(request: URLRequest):
    conn = get_db()
    now = datetime.now(timezone.utc).isoformat()

    cursor = conn.execute(
        "INSERT INTO urls (original_url, created_at) VALUES (?, ?)",
        (request.url, now),
    )
    url_id = cursor.lastrowid
    short_code = encode_base62(url_id)

    conn.execute(
        "UPDATE urls SET short_code = ? WHERE id = ?",
        (short_code, url_id),
    )
    conn.commit()
    conn.close()

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


@app.get("/{short_code}")
async def redirect_to_url(short_code: str):
    conn = get_db()

    row = conn.execute(
        "SELECT id, original_url FROM urls WHERE short_code = ?",
        (short_code,),
    ).fetchone()

    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Short URL not found")

    now = datetime.now(timezone.utc).isoformat()
    conn.execute(
        "INSERT INTO clicks (url_id, clicked_at) VALUES (?, ?)",
        (row["id"], now),
    )
    conn.commit()
    conn.close()

    return RedirectResponse(url=row["original_url"], status_code=307)


@app.get("/analytics/{short_code}", response_model=AnalyticsResponse)
async def get_analytics(short_code: str):
    conn = get_db()

    row = conn.execute(
        "SELECT id, original_url FROM urls WHERE short_code = ?",
        (short_code,),
    ).fetchone()

    if row is None:
        conn.close()
        raise HTTPException(status_code=404, detail="Short URL not found")

    clicks = conn.execute(
        "SELECT clicked_at FROM clicks WHERE url_id = ? ORDER BY clicked_at DESC",
        (row["id"],),
    ).fetchall()
    conn.close()

    return AnalyticsResponse(
        short_code=short_code,
        original_url=row["original_url"],
        total_clicks=len(clicks),
        clicks=[click["clicked_at"] for click in clicks],
    )

Secret mission

Custom Aliases

Right now, every shortened URL gets an auto-generated Base62 code. What if users could choose their own memorable short code instead? In this mission, you'll add custom alias support with proper validation and conflict detection.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • FastAPI development server (running process in your terminal).
  • Project folder on your Desktop (contains main.py, your virtual environment, and url_shortener.db).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with your API.

  • Your FastAPI dev server continues running in the terminal. You can keep testing endpoints at http://127.0.0.1:8000/docs.
  • Your url_shortener.db file stays on disk with all your test data intact.
  • Your virtual environment and installed packages remain ready to use.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your files stay on disk so you can resume later.

  • In the terminal where your FastAPI server is running, press Ctrl+C to stop it.
  • Deactivate your virtual environment by running:
deactivate

When you're ready to resume, navigate back to your project folder, reactivate the virtual environment, and start the server again with fastapi dev main.py.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes everything you built.

  • In the terminal where your FastAPI server is running, press Ctrl+C to stop it.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the project folder by running:

macOS

cd ~/Desktop
rm -rf url-shortener

Windows

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

This removes main.py, url_shortener.db, your virtual environment, and all installed packages.

Nice Work!

Nice Work!

Nice work! You just built a fully functional URL shortener API from scratch using FastAPI and SQLite.

You've learned how to:

  • Built a REST API with FastAPI featuring 4 endpoints (shorten, redirect, analytics, and health check) with auto-generated interactive Swagger UI documentation.
  • Designed a database schema with two related tables and tracked click analytics with timestamps.
  • Implemented Base62 encoding to convert numeric IDs into compact short codes, demonstrating how encoding algorithms compress information.
  • Added custom alias support with input validation and conflict detection (Secret Mission).

Ready to quiz yourself?