Build a URL Shortener API with FastAPI

Build a REST API that shortens URLs, tracks clicks, and redirects users.

Introduction

30 Second Summary

Every time you click a shortened link, a backend API looks up the original destination and sends your browser there in milliseconds. These services power billions of clicks daily across social media, marketing emails, and SMS messages.

In this project, you will build your own URL shortener API using FastAPI and SQLite. You'll create endpoints that generate short codes, redirect visitors, and track click statistics.

What You'll Build

A fully functional URL shortener REST API that accepts long URLs, generates unique 6-character short codes, stores them in a database, and redirects visitors to the original URL when they visit the short link.

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

  • A FastAPI application with three endpoints to create short URLs, redirect visitors, and view statistics.
  • A SQLModel database model with SQLite persistence and dependency injection for session management.
  • Auto-generated interactive Swagger UI documentation for testing your API directly in the browser.
  • Secret Mission: Add custom vanity short codes and time-based URL expiration to your API.

Are there any prerequisites?

Basic Python knowledge is helpful for this project. Familiarity with web frameworks or APIs is a bonus, but not required.

Set Up the Project

Before building any endpoints, you need a clean project folder with the right packages installed.

In this step, you'll create a dedicated project directory, set up a Python virtual environment, and install FastAPI and SQLModel. You'll also create a minimal app to confirm everything works.

In this step, get ready to:
  • Create a project directory and Python virtual environment.
  • Install FastAPI and SQLModel.
  • Create a minimal FastAPI app and verify it runs.
Create a project directory and virtual environment
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
  • Confirm the folder exists by running:
ls

You should see an empty directory (no output). That's expected since the folder is brand new.

  • Create a Python virtual environment by running:
python -m venv .venv

What is a virtual environment?

A virtual environment is an isolated Python installation that keeps this project's packages separate from your system Python. This prevents version conflicts between different projects on your machine.

  • Activate the virtual environment by running the command for your operating system:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

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

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

You should see pip download and install several packages. The output ends with a "Successfully installed" message listing FastAPI, SQLModel, and their dependencies.

Seeing a permissions error or install failure?

Make sure you activated the virtual environment first. The (.venv) prefix must appear in your terminal prompt before running pip install.

If you see a network error, check your internet connection and try again. Help me troubleshoot my pip install

Create a minimal FastAPI app

You need a small starter file to confirm FastAPI is working. This file creates the app object and a single health-check endpoint you can visit in your browser.

  • Open the url-shortener folder in your code editor.
  • Create a new file called main.py and add the following code:
from fastapi import FastAPI

# Create the FastAPI application
app = FastAPI(title="URL Shortener API")

# Health check endpoint to verify the server is running
@app.get("/")
def root():
    return {"status": "ok"}

What does this code do?

  • FastAPI(title="URL Shortener API") creates your application instance. The title appears in the auto-generated documentation.
  • @app.get("/") registers the root() function as the handler for GET requests to the root path.
  • Returning a dictionary automatically converts it to a JSON response.
  • Save main.py.
  • Start the FastAPI development server by running this in your terminal:
fastapi dev

You should see output showing the server started at http://127.0.0.1:8000. The terminal stays occupied because the server is running.

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

You should see a JSON response: {"status":"ok"}. This confirms your app is running.

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

You should see Swagger UI load with your API title "URL Shortener API" at the top and the GET / endpoint listed. This interactive documentation is auto-generated by FastAPI from your code.

Server not starting?

Make sure your virtual environment is activated (you see (.venv) in your prompt). If you see "command not found", the fastapi CLI was not installed. Re-run the pip install command from the previous substep.

If you see an import error, check that main.py is saved in the url-shortener folder (the same folder where you ran fastapi dev). Help me fix my FastAPI dev server

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

from fastapi import FastAPI

app = FastAPI(title="URL Shortener API")

@app.get("/")
def root():
    return {"status": "ok"}
Verify SQLModel is importable

The development server is occupying your current terminal. You need a second terminal to run a quick import check for SQLModel.

  • Open a new terminal tab or window in your code editor.
  • Navigate to your project folder and activate the virtual environment:

macOS

cd ~/Desktop/url-shortener
source .venv/bin/activate

Windows

cd ~\Desktop\url-shortener
.venv\Scripts\activate
  • Confirm SQLModel is installed correctly by running:
