Build a URL Shortener API with FastAPI

Build a URL shortener API with persistent storage and auto-generated docs.

Introduction

30 Second Summary

Every time you share a link on social media, paste one into a message, or click a short URL in someone's bio, a URL shortener is doing the work behind the scenes. It takes an impossibly long web address and gives you back something clean and memorable.

In this project, you will build your own URL shortener API using FastAPI and SQLite. You will create endpoints that shorten URLs, redirect visitors, and track how many times each link gets clicked.

What You'll Build

A fully functional URL shortener running on your machine where you paste in a long URL, get back a short code, and watch your browser redirect to the original destination when you visit that code.

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

  • A POST endpoint that accepts any long URL and instantly returns a short code you can share with anyone.
  • A redirect endpoint that sends visitors to the original URL when they visit your short code in a browser.
  • A stats endpoint and click tracking so you can see exactly how many times each shortened link has been visited.
  • Secret Mission: Add custom vanity short codes so users can pick their own memorable URLs instead of random characters.

Are there any prerequisites?

You should be comfortable with basic Python (functions, variables, and dictionaries). No prior web development or API experience is needed.

Before We Start

Before We Start...

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

Set Up Your Project

To build your URL shortener, you need a clean workspace with the right packages installed. A virtual environment keeps your project's packages isolated from the rest of your system.

In this step, you'll create your project folder, install FastAPI and SQLModel, and confirm everything works by running a simple server.

In this step, get ready to:
  • Create a project folder and virtual environment.
  • Install FastAPI and SQLModel.
  • Create and run a hello world endpoint.
Create a project folder and virtual environment

Every Python project should live in its own folder with its own virtual environment. This prevents package conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, usually above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
  • Confirm the folder was created by running:
pwd

You should see a path ending in url-shortener.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the url-shortener folder, then click Open.