python -c "from sqlmodel import SQLModel, Field, Session, create_engine, select"

If there is no output and no error, SQLModel is installed and ready. You'll use these imports in the next step to build your database model.

Seeing an ImportError?

Make sure you activated the virtual environment in this new terminal. The (.venv) prefix must be visible in your prompt. If it's missing, re-run the activation command above.

If you still see an error after activation, re-run pip install sqlmodel in this terminal. Help me fix my SQLModel import error

Your environment is ready. Next up, you'll define the database model and connect SQLite so your short URLs have somewhere to live.

Define the Database Model

Your FastAPI server is running and Swagger UI confirms everything is wired up. Right now, though, your API has no memory.

Without a database, any URLs you shorten would vanish the moment you restart the server. In this step, you'll connect a SQLite database and define the data model that stores your shortened URLs.

In this step, get ready to:
  • Configure a SQLite database engine and session dependency.
  • Define a URL database model with SQLModel.
  • Wire up automatic table creation when the server starts.
Set up the SQLite database engine

Your API needs a place to store data that survives restarts. SQLModel makes this easy by combining Python type hints with database table definitions in a single class.

  • In main.py, select all the existing code and replace it with the following:
from typing import Annotated

from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select


# --- Database Setup ---

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)

What does this code do?

This sets up the connection between your Python code and a SQLite database file.

  • sqlite_file_name tells SQLModel to create a file called database.db in your project folder.
  • sqlite_url formats the file path into a connection string that SQLAlchemy understands.
  • check_same_thread: False allows FastAPI to share the database connection across request threads safely.
  • engine is the actual connection object that manages all communication with the database.
  • Save main.py.

You won't see a change yet. The next chunk adds the data model that uses this engine.

Define the URL database model

Every shortened URL needs a home in the database. You'll define what information gets stored for each URL by creating a model class.

  • In main.py, add the following code below the database setup section:
# --- Database Model ---

class URL(SQLModel, table=True):
    # Auto-incrementing primary key
    id: int | None = Field(default=None, primary_key=True)
    # The full original URL the user wants to shorten
    original_url: str
    # The unique 6-character code (e.g. "aB3xKm")
    short_code: str = Field(index=True, unique=True)
    # Timestamp when the short URL was created
    created_at: str
    # How many times this short URL has been visited
    clicks: int = Field(default=0)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

What does this code do?

The URL class defines both a Pydantic model and a database table in one place. That's the power of SQLModel.

  • table=True tells SQLModel this class represents an actual database table, not just a data shape.
  • Field(index=True, unique=True) on short_code creates a database index for fast lookups and prevents duplicate codes.
  • create_db_and_tables() reads all your model classes and creates the corresponding tables in the database.
  • Save main.py.

You won't see a visible change yet. The next chunk wires up table creation and the session dependency.

Wire up table creation on app startup

The database model exists in Python, but the actual table hasn't been created in the SQLite file yet. You need to tell FastAPI to create tables when the server starts, and you need a way to hand database sessions to your endpoint functions.

  • In main.py, add the following code below the create_db_and_tables() function:
# --- Session Dependency ---

def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- App ---

app = FastAPI(title="URL Shortener API")


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


@app.get("/")
def root():
    return {"status": "ok"}

What does this code do?

This chunk introduces two important patterns: dependency injection for database sessions and automatic table creation.

  • get_session() opens a database session, hands it to the endpoint function, and automatically closes it when the request finishes.
  • SessionDep is a reusable type alias. Any endpoint that needs database access just adds session: SessionDep to its parameters.
  • @app.on_event("startup") runs create_db_and_tables() once when the server starts, creating the database.db file and the URL table inside it.

Why use dependency injection here?

Without dependency injection, every endpoint would need to manually open and close a database session. That means repeated boilerplate and a risk of forgetting to close the connection.

With SessionDep, FastAPI handles the lifecycle for you. You declare what you need, and FastAPI provides it.

  • Save main.py.
  • Switch back to your terminal. Stop the running server with Ctrl+C, then restart it by running:
fastapi dev
  • Confirm the database.db file was created by running this in a separate terminal:
ls

You should see database.db listed alongside main.py in the output.

Don't see database.db?

  • Make sure you fully stopped the old server with Ctrl+C before restarting. The startup event only fires on a fresh start.
  • Check your terminal for error messages. A common cause is a typo in the URL class definition or a missing import.
  • Make sure your ls command is running inside the url-shortener folder.

Why isn't my database.db file being created when I restart the server?

About the deprecation warning

You may see a deprecation warning about on_event in your terminal output. This is safe to ignore for this project. The newer "lifespan" pattern is the recommended replacement, but on_event still works and keeps our code simpler.

  • Visit http://127.0.0.1:8000/docs in your browser to confirm Swagger UI still loads.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that database.db is visible in your project folder.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from typing import Annotated

from fastapi import Depends, FastAPI
from sqlmodel import Field, Session, SQLModel, create_engine, select


# --- Database Setup ---

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


# --- Database Model ---

class URL(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    original_url: str
    short_code: str = Field(index=True, unique=True)
    created_at: str
    clicks: int = Field(default=0)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


# --- Session Dependency ---

def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- App ---

app = FastAPI(title="URL Shortener API")


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


@app.get("/")
def root():
    return {"status": "ok"}

Your database is connected and ready to store URLs. Next up, you'll build the endpoint that actually creates short URLs and saves them to this database.

Build the Shorten Endpoint

Your database model is in place and the table is ready to store URLs. Now it's time to build the core feature of any URL shortener: an endpoint that accepts a long URL and hands back a short one.

This step introduces Pydantic schemas for validating incoming data, a helper function to generate unique short codes, and a POST endpoint that ties everything together.

In this step, get ready to:
  • Define request and response schemas for URL validation.
  • Write a short code generator function.
  • Implement and test the POST /shorten endpoint.
Define request and response schemas

Before writing endpoint logic, you need to define the shape of data coming in and going out. Pydantic models handle this. They validate request bodies automatically and document the expected format in Swagger UI.

  • In main.py, replace your existing import block at the top of the file with these updated imports:
import secrets
import string
from datetime import datetime, timezone
from typing import Annotated

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

What changed in the imports?

  • secrets and string power the short code generator you'll write next.
  • datetime records when each short URL was created.
  • HTTPException and RedirectResponse will be used in the next step for error handling and redirects.
  • BaseModel and HttpUrl from Pydantic define and validate your request/response shapes.
  • Now find the SessionDep line. Directly below it, add a new section comment and two schema classes:
# --- Request/Response Schemas ---

# Validates that the incoming URL is a real URL format
class URLCreate(BaseModel):
    url: HttpUrl


# Defines the shape of data sent back to the client
class URLResponse(BaseModel):
    original_url: str
    short_code: str
    short_url: str

What do these schemas do?

URLCreate uses Pydantic's HttpUrl type to automatically reject invalid URLs. If someone sends not-a-url as the request body, FastAPI returns a 422 validation error without your endpoint code ever running.

URLResponse shapes the JSON that comes back after a successful shorten request. FastAPI uses this to serialize the return value and document the response format in Swagger UI.

  • Save main.py.
  • Check that the terminal running fastapi dev does not show any import errors. The server should reload without crashing.

Seeing an import error?

  • Make sure from pydantic import BaseModel, HttpUrl is on its own line, separate from the sqlmodel import.
  • Check that your virtual environment is still active. You should see (.venv) in your terminal prompt.

Ask for help: Why am I getting an import error after adding Pydantic imports?

Write the short code generator

Every shortened URL needs a unique code like aB3xKm. You need a function that generates these randomly.

  • In main.py, find the # --- App --- comment. Add this helper function section directly above it:
# --- Helper Functions ---

# Generates a random 6-character code from letters and digits
def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(secrets.choice(characters) for _ in range(length))

Why secrets instead of random?

The secrets module generates cryptographically strong random values. With random.choice(), an attacker could potentially predict future codes by observing a pattern. secrets.choice() prevents this.

The character pool of string.ascii_letters + string.digits gives 62 possible characters per position. With 6 positions, that's over 56 billion possible codes.

  • Save main.py. You won't see a visible change yet. The next chunk uses this function in the endpoint.
Implement the POST /shorten endpoint

This is the main endpoint of your API. It receives a long URL, generates a unique short code, saves it to the database, and returns the shortened link.

  • In main.py, find the existing root endpoint:
@app.get("/")
def root():
    return {"status": "ok"}
  • Delete the root endpoint and replace it with this new endpoints section:
# --- Endpoints ---

@app.post("/shorten", response_model=URLResponse, status_code=201)
def create_short_url(url_data: URLCreate, session: SessionDep):
    # Generate a short code and ensure it's unique in the database
    short_code = generate_short_code()
    while session.exec(select(URL).where(URL.short_code == short_code)).first():
        short_code = generate_short_code()

    # Create the URL record and persist it
    url_entry = URL(
        original_url=str(url_data.url),
        short_code=short_code,
        created_at=datetime.now(timezone.utc).isoformat(),
    )
    session.add(url_entry)
    session.commit()
    session.refresh(url_entry)

    # Return the shortened URL details
    return URLResponse(
        original_url=url_entry.original_url,
        short_code=url_entry.short_code,
        short_url=f"http://127.0.0.1:8000/{url_entry.short_code}",
    )

What does this endpoint do?

  • status_code=201 tells FastAPI to return HTTP 201 Created instead of the default 200 OK. This follows REST conventions for resource creation.
  • The while loop queries the database to check if the generated code already exists. If it does (extremely unlikely), it generates a new one.
  • str(url_data.url) converts the Pydantic HttpUrl object into a plain string for storage.
  • session.refresh(url_entry) reloads the record from the database so the auto-generated id field is populated.
  • Save main.py.
  • Switch to your browser and visit http://127.0.0.1:8000/docs to open Swagger UI.
  • Click the POST /shorten endpoint to expand it.
  • Click Try it out.
  • In the request body, enter this JSON:
{
  "url": "https://www.python.org"
}
  • Click Execute.
  • You should see a 201 response with a JSON body containing original_url, short_code, and short_url fields.

What about invalid URLs?

Try sending an invalid URL (like not-a-url) in the request body. FastAPI automatically returns a 422 response with details about what went wrong. You didn't write any validation code for this. Pydantic's HttpUrl type handles it for free.

Getting a 500 Internal Server Error?

  • Check the terminal running fastapi dev for a traceback. The most common issue is a missing import.
  • Make sure you deleted the old @app.get("/") endpoint. Having duplicate route definitions can cause issues.
  • Verify that from datetime import datetime, timezone is in your imports. Without it, the created_at line will fail.

Ask for help: My POST /shorten endpoint is returning a 500 error. Help me debug it.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

import secrets
import string
from datetime import datetime, timezone
from typing import Annotated

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


# --- Database Setup ---

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


# --- Database Model ---

class URL(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    original_url: str
    short_code: str = Field(index=True, unique=True)
    created_at: str
    clicks: int = Field(default=0)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


# --- Session Dependency ---

def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- Request/Response Schemas ---

class URLCreate(BaseModel):
    url: HttpUrl


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


# --- Helper Functions ---

def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(secrets.choice(characters) for _ in range(length))


# --- App ---

app = FastAPI(title="URL Shortener API")


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


# --- Endpoints ---

@app.post("/shorten", response_model=URLResponse, status_code=201)
def create_short_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()

    url_entry = URL(
        original_url=str(url_data.url),
        short_code=short_code,
        created_at=datetime.now(timezone.utc).isoformat(),
    )
    session.add(url_entry)
    session.commit()
    session.refresh(url_entry)

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

Your API can now shorten URLs and store them in the database. Next up, you'll build the redirect endpoint so visiting a short URL actually takes you to the original page.

Build the Redirect Endpoint

Your POST /shorten endpoint is creating short URLs and storing them in the database. But a URL shortener is only useful if those short URLs actually take people somewhere.

In this step, you will build the redirect endpoint. When someone visits a short URL, your API will look up the original URL in the database, track the click, and send the visitor to the right destination using an HTTP redirect.

In this step, get ready to:
  • Implement a GET endpoint that redirects visitors to the original URL.
  • Understand how HTTP 307 redirects work.
  • Test the full shorten-and-redirect flow in your browser.
Implement the GET /{short_code} endpoint

The redirect endpoint is the core of any URL shortener. When a visitor hits a short URL, your API needs to look up the matching entry in the database, record that someone clicked the link, and send them to the original destination.

  • In main.py, scroll to the bottom of the file (after the create_short_url function) and add the redirect endpoint by copying this code:
@app.get("/{short_code}")
def redirect_to_url(short_code: str, session: SessionDep):
    # Look up the short code in the database
    url_entry = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")

    # Increment the click counter
    url_entry.clicks += 1
    session.add(url_entry)
    session.commit()

    # Redirect the visitor to the original URL
    return RedirectResponse(url=url_entry.original_url, status_code=307)

What does this code do?

This endpoint captures any GET request where the path is a single segment (like /aB3xKm). FastAPI passes that segment as the short_code parameter.

  • session.exec(select(URL).where(URL.short_code == short_code)).first() queries the database for a matching entry.
  • If no entry is found, the endpoint raises a 404 error with a JSON message.
  • If found, it increments the clicks counter by 1 and commits the change to the database.
  • RedirectResponse tells the browser to navigate to the original URL with a 307 status code.
  • Save main.py.
  • Your dev server auto-reloads. Check your terminal to confirm it says Application startup complete with no errors.

Seeing an indentation or syntax error?

Make sure the @app.get decorator is at the same indentation level as your existing @app.post decorator (no extra indent).

Check that RedirectResponse is imported at the top of the file. It should already be there from a previous step: from fastapi.responses import RedirectResponse.

help me fix a syntax error in my redirect endpoint

Understand the HTTP 307 redirect

You might wonder why the endpoint returns a 307 status code instead of a more common 301 or 302. The choice matters for API behaviour.

Why 307 Temporary Redirect?

HTTP 307 preserves the original HTTP method. If a client sends a POST request to your short URL, the redirect keeps it as a POST. A 301 or 302 redirect might silently convert it to a GET, which can break API integrations.

The browser follows the Location header in the response to navigate to the original URL. The default status code for RedirectResponse in FastAPI is already 307, so the explicit status_code=307 is technically optional. It is included for clarity.

Test the full flow

Time to see the whole system work end-to-end. You will create a short URL, then visit it in your browser to confirm the redirect happens.

  • Open http://127.0.0.1:8000/docs in your browser to access Swagger UI.
  • Expand the POST /shorten endpoint and click Try it out.
  • In the request body, enter a URL like {"url": "https://www.python.org"} and click Execute.
  • In the 201 response, find the short_code value (something like aB3xKm) and copy it.
  • Open a new browser tab and visit http://127.0.0.1:8000/ followed by the short code you copied.
  • Verify that your browser redirects to the original URL (in this example, the Python homepage).

What should I see?

Your browser address bar should change from http://127.0.0.1:8000/aB3xKm (your short code) to the full destination URL. The redirect happens almost instantly.

  • Now test the error case. In your browser, visit http://127.0.0.1:8000/nonexistent.
  • Verify you see a JSON error response: {"detail":"Short URL not found"}.

Not getting redirected?

Make sure you copied only the short code (like aB3xKm), not the full short_url field. The browser address should be http://127.0.0.1:8000/{your_code}.

If you see a 404 error for a code you just created, confirm the dev server reloaded successfully after you saved. Check the terminal for any error messages.

help me debug why my short URL redirect is not working

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and confirmed the redirect works in your browser.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point in the project:

import secrets
import string
from datetime import datetime, timezone
from typing import Annotated

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


# --- Database Setup ---

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


# --- Database Model ---

class URL(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    original_url: str
    short_code: str = Field(index=True, unique=True)
    created_at: str
    clicks: int = Field(default=0)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


# --- Session Dependency ---

def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- Request/Response Schemas ---

class URLCreate(BaseModel):
    url: HttpUrl


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


# --- Helper Functions ---

def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(secrets.choice(characters) for _ in range(length))


# --- App ---

app = FastAPI(title="URL Shortener API")


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


# --- Endpoints ---

@app.post("/shorten", response_model=URLResponse, status_code=201)
def create_short_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()

    url_entry = URL(
        original_url=str(url_data.url),
        short_code=short_code,
        created_at=datetime.now(timezone.utc).isoformat(),
    )
    session.add(url_entry)
    session.commit()
    session.refresh(url_entry)

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


@app.get("/{short_code}")
def redirect_to_url(short_code: str, session: SessionDep):
    url_entry = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")

    url_entry.clicks += 1
    session.add(url_entry)
    session.commit()

    return RedirectResponse(url=url_entry.original_url, status_code=307)

Your URL shortener now handles the full create-and-redirect cycle. Next up, you will add a statistics endpoint so you can see how many times each short URL has been clicked.

Add URL Statistics

Your URL shortener can create short links and redirect visitors. But how do you know if anyone is actually using them?

In this step, you'll add a statistics endpoint that reports how many times each short URL has been clicked. This gives you a read-only view into your data without triggering a redirect.

In this step, get ready to:
  • Define a stats response schema with Pydantic.
  • Implement a GET endpoint that returns URL click counts.
  • Test the complete three-endpoint API through Swagger UI.
Define the stats response schema

The stats endpoint needs its own response shape. It returns metadata about the URL (when it was created, how many clicks it has) instead of triggering a redirect.

  • In main.py, find the URLResponse class under the # --- Request/Response Schemas --- section.
  • Add the following URLStats class directly below URLResponse:
class URLStats(BaseModel):
    original_url: str
    short_code: str
    created_at: str
    clicks: int

What does this code do?

URLStats is a Pydantic model that defines the shape of the stats response. It includes created_at and clicks so the caller can see when the URL was created and how often it has been visited.

Unlike URLResponse (which includes the full short_url string), this schema focuses on analytics data only.

  • Save main.py.
  • You won't see a change yet. The next chunk uses this schema in a new endpoint.
Implement the GET /{short_code}/stats endpoint

This endpoint queries the database for a URL entry and returns its statistics without triggering a redirect. Route placement matters here.

  • In main.py, scroll down to the # --- Endpoints --- section.
  • Find the redirect_to_url function (the one decorated with @app.get("/{short_code}")).
  • Add the following new endpoint directly above the redirect_to_url function:
@app.get("/{short_code}/stats", response_model=URLStats)
def get_url_stats(short_code: str, session: SessionDep):
    # Look up the URL entry by its short code
    url_entry = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")

    # Return the stats without triggering a redirect
    return URLStats(
        original_url=url_entry.original_url,
        short_code=url_entry.short_code,
        created_at=url_entry.created_at,
        clicks=url_entry.clicks,
    )

What does this code do?

The endpoint queries the database for the matching short_code. If no entry exists, it raises a 404 error. Otherwise, it returns the URL's metadata and click count.

Notice this endpoint does NOT increment the click counter. It is a read-only stats view, unlike the redirect endpoint which modifies data on every request.

Why define this route above the redirect route?

FastAPI evaluates routes in the order they are defined. The path /{short_code}/stats is more specific than /{short_code}. Placing the stats route first ensures that a request to /abc123/stats matches the stats handler, not the redirect handler.

  • Save main.py.
  • Switch to your browser and refresh the Swagger UI at http://127.0.0.1:8000/docs. You should see a new GET /{short_code}/stats endpoint listed alongside your other two endpoints.

Don't see the new endpoint in Swagger UI?

Make sure you placed the get_url_stats function above redirect_to_url in your file. Both functions should be in the # --- Endpoints --- section.

Check your terminal for error messages. A common cause is a missing comma or incorrect indentation in the URLStats return statement.

Why isn't my stats endpoint showing up in Swagger UI?

Test the complete API

Your API now has three endpoints working together. Let's test the full flow to confirm the stats endpoint returns accurate click counts.

  • In the Swagger UI at http://127.0.0.1:8000/docs, expand the POST /shorten section and click Try it out.
  • Enter a URL like https://www.python.org and click Execute.
  • Copy the short_code value from the response.
  • Open a new browser tab and visit http://127.0.0.1:8000/{short_code} (replacing {short_code} with your actual code). Do this 2-3 times to build up clicks.
  • Back in the Swagger UI, expand the GET /{short_code}/stats section and click Try it out.
  • Enter the same short code and click Execute.
  • Verify the clicks field matches the number of times you visited the short URL.

What should I see?

The stats response looks something like this:

{
  "original_url": "https://www.python.org/",
  "short_code": "aB3xKm",
  "created_at": "2026-06-22T10:30:00+00:00",
  "clicks": 3
}

The clicks count goes up each time someone visits the redirect endpoint. Hitting the stats endpoint itself does not increment the counter.

Seeing 0 clicks even after visiting the short URL?

Make sure you are visiting http://127.0.0.1:8000/{short_code} (the redirect endpoint) in your browser, not the /stats path. Only the redirect endpoint increments the counter.

Also double check you are using the same short code for both the redirect visits and the stats query.

Why are my click counts not incrementing when I visit the short URL?

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that your server is still running.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

import secrets
import string
from datetime import datetime, timezone
from typing import Annotated

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


# --- Database Setup ---

sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, connect_args=connect_args)


# --- Database Model ---

class URL(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    original_url: str
    short_code: str = Field(index=True, unique=True)
    created_at: str
    clicks: int = Field(default=0)


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)


# --- Session Dependency ---

def get_session():
    with Session(engine) as session:
        yield session


SessionDep = Annotated[Session, Depends(get_session)]


# --- Request/Response Schemas ---

class URLCreate(BaseModel):
    url: HttpUrl


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


class URLStats(BaseModel):
    original_url: str
    short_code: str
    created_at: str
    clicks: int


# --- Helper Functions ---

def generate_short_code(length: int = 6) -> str:
    characters = string.ascii_letters + string.digits
    return "".join(secrets.choice(characters) for _ in range(length))


# --- App ---

app = FastAPI(title="URL Shortener API")


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


# --- Endpoints ---

@app.post("/shorten", response_model=URLResponse, status_code=201)
def create_short_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()

    url_entry = URL(
        original_url=str(url_data.url),
        short_code=short_code,
        created_at=datetime.now(timezone.utc).isoformat(),
    )
    session.add(url_entry)
    session.commit()
    session.refresh(url_entry)

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


@app.get("/{short_code}/stats", response_model=URLStats)
def get_url_stats(short_code: str, session: SessionDep):
    url_entry = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")

    return URLStats(
        original_url=url_entry.original_url,
        short_code=url_entry.short_code,
        created_at=url_entry.created_at,
        clicks=url_entry.clicks,
    )


@app.get("/{short_code}")
def redirect_to_url(short_code: str, session: SessionDep):
    url_entry = session.exec(select(URL).where(URL.short_code == short_code)).first()
    if not url_entry:
        raise HTTPException(status_code=404, detail="Short URL not found")

    url_entry.clicks += 1
    session.add(url_entry)
    session.commit()

    return RedirectResponse(url=url_entry.original_url, status_code=307)

Secret mission

Custom Short Codes and URL Expiration

Your URL shortener works, but every short code is randomly generated. What if users want a vanity code like 'my-blog'? And what about links that should only work for a limited time? In this mission, you'll add custom short codes and time-based URL expiration to make your API production-ready.

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:

  • The url-shortener project directory on your Desktop (includes main.py, .venv, and database.db).
  • The FastAPI dev server process (running in your terminal).

Keep everything running

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

  • Your FastAPI dev server continues running in the terminal.
  • Your database.db file keeps all the short URLs you created during testing.
  • Your virtual environment stays activated and ready for development.

Pause - I'll come back to this later

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

  • Switch to the terminal where the FastAPI dev server is running.
  • Press Ctrl+C to stop the server.
  • Deactivate the virtual environment by running:
deactivate

When you come back, navigate to the project folder, reactivate the virtual environment, and start the server again.

Delete - I don't want to use this again

Remove all project files and start fresh. This deletes your code, database, and virtual environment permanently.

  • Switch to the terminal where the FastAPI dev server is running.
  • Press Ctrl+C to stop the server.
  • Deactivate the virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the project folder.

macOS

cd ~/Desktop
rm -rf url-shortener

Windows

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

This removes main.py, database.db, the .venv virtual environment, and all installed packages.

Nice Work!

Nice Work!

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

You've learned how to:

  • Build a complete REST API with FastAPI featuring POST, GET, and redirect endpoints that create short URLs, track clicks, and return statistics.
  • Persist data to a SQLite database using SQLModel and wire up dependency injection for database sessions with FastAPI's Depends system.
  • Use Pydantic for automatic request validation and work with HTTP status codes like 201 Created, 307 Temporary Redirect, and 404 Not Found to build a production-style API with auto-generated Swagger UI documentation.
  • Secret Mission: Added custom vanity short codes and time-based URL expiration with 409 Conflict and 410 Gone status codes.

Ready to quiz yourself?