VS Code reloads with url-shortener as your workspace. You should see the folder name in the top of the Explorer sidebar on the left.

  • Open a new terminal inside VS Code by pressing Ctrl+` (backtick).
  • Create your virtual environment by running this command:
python -m venv venv

What does this command do?

python -m venv venv creates an isolated Python environment inside a folder called venv. Packages you install here stay separate from your system Python.

  • Confirm the venv folder was created by running:
ls

You should see a venv folder listed in the output.

Seeing 'python not found'?

On macOS, try python3 -m venv venv instead. Some systems use python3 as the command name for Python 3.

help me fix a 'python not found' error when creating a virtual environment

  • Activate the virtual environment so that packages install into it instead of your system Python.

macOS

  • Run this command to activate:
source venv/bin/activate

Windows

  • Run this command to activate:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms you are working inside the virtual environment.

Don't see (venv) in your prompt?

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

help me activate my Python virtual environment

Install FastAPI and SQLModel

With your virtual environment active, you can install the packages your project needs. Everything installs into venv and won't affect other projects on your machine.

  • Install both packages by running this command:
pip install "fastapi[standard]" sqlmodel

What does this install?

  • fastapi[standard] installs FastAPI plus Uvicorn (the development server) and Pydantic (data validation).
  • sqlmodel installs SQLModel plus SQLAlchemy (the database engine under the hood).

You should see output scrolling as packages download and install, ending with a line like Successfully installed followed by a list of package names.

Seeing permission errors or install failures?

Make sure (venv) appears in your terminal prompt. If it does not, your virtual environment is not active. Go back and run the activate command from the previous substep.

help me fix a pip install error for FastAPI and SQLModel

Create a hello world endpoint and run it

Now you have FastAPI installed. Time to write your first endpoint and see it respond in the browser.

  • Create a new file called main.py in your url-shortener folder by clicking the new file icon in the Explorer sidebar.
  • Add the following code to main.py:
from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI()


# Define a GET endpoint at the root URL
@app.get("/")
def read_root():
    return {"message": "Hello World"}

What does this code do?

  • FastAPI() creates your application object. Every endpoint you build gets attached to this.
  • @app.get("/") registers the function below it as the handler for GET requests to the root URL (/).
  • Returning a dictionary automatically becomes a JSON response. FastAPI handles the conversion for you.
  • Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Before you run the server, what do you think will happen when you visit http://127.0.0.1:8000 in your browser?

  • Start the development server by running this command:
fastapi dev

What does fastapi dev do?

This command starts Uvicorn in development mode. It auto-detects your main.py file, serves your app at http://127.0.0.1:8000, and automatically reloads when you change code.

The terminal stays occupied while the server runs. When you need to stop it later, press Ctrl+C.

You should see output in your terminal indicating the server is running, with a line showing the URL http://127.0.0.1:8000.

  • Open your web browser and navigate to http://127.0.0.1:8000.

You should see the JSON response {"message": "Hello World"} displayed in your browser.

  • Now navigate to http://127.0.0.1:8000/docs in your browser.

This is the auto-generated interactive API documentation. FastAPI creates this from your code automatically. You'll use this page to test every endpoint you build in this project.

Browser showing 'connection refused'?

Check your terminal to make sure the server is still running. If you see an error instead of the server URL, the server failed to start.

  • Make sure you saved main.py before running fastapi dev.
  • Make sure (venv) shows in your terminal prompt, confirming FastAPI is accessible.

help me fix my FastAPI dev server not starting

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your server is still running in the terminal.

ⓧ I'd like to double check the full code

Here is the complete main.py file you should have at this point:

from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI()


# Define a GET endpoint at the root URL
@app.get("/")
def read_root():
    return {"message": "Hello World"}

Your environment is ready and your first endpoint is live. Next up, you'll build the core URL shortening logic so you can POST a long URL and get back a short code.

Build the In-Memory URL Shortener

Your development environment is ready and the server runs. Now you need a working shortener as fast as possible to see the core concept in action.

An in-memory Python dictionary is the simplest way to store URL mappings. It proves the API logic works before adding any database complexity.

In this step, get ready to:
  • Create a POST endpoint that accepts a long URL and returns a random short code.
  • Create a GET endpoint that looks up a short code and redirects to the original URL.
  • Test the full shorten-and-redirect flow through the interactive docs.
Add a POST endpoint that generates short codes

Your current main.py has a Hello World endpoint. You need to replace it entirely with the shortener logic: imports, a storage dictionary, a helper function, and the POST route.

  • In VS Code, open main.py and replace its entire contents with the following code:
import string
import random

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

app = FastAPI(title="URL Shortener")

# In-memory storage for URL mappings
url_store = {}


def generate_short_code(length: int = 6) -> str:
    # Pick from letters and digits to create a URL-safe code
    characters = string.ascii_letters + string.digits
    return "".join(random.choices(characters, k=length))


@app.post("/shorten")
def shorten_url(payload: dict):
    # Extract the URL from the request body
    original_url = payload["original_url"]
    short_code = generate_short_code()
    # Store the mapping so we can look it up later
    url_store[short_code] = original_url
    return {"short_code": short_code}

What does this code do?

  • url_store = {} creates a Python dictionary that maps short codes to original URLs. It lives in memory, so it resets every time the server restarts.
  • generate_short_code() picks 6 random characters from letters and digits. This produces codes like xK9mPq that are safe to use in URLs.
  • The POST /shorten endpoint reads original_url from the JSON body, generates a code, stores the mapping, and returns the code to the caller.
  • Save main.py.
  • Your server auto-reloads when you save. Open http://127.0.0.1:8000/docs in your browser.
  • You should see the POST /shorten endpoint listed in the Swagger UI.
  • Click the POST /shorten row to expand it.
  • Click Try it out.
  • Replace the request body with:
{"original_url": "https://www.python.org"}
  • Click Execute.
  • You should see a response with a short_code value like {"short_code": "xK9mPq"}. Your code will be different since it is randomly generated.

Not seeing the POST endpoint?

  • Make sure you saved main.py after replacing the contents. The server only reloads on save.
  • Check your terminal for error messages. A syntax error (missing colon, wrong indentation) will prevent the reload.
  • If your server is not running, start it again with fastapi dev in the terminal.

Why isn't my POST endpoint showing in /docs?

Add a GET endpoint that redirects

The POST endpoint stores URLs, but you need a way to use the short codes. A GET endpoint will look up the code and send the visitor to the original destination using an HTTP redirect.

  • In main.py, add the following code below your shorten_url function:
@app.get("/{short_code}")
def redirect_to_url(short_code: str):
    # Look up the original URL from storage
    original_url = url_store.get(short_code)
    if not original_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=original_url)

What does this code do?

  • /{short_code} is a path parameter. Any URL like /xK9mPq gets captured into the short_code variable.
  • url_store.get(short_code) looks up the code in the dictionary. If it does not exist, the endpoint raises a 404 error.
  • RedirectResponse sends back an HTTP redirect that tells the browser to navigate to the original URL automatically.
  • Save main.py.
  • Refresh http://127.0.0.1:8000/docs in your browser. You should see both POST /shorten and GET /{short_code} listed.

Only seeing the POST endpoint?

  • Check your terminal for a traceback. An indentation error in the new function prevents the server from reloading.
  • Make sure the @app.get decorator is at the same indentation level as @app.post (no extra indent).

Why doesn't my GET endpoint appear in the Swagger docs?

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py and both endpoints appear in /docs.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

import string
import random

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

app = FastAPI(title="URL Shortener")

url_store = {}


def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(random.choices(characters, k=length))


@app.post("/shorten")
def shorten_url(payload: dict):
    original_url = payload["original_url"]
    short_code = generate_short_code()
    url_store[short_code] = original_url
    return {"short_code": short_code}


@app.get("/{short_code}")
def redirect_to_url(short_code: str):
    original_url = url_store.get(short_code)
    if not original_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=original_url)
Test the full flow

Both endpoints are in place. Time to prove the whole shorten-and-redirect cycle works end to end.

  • In the /docs Swagger UI, expand POST /shorten and click Try it out.
  • Enter this request body:
{"original_url": "https://www.python.org"}
  • Click Execute.
  • Copy the short_code value from the response body (for example, aB3xYz).

Before you open the short URL, what do you think will happen when you visit it?

  • Open a new browser tab and go to http://127.0.0.1:8000/ followed by the short code you copied (for example, http://127.0.0.1:8000/aB3xYz).

Your browser redirects to python.org. The short code worked.

What is happening under the hood?

When your browser hits /aB3xYz, FastAPI matches it to the GET /{short_code} route. The route looks up the code in the dictionary, finds the original URL, and returns a RedirectResponse.

The browser receives an HTTP 307 redirect header and automatically navigates to the original destination. No JavaScript or page rendering involved.

Seeing a "Short URL not found" error?

  • Make sure you copied the exact code from the POST response. Codes are case-sensitive.
  • If your server restarted between the POST and the GET, the dictionary was wiped. POST a new URL and try the fresh code.

Why am I getting a 404 when I visit my short code?

Your URL shortener works. Next up, you will add a real database so your links survive a server restart.

Add Database Persistence

Your URL shortener works. You can shorten links and get redirected back to the original page.

But there is a fatal flaw hiding in your code. Every shortened URL lives inside a Python dictionary, and dictionaries only exist in your computer's memory. The moment you stop the server, everything disappears.

In this step, get ready to:
  • Discover why in-memory storage fails for a URL shortener.
  • Define a database model with SQLModel to store URLs permanently.
  • Rewrite your endpoints to read and write from a SQLite database.
Experience the data loss problem

Before you fix anything, prove the problem exists. What do you think will happen to your shortened URLs if you stop and restart the server?

  • Switch back to your terminal where the server is running.
  • Stop the server by pressing Ctrl+C.
  • Restart the server by running:
fastapi dev
  • Open your browser and try visiting one of the short codes you created in the previous step (for example, http://127.0.0.1:8000/abc123 where abc123 is any short code you generated earlier).

You get a 404 error. Your URLs are gone. The Python dictionary that held them was wiped from memory the instant the server stopped.

Why did the URLs vanish?

Python variables, including dictionaries, only exist in RAM while your program is running. When the process stops, all that data disappears. A database writes data to a file on disk, so it persists across restarts.

Define the database model with SQLModel

You will replace the entire contents of main.py with database-backed code. The first piece defines what a URL record looks like in the database.

  • Stop the server with Ctrl+C if it is still running.
  • Open main.py in VS Code.
  • Select all the existing code and delete it.
  • Replace the file contents with this code:
from typing import Annotated
import string
import random

from fastapi import Depends, FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from sqlmodel import Field, Session, SQLModel, create_engine, select


# Define the database table for storing shortened URLs
class URL(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    original_url: str
    short_code: str = Field(index=True)
    clicks: int = Field(default=0)

What does this code do?

  • The new imports bring in SQLModel tools: Field for column options, Session for database transactions, create_engine for the database connection, and select for queries.
  • The URL class with table=True tells SQLModel this is both a Pydantic data model AND a database table. Each instance becomes a row in the database.
  • Field(index=True) on short_code creates a database index for fast lookups when someone visits a short URL.
  • The clicks field defaults to 0. You will use it in a later step to track how many times each link is visited.
Set up the session dependency

Now you need the plumbing that connects your app to a SQLite database file and provides a session to each endpoint.

  • Add the following code below the URL class in main.py:
# Configure the SQLite database connection
sqlite_file_name = "url_shortener.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


# Create the database tables when the app starts
def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


# Provide a database session for each request
def get_session():
    with Session(engine) as session:
        yield session


# Type alias so endpoints can request a session via dependency injection
SessionDep = Annotated[Session, Depends(get_session)]

app = FastAPI(title="URL Shortener")


@app.on_event("startup")
def on_startup():
    create_db_and_tables()

What does this code do?

  • The engine points to a file called url_shortener.db. SQLite stores the entire database in this single file on disk.
  • check_same_thread: False is required because FastAPI can handle requests across multiple threads, and SQLite normally restricts access to one thread.
  • get_session() is a generator that opens a database session, lets the endpoint use it, then closes it automatically.
  • SessionDep is a type alias using Depends. When an endpoint declares session: SessionDep, FastAPI automatically calls get_session() and passes the result in.
  • The @app.on_event("startup") decorator runs create_db_and_tables() once when the server starts, creating the database file and tables if they do not already exist.
  • Save main.py.
  • Start the server by running:
fastapi dev
  • Confirm the database file was created by opening a second terminal and running:
ls url_shortener.db

You should see url_shortener.db listed. The server created the database file on startup.

Don't see the database file?

  • Check that the @app.on_event("startup") decorator is directly above def on_startup(): with no blank line between them.
  • Make sure app = FastAPI(title="URL Shortener") appears before the startup event in your file.
  • Look at the terminal output for any import errors. If you see ModuleNotFoundError, confirm your virtual environment is activated.

Still stuck? Help me debug why my database file is not being created

Rewrite endpoints to use the database

The database is ready. Now replace the old dictionary-based endpoints with ones that read and write from the database.

  • Stop the server with Ctrl+C.
  • Add the following code at the bottom of main.py, below the on_startup function:
def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(random.choices(characters, k=length))


@app.post("/shorten")
def shorten_url(payload: dict, session: SessionDep):
    original_url = payload["original_url"]
    # Generate a unique short code, regenerating if there's a collision
    short_code = generate_short_code()
    while session.exec(select(URL).where(URL.short_code == short_code)).first():
        short_code = generate_short_code()
    # Create a new URL record and save it to the database
    db_url = URL(original_url=original_url, short_code=short_code)
    session.add(db_url)
    session.commit()
    session.refresh(db_url)
    return db_url


@app.get("/{short_code}")
def redirect_to_url(short_code: str, session: SessionDep):
    # Look up the short code in the database
    db_url = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not db_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return RedirectResponse(url=db_url.original_url)

What changed in the endpoints?

  • Both endpoints now accept session: SessionDep. FastAPI injects a live database session automatically.
  • The while loop in shorten_url checks if a generated code already exists. If it does, it generates a new one. This prevents collisions.
  • session.add(), session.commit(), and session.refresh() save the new URL record to the database and reload it with the auto-generated id.
  • The redirect endpoint queries the database with select(URL).where(...) instead of reading from a dictionary.
  • Save main.py.
  • Start the server by running:
fastapi dev
  • Open http://127.0.0.1:8000/docs in your browser.
  • Expand the POST /shorten endpoint, click Try it out, and enter this request body:
{"original_url": "https://www.python.org"}
  • Click Execute.
  • Copy the short_code value from the response.

You should see a JSON response containing original_url, short_code, clicks, and id. The database assigned the record an ID automatically.

Seeing a 422 or 500 error?

  • A 422 means the request body format is wrong. Make sure the JSON has the exact key original_url (with an underscore, not a hyphen).
  • A 500 error usually means a missing import or typo. Check the terminal for the full traceback and compare your imports to the code above.
  • If you see OperationalError, delete the url_shortener.db file and restart the server. The tables will be recreated fresh.

Still stuck? Help me debug my FastAPI shorten endpoint error

Now for the real test. Do you think your short code will survive a server restart this time?

  • Stop the server with Ctrl+C.
  • Restart it by running:
fastapi dev
  • Open a new browser tab and visit http://127.0.0.1:8000/ followed by the short code you copied earlier.

Your browser redirects to python.org. The URL survived the restart because it is stored in url_shortener.db on disk, not in memory.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that the redirect still works after a restart.

ⓧ I'd like to double check the full code

Your main.py should contain the following sections in this exact order. Scroll up and confirm each is present:

  • Imports from typing, string, random, fastapi, and sqlmodel.
  • The URL class with table=True, containing id, original_url, short_code, and clicks fields.
  • SQLite engine configuration with url_shortener.db and check_same_thread: False.
  • create_db_and_tables() and get_session() functions.
  • SessionDep type alias, app = FastAPI(title="URL Shortener"), and the on_startup event.
  • generate_short_code() helper function.
  • POST /shorten endpoint using session: SessionDep with the collision-check while loop.
  • GET /{short_code} endpoint querying the database and returning a RedirectResponse.

Your URLs now live on disk instead of disappearing into thin air. Next up, you will add proper input and output models so clients cannot sneak in fields like id or clicks when creating a link.

Add Proper Input and Output Models

Your links survive server restarts now. The database keeps everything safe.

But there's a problem with your API's data contract. Right now the POST endpoint accepts a raw Python dictionary, so the /docs page can't show clients what fields are required. Even worse, the response exposes the internal id field to anyone who calls your API. You need separate models for input and output to control exactly what data flows in and out.

In this step, get ready to:
  • Create a model hierarchy with separate input and output models.
  • Update your POST endpoint to use typed models instead of a raw dictionary.
  • Verify the API contract in /docs shows only the fields you intend.
Create a model hierarchy

Instead of one model doing everything, you'll split it into four models that each have a single job. A base model holds shared fields. A table model adds database columns. An input model controls what clients send. An output model controls what clients receive.

  • In main.py, find the existing URL class:
class URL(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    original_url: str
    short_code: str = Field(index=True)
    clicks: int = Field(default=0)
  • Replace that entire class with these four models:
# Base model holds the field shared across all URL models
class URLBase(SQLModel):
    original_url: str


# Table model adds database-only fields
class URL(URLBase, table=True):
    id: int | None = Field(default=None, primary_key=True)
    short_code: str = Field(index=True)
    clicks: int = Field(default=0)


# Input model controls what clients can send
class URLCreate(URLBase):
    pass


# Output model controls what clients receive back
class URLPublic(URLBase):
    short_code: str
    clicks: int

What does this model hierarchy do?

URLBase holds original_url because every model needs it. The other three inherit from it.

URLCreate only inherits original_url from the base. Clients can't sneak in id or clicks because those fields simply don't exist on this model.

URLPublic includes short_code and clicks so clients can see their shortened link and its stats. But it deliberately omits id because that's an internal database detail.

  • Save main.py.
  • Check your terminal where fastapi dev is running. You should see the server reload successfully without any errors.

Seeing an error on reload?

  • Make sure you replaced the entire original URL class. If both the old and new URL class exist, Python will raise an error about duplicate definitions.
  • Check that URLBase is defined above URL in the file. Python reads top to bottom, so the base class must come first.

Help me fix my model hierarchy error.

Update endpoints to use the new models

The models exist, but your endpoint still accepts a raw dictionary. You need to wire up URLCreate as the input type and URLPublic as the output shape.

  • Find the current POST /shorten endpoint in main.py:
@app.post("/shorten")
def shorten_url(payload: dict, session: SessionDep):
    original_url = payload["original_url"]
    short_code = generate_short_code()
    while session.exec(select(URL).where(URL.short_code == short_code)).first():
        short_code = generate_short_code()
    db_url = URL(original_url=original_url, short_code=short_code)
    session.add(db_url)
    session.commit()
    session.refresh(db_url)
    return db_url
  • Replace that entire endpoint with this updated version:
# Accept URLCreate as input, return URLPublic as output
@app.post("/shorten", response_model=URLPublic)
def shorten_url(url_data: URLCreate, session: SessionDep):
    short_code = generate_short_code()
    while session.exec(select(URL).where(URL.short_code == short_code)).first():
        short_code = generate_short_code()
    db_url = URL(original_url=url_data.original_url, short_code=short_code)
    session.add(db_url)
    session.commit()
    session.refresh(db_url)
    return db_url

What changed in this endpoint?

  • response_model=URLPublic tells FastAPI to filter the response through URLPublic before sending it back. The internal id field gets stripped out automatically.
  • url_data: URLCreate replaces payload: dict. FastAPI validates incoming JSON against the URLCreate schema and rejects requests that don't match.
  • url_data.original_url replaces the old dictionary access. You get autocomplete and type checking because url_data is a proper typed object.
  • Save main.py.
  • Check your terminal to confirm the server reloaded without errors.

Server won't reload?

  • Make sure you removed the old original_url = payload["original_url"] line. That variable no longer exists.
  • Check that URLCreate and URLPublic are defined above the endpoint in your file. Python needs them to exist before they're referenced.

Help me debug my endpoint update.

Verify the API contract

Before you check, think about what the /docs page should look like now. What fields should the POST /shorten request body show? What about the response?

  • Open http://127.0.0.1:8000/docs in your browser.
  • Click on the POST /shorten endpoint to expand it.
  • Look at the Request body schema. It should only show original_url. No id, no clicks, no short_code.
  • Look at the Responses section. The response schema should show original_url, short_code, and clicks but NOT id.

Now test that extra fields get rejected properly.

  • Click Try it out on the POST /shorten endpoint.
  • Replace the request body with this JSON that includes an extra field:
{"original_url": "https://example.com", "id": 999}
  • Click Execute.

The response should show your shortened URL with a random code and clicks: 0. The id: 999 you tried to sneak in gets silently ignored. Your models are protecting the API.

Why use separate models instead of one?

This pattern is called model separation. It's one of the most important API design patterns because it prevents data leaks and injection attacks at the schema level. If a field doesn't exist on the input model, it can't be sent. If a field doesn't exist on the output model, it can't leak.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Scroll up and confirm each section is present in your main.py, in this order:

  • Imports at the top (typing, string, random, fastapi, sqlmodel).
  • Four model classes: URLBase, URL, URLCreate, URLPublic.
  • Database engine and session setup (sqlite_url, engine, get_session, SessionDep).
  • App creation, startup event, and generate_short_code() helper.
  • POST /shorten endpoint with response_model=URLPublic and url_data: URLCreate.
  • GET /{short_code} redirect endpoint (unchanged from previous step).

Your API now has a proper contract. Clients know exactly what to send and what they'll get back. Next up, you'll add click tracking and a stats endpoint so you can see which links get the most traffic.

Add Click Tracking and Stats

Your shortener accepts URLs, stores them in a database, and redirects visitors to the original destination. But right now you have no idea how popular each link is.

Adding a click counter and a stats endpoint turns your API into something genuinely useful. After this step, you can see which links get traffic and how many times each one has been visited.

In this step, get ready to:
  • Increment a click counter every time someone visits a short link.
  • Add a stats endpoint that returns click count data for any shortened URL.
  • Verify click tracking works end-to-end by visiting a link multiple times and checking the count.
Increment clicks on redirect

Every time someone visits a short code, your API should record that click. The URL model already has a clicks field defaulting to 0, so you just need to increment it before returning the redirect.

  • In main.py, find the redirect_to_url function at the bottom of your file.
  • Replace it with this updated version that tracks clicks before redirecting:
@app.get("/{short_code}")
def redirect_to_url(short_code: str, session: SessionDep):
    db_url = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not db_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    # Track the click
    db_url.clicks += 1
    session.add(db_url)
    session.commit()
    return RedirectResponse(url=db_url.original_url)

What does this new code do?

  • db_url.clicks += 1 increments the counter on the Python object.
  • session.add(db_url) marks the changed record for saving.
  • session.commit() writes the updated count to the SQLite database before the redirect happens.
  • Save main.py.
  • Switch to your browser and visit one of your existing short codes (e.g. http://127.0.0.1:8000/ followed by a code you shortened earlier).
  • You should still get redirected to the original URL. The click tracking happens silently in the background.

Redirect not working?

  • Make sure your fastapi dev server is still running in your terminal. If you stopped it earlier, restart it.
  • Check that you did not accidentally delete the return RedirectResponse(url=db_url.original_url) line at the end of the function.
  • If your database was deleted between steps, you will need to shorten a new URL first.

Help me debug my redirect endpoint

Add a stats endpoint

You need a way to check how many clicks a link has received without triggering another redirect. A dedicated stats endpoint solves this by returning the URL record as JSON.

Why does route order matter here?

FastAPI evaluates routes in the order you define them. The /{short_code} route matches ANY single path segment.

If you place the stats endpoint below it, a request to /stats/abc123 would match /{short_code} first with short_code="stats". You must define /stats/{short_code} ABOVE the catch-all route so FastAPI matches it first.

  • In main.py, find the redirect_to_url function you just updated.
  • Add the following stats endpoint ABOVE it (between the shorten_url function and the redirect_to_url function):
@app.get("/stats/{short_code}", response_model=URLPublic)
def get_url_stats(short_code: str, session: SessionDep):
    # Look up the URL record by its short code
    db_url = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not db_url:
        raise HTTPException(status_code=404, detail="Short URL not found")
    return db_url

What does this endpoint do?

  • response_model=URLPublic tells FastAPI to serialize the response using the URLPublic model, which includes original_url, short_code, and clicks but hides the internal id.
  • The function queries the database and returns the record as JSON instead of triggering a redirect.
  • Save main.py.
  • Open http://127.0.0.1:8000/docs in your browser.
  • You should see a new GET /stats/{short_code} endpoint listed in the docs.
  • Try it with one of your existing short codes. You should see a JSON response with original_url, short_code, and clicks (which should be at least 1 from your earlier test).

Getting a 404 on the stats endpoint?

  • Double check that your get_url_stats function appears ABOVE redirect_to_url in the file. If it is below, FastAPI matches the catch-all route first.
  • Make sure you are using a short code that actually exists in your database. If you reset your database, shorten a new URL first.

Help me fix my stats endpoint route ordering

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py with both the updated redirect function and the new stats endpoint.

ⓧ I'd like to double check the full code

Your main.py should have these sections in order. Scroll through your file and confirm each is present:

  • Imports (typing, string, random, fastapi, sqlmodel).
  • Model classes: URLBase, URL, URLCreate, URLPublic.
  • Database setup: engine, create_db_and_tables(), get_session(), SessionDep.
  • App creation and on_startup event.
  • generate_short_code() helper function.
  • POST /shorten endpoint (shorten_url).
  • GET /stats/{short_code} endpoint (get_url_stats) placed ABOVE the catch-all.
  • GET /{short_code} endpoint (redirect_to_url) with db_url.clicks += 1 before the redirect.
Test the full click tracking flow

Time to prove the whole system works end-to-end. You will shorten a fresh URL, visit it multiple times, and confirm the stats endpoint reports the correct count.

  • Open http://127.0.0.1:8000/docs in your browser.
  • Use the POST /shorten endpoint to shorten a new URL (e.g. {"original_url": "https://www.python.org"}).
  • Copy the short_code from the response.
  • Open http://127.0.0.1:8000/ followed by your short code in a new browser tab.
  • Watch it redirect to python.org. Go back and visit the same short code URL 2 more times (3 visits total).

Before you check the stats, predict: what number do you expect to see in the clicks field?

  • Return to http://127.0.0.1:8000/docs and use the GET /stats/{short_code} endpoint with your short code.

You should see "clicks": 3 in the JSON response. Each browser visit incremented the counter, and the stats endpoint reports the total.

Clicks showing 0 or the wrong number?

  • Make sure you visited the short code URL directly in your browser address bar, not through the /docs UI. The Swagger UI does not follow redirects the same way a browser does.
  • Check that session.commit() appears after db_url.clicks += 1 in your redirect function. Without the commit, the increment is never saved to the database.

Help me debug my click tracking

Your URL shortener is now feature-complete. You can shorten links, redirect visitors, and track how popular each link is. Next up, you will tackle a Secret Mission that adds custom vanity codes to your shortener.

Secret mission

Add Custom Short Codes

Random codes like xK9mPq work, but they are forgettable. Real URL shorteners like Bitly let users pick vanity codes such as my-portfolio or sale2026. Can you add optional custom short codes with proper validation and conflict handling?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • url-shortener project folder on your Desktop (contains main.py, virtual environment, and SQLite database).
  • Python virtual environment (venv folder inside your project).
  • SQLite database file (url_shortener.db) storing your shortened URLs.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • Your project folder stays on your Desktop with all files intact.
  • The FastAPI dev server only runs when you start it manually, so it uses no resources when idle.
  • The SQLite database file takes up minimal disk space.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files and database remain on disk for when you return.

  • If the FastAPI dev server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate the virtual environment by running:
deactivate

When you come back, reactivate the virtual environment and start the server again.

Delete - I don't want to use this again

Remove all project files, the virtual environment, and the database. This cannot be undone.

  • If the FastAPI dev server is still running, press Ctrl+C in the terminal to stop it.
  • Deactivate the virtual environment by running:
deactivate
  • Delete the entire project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf url-shortener

On Windows, run this instead:

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

This removes main.py, the venv virtual environment, the url_shortener.db database, and all other project files.

Nice Work!

Nice Work!

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

You've learned how to:

  • Build a REST API with FastAPI that accepts long URLs, generates short codes, and returns HTTP redirect responses that send visitors to the original destination.
  • Persist data in SQLite using SQLModel so shortened links survive server restarts, with proper session dependencies and startup hooks.
  • Separate input and output models to control what data clients can send and receive, and add click tracking with a stats endpoint to monitor link usage.
  • Secret Mission: Added custom vanity short codes with regex validation, conflict detection, and proper HTTP error responses.

Ready to quiz yourself?