Build a URL Shortener API with FastAPI
Build a URL shortener REST API with Base62 encoding and click analytics.
Introduction
30 Second Summary
Every time you click a shortened link, a server translates that tiny code back into the full URL and sends you on your way. URL shortener design is one of the most common system design interview questions at major tech companies.
In this project, you will build a URL shortener REST API that creates short links, redirects visitors, and tracks click analytics. You will implement the same Base62 encoding algorithm that interviewers ask about, and walk away with working code you can demo live.
What You'll Build
A fully functional URL shortener REST API (like a mini Bitly) that creates short links, redirects visitors, and tracks analytics, with auto-generated interactive documentation.
By the end of this project, you'll have:
- A FastAPI application with 4 endpoints for creating short URLs, redirecting visitors, listing links, and viewing analytics.
- A Base62 encoding module that converts database IDs into short codes (the core algorithm interviewers ask about).
- A SQLite database with two related tables for URLs and click tracking, connected by foreign keys.
- Secret Mission: Add custom aliases and link expiration for production-ready link management.
Are there any prerequisites?
You should have basic Python knowledge and experience building a simple API (for example, a task manager project). You do not need prior experience with FastAPI or databases.
Set Up the Project
Before you can build the URL shortener, you need a clean development environment. This means a dedicated project folder, an isolated Python environment, and the right packages installed.
In this step, you will create the project folder, install FastAPI and SQLModel, and set up the four files that will hold your application code.
In this step, get ready to:
- Create the project folder and virtual environment.
- Install FastAPI and SQLModel.
- Create the project file structure.
Create a project folder and virtual environment
A virtual environment isolates your project's packages from the rest of your system. This prevents version conflicts between projects.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal 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
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the url-shortener folder on your Desktop, then click Open.
- Open the built-in terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
Now create and activate the virtual environment. The commands differ by operating system.
- Create the virtual environment by running:
python -m venv venv
What is a virtual environment?
A virtual environment is a self-contained folder that holds its own copy of Python and any packages you install. This keeps your project's dependencies separate from other Python projects on your machine.
- Activate the virtual environment by running the command for your operating system:
macOS
source venv/bin/activate
You should see (venv) appear at the start of your terminal prompt.
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt.
- Verify your Python version by running:
python --version
You should see Python 3.13.0 in the output.
Not seeing Python 3.13.0?
If you see a different version number, make sure your virtual environment is activated (you should see (venv) in your prompt). If you see a version lower than 3.13, you may need to install Python 3.13 from python.org and recreate the virtual environment using the full path to the new binary.
Help me fix my Python version
Install dependencies
Your project needs two main packages: FastAPI (the web framework) and SQLModel (the database library). Installing FastAPI with the standard extras also installs uvicorn, the server that runs your app.
- Install FastAPI and its development server by running:
pip install "fastapi[standard]"
What does the [standard] part mean?
The [standard] extra tells pip to install FastAPI along with everything you need for development, including the uvicorn server and the fastapi CLI tool. Without it, you would need to install uvicorn separately.
- Install SQLModel by running:
pip install sqlmodel
What is SQLModel?
SQLModel combines two powerful libraries into one: Pydantic (for data validation) and SQLAlchemy (for database operations). It lets you define your database tables and API schemas using the same Python classes.
- Verify both packages installed correctly by running:
pip list
You should see fastapi and sqlmodel in the list of installed packages.
Packages not showing up?
Make sure your virtual environment is activated before running pip install. You should see (venv) in your terminal prompt. If you installed without it active, deactivate, reactivate, and run the install commands again.
Help me troubleshoot my pip install
Create the project file structure
Your URL shortener needs four Python files. Each one has a specific responsibility: the encoding algorithm, database configuration, data models, and the main application.
- Create the four project files by running this command:
macOS
touch main.py models.py database.py base62.py
Windows
New-Item main.py, models.py, database.py, base62.py
- Confirm the files exist by running:
ls
You should see base62.py, database.py, main.py, and models.py listed in the output (along with the venv folder).
- Verify the FastAPI CLI is installed by running:
fastapi --version
You should see a version number printed. This confirms that the fastapi command-line tool is ready to use.
Why not start the server yet?
The fastapi dev main.py command expects to find a FastAPI app object inside main.py. Since the file is empty right now, the command would produce an error. You will add the app object in Step 3 and run the server then.
Command not found?
If your terminal says the fastapi command is not found, make sure your virtual environment is activated and that you installed FastAPI with the [standard] extra. Try running pip install "fastapi[standard]" again.
Help me fix the fastapi command
Your environment is ready. Next up, you will write the Base62 encoding algorithm and set up the database layer.
Build the Base62 Algorithm and Database Layer
Your project now has four empty Python files and all the dependencies installed. Before building any API endpoints, you need two foundation layers: an encoding algorithm to generate short codes, and a database layer to persist URLs and track clicks.
URL shorteners convert a numeric database ID into a compact string using Base62 encoding. This is the core algorithm that system design interviewers ask about. You will implement it from scratch, then wire up the database models that store both the shortened URLs and click analytics.
In this step, get ready to:
- Implement the Base62 encoding algorithm that converts integers to short strings.
- Configure a SQLite database with helper functions for FastAPI.
- Define data models for URLs and click tracking with proper relationships.
Implement the Base62 encoding module
Base62 uses 62 characters (0-9, a-z, A-Z) to represent numbers as short strings. With just 7 characters, Base62 can encode 3.5 trillion unique values.
- Open base62.py in VS Code and add the complete encoding module by pasting this code:
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode(num: int) -> str:
"""Convert a base-10 integer to a base-62 string."""
if num == 0:
return ALPHABET[0]
result = []
base = len(ALPHABET)
while num > 0:
num, remainder = divmod(num, base)
result.append(ALPHABET[remainder])
result.reverse()
return "".join(result)
def decode(string: str) -> int:
"""Convert a base-62 string back to a base-10 integer."""
base = len(ALPHABET)
num = 0
for char in string:
num = num * base + ALPHABET.index(char)
return num
What does this code do?
ALPHABET defines the 62 characters available for encoding. The order matters because each character's position determines its numeric value.
encode() works like converting decimal to binary, except the base is 62 instead of 2. It repeatedly divides the number by 62 and maps each remainder to a character.
decode() reverses the process by walking each character and multiplying by the base. Together, these two functions guarantee that every database ID maps to a unique short code with zero collisions.
- Save base62.py.
- Verify the module works by running this command in your terminal:
python -c "import base62; print(base62.encode(1)); print(base62.encode(100)); print(base62.decode('1C'))"
You should see three lines of output: 1, 1C, and 100. This confirms that encoding and decoding are working as inverses of each other.
Seeing a ModuleNotFoundError?
Make sure your terminal is in the url-shortener folder (the same directory where base62.py lives). Also confirm your virtual environment is still active. You should see (venv) at the start of your terminal prompt.
help me fix a ModuleNotFoundError when importing base62
✔️ Awesome, I've got everything!
Great. Double check you have saved base62.py and move on.
ⓧ I'd like to double check the full code
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def encode(num: int) -> str:
"""Convert a base-10 integer to a base-62 string."""
if num == 0:
return ALPHABET[0]
result = []
base = len(ALPHABET)
while num > 0:
num, remainder = divmod(num, base)
result.append(ALPHABET[remainder])
result.reverse()
return "".join(result)
def decode(string: str) -> int:
"""Convert a base-62 string back to a base-10 integer."""
base = len(ALPHABET)
num = 0
for char in string:
num = num * base + ALPHABET.index(char)
return num
Set up the database layer
FastAPI handles requests across multiple threads, but SQLite's default Python driver restricts database connections to the thread that created them. The database module configures the engine to work safely with FastAPI and exposes a session dependency that endpoints will use.
- Open database.py and add the database configuration by pasting this code:
from sqlmodel import SQLModel, Session, create_engine
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)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
What does this code do?
check_same_thread: False tells SQLite to allow connections from any thread. This is necessary because FastAPI processes requests in a thread pool.
create_db_and_tables() reads all your SQLModel table definitions and creates the corresponding SQLite tables. You will call this once when the app starts.
get_session() is a dependency that FastAPI injects into each endpoint. The yield pattern ensures the session is properly closed after each request.
- Save database.py.
- Verify the engine is configured correctly by running:
python -c "from database import engine; print(engine.url)"
You should see sqlite:///url_shortener.db printed in your terminal.
Seeing an ImportError?
Confirm that your virtual environment is active (look for (venv) in your prompt) and that you installed sqlmodel in the previous step. You can verify by running pip list and checking that sqlmodel appears in the output.
help me fix an ImportError with sqlmodel in my database.py
✔️ Awesome, I've got everything!
Great. Double check you have saved database.py and move on.
ⓧ I'd like to double check the full code
from sqlmodel import SQLModel, Session, create_engine
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)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
Define data models
Your API needs two database tables: one for stored URLs and one for click events. You also need request and response schemas that control what data comes in and goes out of each endpoint.
Why one file for both tables and schemas?
SQLModel combines Pydantic validation with SQLAlchemy ORM in a single model definition. Table models (marked with table=True) map to database tables. Plain models act as request/response schemas with automatic validation.
- Open models.py and start by adding the imports and URL table model:
from datetime import datetime
from pydantic import field_validator
from sqlmodel import Field, SQLModel
class URLBase(SQLModel):
original_url: str
class URL(URLBase, table=True):
id: int | None = Field(default=None, primary_key=True)
short_code: str = Field(default="", index=True)
created_at: datetime = Field(default_factory=datetime.now)
What does this code do?
URLBase holds the field shared across all URL-related models: original_url. Other URL models inherit from it to avoid repeating this field.
URL is the actual database table (table=True). The id field auto-increments and becomes the input to your Base62 encoder. The short_code field is indexed for fast lookups during redirects.
- Below the URL class, add the request and response schemas for URLs:
class URLCreate(URLBase):
@field_validator("original_url")
@classmethod
def validate_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
class URLPublic(URLBase):
id: int
short_code: str
created_at: datetime
What does this code do?
URLCreate is the schema for incoming requests. The field_validator decorator from Pydantic rejects any URL that does not start with http:// or https://.
URLPublic is the response schema. It controls exactly which fields appear in API responses, keeping internal details hidden.
- Below URLPublic, add the click tracking models and the analytics response schema:
class Click(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
url_id: int = Field(foreign_key="url.id")
clicked_at: datetime = Field(default_factory=datetime.now)
ip_address: str | None = None
user_agent: str | None = None
class ClickPublic(SQLModel):
id: int
clicked_at: datetime
ip_address: str | None
user_agent: str | None
class URLStats(SQLModel):
short_code: str
original_url: str
created_at: datetime
total_clicks: int
recent_clicks: list[ClickPublic]
What does this code do?
Click is the second database table. The foreign_key="url.id" on url_id links each click to the URL that was visited. This relationship lets you query all clicks for a given short code.
URLStats is the analytics response schema. It bundles the URL metadata, total click count, and the 10 most recent click records into a single response.
- Save models.py.
- Verify all models load correctly by running:
python -c "from models import URL, Click, URLStats; print('Models loaded successfully')"
You should see Models loaded successfully printed in your terminal.
Seeing a validation or import error?
Check that your models.py starts with the three import lines (datetime, field_validator, Field and SQLModel). A missing import is the most common cause of errors here.
Also verify that foreign_key="url.id" uses a lowercase url (the table name SQLModel derives from the class name URL).
help me debug an import error in my models.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved models.py and move on.
ⓧ I'd like to double check the full code
from datetime import datetime
from pydantic import field_validator
from sqlmodel import Field, SQLModel
class URLBase(SQLModel):
original_url: str
class URL(URLBase, table=True):
id: int | None = Field(default=None, primary_key=True)
short_code: str = Field(default="", index=True)
created_at: datetime = Field(default_factory=datetime.now)
class URLCreate(URLBase):
@field_validator("original_url")
@classmethod
def validate_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
class URLPublic(URLBase):
id: int
short_code: str
created_at: datetime
class Click(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
url_id: int = Field(foreign_key="url.id")
clicked_at: datetime = Field(default_factory=datetime.now)
ip_address: str | None = None
user_agent: str | None = None
class ClickPublic(SQLModel):
id: int
clicked_at: datetime
ip_address: str | None
user_agent: str | None
class URLStats(SQLModel):
short_code: str
original_url: str
created_at: datetime
total_clicks: int
recent_clicks: list[ClickPublic]
Your foundation is solid. You have an encoding algorithm, a configured database, and a full set of data models ready to go. Next up, you will wire these pieces together into a working API endpoint that creates short URLs.
Create the URL Shortening Endpoint
You have the encoding algorithm and database layer ready. Now it is time to bring them together into a working API endpoint.
In this step, you will create the FastAPI application and build the first endpoint: POST /shorten. This endpoint accepts a URL, saves it to the database, encodes the auto-generated ID with Base62, and returns the short code.
In this step, get ready to:
- Set up the FastAPI app with a lifespan handler that creates tables on startup.
- Build the POST /shorten endpoint that generates Base62 short codes.
- Test the endpoint in Swagger UI.
Set up the FastAPI app in main.py
Every FastAPI app needs an application instance and a way to run startup tasks. The lifespan handler is the modern pattern for running code when the app boots up (like creating database tables).
- Open main.py in VS Code and add the following code to set up the app:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import URL, URLCreate, URLPublic, Click, ClickPublic, URLStats
from base62 import encode
# Create database tables when the app starts
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
# Initialize the FastAPI app with metadata and lifespan handler
app = FastAPI(
title="URL Shortener API",
description="A URL shortening service with click analytics",
lifespan=lifespan,
)
What does this code do?
The imports pull in everything you will need across all endpoints. Some (like HTTPException and RedirectResponse) are for endpoints you will build in the next step.
The lifespan function uses Python's asynccontextmanager to run create_db_and_tables() once when the server starts. The yield keeps the app running until you shut it down.
- Save main.py.
You won't see a change yet. The next chunk adds the endpoint that makes this app do something useful.
Build the POST /shorten endpoint
The shortening endpoint is where the Base62 algorithm meets the database. The pattern is: save the URL to get an auto-increment ID, then encode that ID into a short code.
- In main.py, add the following endpoint below the app = FastAPI(...) block:
@app.post("/shorten", response_model=URLPublic)
def create_short_url(url_data: URLCreate, session: Session = Depends(get_session)):
# Save the URL to get an auto-generated ID
db_url = URL(original_url=url_data.original_url)
session.add(db_url)
session.commit()
session.refresh(db_url)
# Encode the ID as a Base62 short code
db_url.short_code = encode(db_url.id)
session.add(db_url)
session.commit()
session.refresh(db_url)
return db_url
What does this code do?
- The @app.post("/shorten") decorator registers this function as the handler for POST requests to /shorten.
- The response_model=URLPublic tells FastAPI to validate the response and auto-generate documentation for it.
- The first commit saves the URL and gets a database-assigned ID. The second commit updates the record with the Base62 short code derived from that ID.
- Depends(get_session) is FastAPI's dependency injection pattern. It provides a fresh database session for each request and closes it afterwards.
Why two commits?
You need the database to assign an ID before you can encode it. The first commit triggers the auto-increment, giving you a numeric ID. The second commit saves the Base62-encoded version of that ID as the short code. This "two-commit pattern" is a common system design interview talking point for counter-based URL shorteners.
- Save main.py.
Seeing a red underline on imports?
If VS Code shows import errors for fastapi or sqlmodel, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and search for "Python: Select Interpreter". Choose the one inside your venv folder.
Help me fix import errors in my FastAPI project
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import URL, URLCreate, URLPublic, Click, ClickPublic, URLStats
from base62 import encode
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="URL Shortener API",
description="A URL shortening service with click analytics",
lifespan=lifespan,
)
@app.post("/shorten", response_model=URLPublic)
def create_short_url(url_data: URLCreate, session: Session = Depends(get_session)):
db_url = URL(original_url=url_data.original_url)
session.add(db_url)
session.commit()
session.refresh(db_url)
db_url.short_code = encode(db_url.id)
session.add(db_url)
session.commit()
session.refresh(db_url)
return db_url
Test in Swagger UI
FastAPI auto-generates interactive API documentation at /docs. You can use this to test your endpoint without writing any client code.
- Start the development server by running this command in your terminal:
fastapi dev main.py
You should see output ending with Application startup complete and the server running at http://127.0.0.1:8000.
- Open your browser and navigate to http://127.0.0.1:8000/docs.
- Click on the POST /shorten endpoint to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{
"original_url": "https://www.example.com"
}
- Click Execute.
You should see a 200 response with a JSON body containing your original URL, an id of 1, and a short_code of 1 (since Base62 encodes the integer 1 as the string "1").
What is Swagger UI?
Swagger UI is an interactive documentation page that FastAPI generates automatically from your endpoint definitions and Pydantic models. It lets you call your API directly from the browser without needing tools like curl or Postman.
Server won't start?
- If you see a ModuleNotFoundError, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- If you see a SyntaxError, double check that your main.py matches the full code in the tab above. Pay attention to indentation and colons.
- If the port is already in use, you may have another server running. Press Ctrl+C in any other terminal windows running a server, then try again.
Help me debug my FastAPI server startup error
Your API can now shorten URLs. Next up, you will build the redirect engine that turns those short codes into actual redirects, complete with click tracking.
Build the Redirect Engine with Click Tracking
Your POST /shorten endpoint is generating short codes. But a short URL is useless if visiting it does nothing.
In this step, you'll build the redirect engine that sends visitors to the original URL and records every click. You'll use a 302 status code so that every visit hits your server instead of being cached by the browser.
In this step, get ready to:
- Build a listing endpoint to see all shortened URLs.
- Build the redirect endpoint with click tracking.
- Test the full redirect flow end to end.
Build the GET /api/urls listing endpoint
Before building the redirect, you need a quick way to see what URLs exist in the database. A listing endpoint lets you verify short codes without opening the database file directly.
- In main.py, find the closing return db_url line of the create_short_url function.
- Add the following endpoint below it:
@app.get("/api/urls", response_model=list[URLPublic])
def list_all_urls(session: Session = Depends(get_session)):
# Query all URL records from the database
urls = session.exec(select(URL)).all()
return urls
What does this code do?
This endpoint queries every row in the URL table and returns them as a list. The response_model=list[URLPublic] tells FastAPI to serialize each record using your URLPublic schema.
The /api/urls path uses an /api/ prefix to distinguish it from the redirect route you'll add next. This prevents FastAPI from confusing it with a short code lookup.
- Save main.py.
- Switch to the Swagger UI at http://127.0.0.1:8000/docs and refresh the page. You should see a new GET /api/urls endpoint listed.
Don't see the new endpoint?
Make sure you saved main.py. The dev server auto-reloads on save, but unsaved changes won't appear.
Check your terminal for error messages. A syntax error (like a missing colon or wrong indentation) will stop the reload.
Help me debug why my new endpoint isn't showing in Swagger
Build the GET /{short_code} redirect endpoint
This is the core of your URL shortener. When someone visits a short link, the API needs to find the original URL, record the click for analytics, and send the visitor to the right place.
- In main.py, add the redirect endpoint below the list_all_urls function you just wrote:
@app.get("/{short_code}")
def redirect_to_url(
short_code: str, request: Request, session: Session = Depends(get_session)
):
# 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")
# Record the click with visitor details
click = Click(
url_id=db_url.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.add(click)
session.commit()
# Send the visitor to the original URL
return RedirectResponse(url=db_url.original_url, status_code=302)
What does this code do?
- The function accepts a short_code path parameter and a Request object. The request gives you access to the visitor's IP address and browser user agent.
- If no matching short code exists, it raises a 404 error.
- Before redirecting, it creates a Click record. This is your analytics data: who visited, when, and from what browser.
- The RedirectResponse with status_code=302 tells the browser to go to the original URL. The 302 means "temporary redirect" so the browser always comes back to your server instead of caching the destination.
Why 302 instead of 301?
A 301 redirect means "permanent." Browsers cache 301s, so future visits skip your server entirely. That means no analytics tracking.
A 302 redirect means "temporary." Every click hits your server, letting you record it in the Click table. This is how real URL shorteners like Bitly track click counts.
Why does route order matter?
FastAPI matches routes in declaration order. The /{short_code} pattern is a catch-all that would match ANY path, including /api/urls.
By declaring /api/urls first, FastAPI checks it before falling through to the catch-all. If you put /{short_code} first, every request would be treated as a short code lookup.
- Save main.py.
- Refresh Swagger UI. You should see a new GET /{short_code} endpoint at the bottom of the list.
Seeing a server error in the terminal?
Check that the redirect endpoint is placed AFTER the /api/urls endpoint in your file. Declaration order determines which route matches first.
Also verify that Request and RedirectResponse are already imported at the top of your file. They were added in the previous step.
Help me fix an error with my redirect endpoint
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import URL, URLCreate, URLPublic, Click, ClickPublic, URLStats
from base62 import encode
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="URL Shortener API",
description="A URL shortening service with click analytics",
lifespan=lifespan,
)
@app.post("/shorten", response_model=URLPublic)
def create_short_url(url_data: URLCreate, session: Session = Depends(get_session)):
db_url = URL(original_url=url_data.original_url)
session.add(db_url)
session.commit()
session.refresh(db_url)
db_url.short_code = encode(db_url.id)
session.add(db_url)
session.commit()
session.refresh(db_url)
return db_url
@app.get("/api/urls", response_model=list[URLPublic])
def list_all_urls(session: Session = Depends(get_session)):
urls = session.exec(select(URL)).all()
return urls
@app.get("/{short_code}")
def redirect_to_url(
short_code: str, request: Request, session: Session = Depends(get_session)
):
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")
click = Click(
url_id=db_url.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.add(click)
session.commit()
return RedirectResponse(url=db_url.original_url, status_code=302)
Test the full redirect flow
Time to see your redirect engine in action. You'll create a short URL, visit it in your browser, and confirm you land on the original page.
- In Swagger UI at http://127.0.0.1:8000/docs, expand the POST /shorten endpoint.
- Click Try it out.
- Replace the request body with:
{
"original_url": "https://www.example.com"
}
- Click Execute. Note the short_code in the response (it will be 1 if this is your first URL, or a short alphanumeric string).
- Open a new browser tab and visit http://127.0.0.1:8000/1 (replace 1 with whatever short code you received).
Your browser should redirect you to https://www.example.com. Check your terminal where the server is running. You should see a GET request logged for your short code path.
- Back in Swagger UI, expand the GET /api/urls endpoint.
- Click Try it out, then Execute.
- Verify the response shows a list containing the URL you just created, with its short_code and original_url fields populated.
Getting a 404 when visiting the short URL?
If you previously tested POST /shorten and then deleted your database file, the IDs will have reset. Create a fresh short URL and use the new short code from the response.
Also confirm you're using http://127.0.0.1:8000/ as the base, not localhost. Some browsers handle them differently.
Help me debug why my short URL returns 404
Your redirect engine is live. Every short link now sends visitors to the right place and quietly records the click. Next up, you'll build an analytics endpoint that turns those raw click records into useful stats.
Build the Analytics Endpoint
Your redirect engine is recording clicks in the database. Now you need a way to actually see that data.
In this step, you will build an analytics endpoint that aggregates click data for any short URL. This is what transforms your shortener from a toy into a demoable project. Interviewers want to see that you can query related data across tables and shape it into a useful response.
In this step, get ready to:
- Build the GET /analytics/{short_code} endpoint that returns click statistics.
- Test the full analytics flow from creating a URL to viewing click data.
- Verify all 4 endpoints are working together in Swagger UI.
Build the GET /analytics/{short_code} endpoint
The analytics endpoint queries the Click table for all clicks belonging to a specific short URL. It returns a URLStats response containing the total click count and the 10 most recent clicks with their timestamps, IP addresses, and user agents.
- In main.py, find the list_all_urls function. Place your cursor after the closing return urls line of that function, then add a blank line and paste the following code:
@app.get("/analytics/{short_code}", response_model=URLStats)
def get_url_analytics(short_code: str, session: Session = Depends(get_session)):
# 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")
# Query all clicks for this URL, newest first
clicks = session.exec(
select(Click)
.where(Click.url_id == db_url.id)
.order_by(Click.clicked_at.desc())
).all()
# Build the response with the 10 most recent clicks
recent_clicks = [
ClickPublic(
id=click.id,
clicked_at=click.clicked_at,
ip_address=click.ip_address,
user_agent=click.user_agent,
)
for click in clicks[:10]
]
return URLStats(
short_code=db_url.short_code,
original_url=db_url.original_url,
created_at=db_url.created_at,
total_clicks=len(clicks),
recent_clicks=recent_clicks,
)
What does this code do?
This endpoint takes a short code from the URL path and returns aggregated analytics.
- select(Click).where(Click.url_id == db_url.id).order_by(Click.clicked_at.desc()) queries the Click table for all clicks linked to this URL, sorted newest first.
- clicks[:10] slices the list to only the 10 most recent clicks. This keeps the response payload small even for popular links.
- total_clicks uses len(clicks) to count ALL clicks, not just the recent 10.
Why does route order matter here?
FastAPI matches routes in the order they are declared. Your /{short_code} catch-all endpoint would intercept a request to /analytics/abc if it came first, treating "analytics" as a short code.
By declaring /analytics/{short_code} before /{short_code}, FastAPI matches the more specific path first. This is a common pitfall in REST API design.
- Save main.py.
- Your dev server auto-reloads. Check the terminal for a clean restart with no errors.
Seeing an error on reload?
- Make sure the analytics endpoint is placed ABOVE the /{short_code} endpoint and BELOW the /api/urls endpoint in your file.
- Check that URLStats and ClickPublic are both in your imports line from models. They were added in a previous step.
- Verify indentation is consistent. The @app.get decorator should be at the same level as the other route decorators (no extra indentation).
Help me debug my analytics endpoint error
Test the analytics flow end-to-end
Now you will create a short URL, visit it a few times to generate clicks, and then check the analytics endpoint to see the data.
- Open your browser and go to http://127.0.0.1:8000/docs.
- Expand the POST /shorten endpoint and click Try it out.
- Enter a URL like https://example.com in the request body and click Execute. Note the short_code in the response.
- 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). Do this 2-3 times to generate multiple clicks.
- Return to Swagger UI. Expand the GET /analytics/{short_code} endpoint and click Try it out.
- Enter your short code and click Execute.
You should see a JSON response with total_clicks matching the number of times you visited the short URL, and recent_clicks showing each visit with a timestamp, IP address, and user agent string.
Getting 404 on the analytics endpoint?
- Make sure you are entering just the short code (like 1), not the full URL.
- If you deleted your url_shortener.db file or restarted the server since the last step, you need to create a new short URL first using POST /shorten.
Help me troubleshoot my analytics 404 error
Verify your complete API in Swagger
Your API now has all 4 endpoints. The Swagger UI at /docs auto-generates documentation from your type annotations and Pydantic models.
- In Swagger UI, confirm you see all 4 endpoints listed:
- POST /shorten for creating short URLs.
- GET /api/urls for listing all URLs.
- GET /analytics/{short_code} for viewing click stats.
- GET /{short_code} for redirecting visitors.
- Scroll down in Swagger UI to the Schemas section at the bottom. You should see URLCreate, URLPublic, URLStats, and ClickPublic listed there. FastAPI generated these from your Pydantic models automatically.
Why is auto-generated documentation valuable?
This Swagger UI page is a live, interactive API reference that stays in sync with your code. You can use it to demo your API in interviews without needing a separate frontend or Postman collection.
Every time you add type hints and response models, FastAPI updates the docs automatically. This is one of the key advantages of FastAPI over frameworks like Flask.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that your dev server is running without errors.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point in the project:
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from database import create_db_and_tables, get_session
from models import URL, URLCreate, URLPublic, Click, ClickPublic, URLStats
from base62 import encode
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(
title="URL Shortener API",
description="A URL shortening service with click analytics",
lifespan=lifespan,
)
@app.post("/shorten", response_model=URLPublic)
def create_short_url(url_data: URLCreate, session: Session = Depends(get_session)):
db_url = URL(original_url=url_data.original_url)
session.add(db_url)
session.commit()
session.refresh(db_url)
db_url.short_code = encode(db_url.id)
session.add(db_url)
session.commit()
session.refresh(db_url)
return db_url
@app.get("/api/urls", response_model=list[URLPublic])
def list_all_urls(session: Session = Depends(get_session)):
urls = session.exec(select(URL)).all()
return urls
@app.get("/analytics/{short_code}", response_model=URLStats)
def get_url_analytics(short_code: str, session: Session = Depends(get_session)):
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")
clicks = session.exec(
select(Click)
.where(Click.url_id == db_url.id)
.order_by(Click.clicked_at.desc())
).all()
recent_clicks = [
ClickPublic(
id=click.id,
clicked_at=click.clicked_at,
ip_address=click.ip_address,
user_agent=click.user_agent,
)
for click in clicks[:10]
]
return URLStats(
short_code=db_url.short_code,
original_url=db_url.original_url,
created_at=db_url.created_at,
total_clicks=len(clicks),
recent_clicks=recent_clicks,
)
@app.get("/{short_code}")
def redirect_to_url(
short_code: str, request: Request, session: Session = Depends(get_session)
):
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")
click = Click(
url_id=db_url.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
)
session.add(click)
session.commit()
return RedirectResponse(url=db_url.original_url, status_code=302)
Your URL shortener is now feature-complete. You have a working API that creates short links, redirects visitors, tracks clicks, and reports analytics. This is a complete system you can demo in an interview. Next up, tackle the Secret Mission to add custom aliases and link expiration.
Secret mission
Add Custom Aliases and Link Expiration
Your URL shortener works, but real users want vanity URLs like /mysite instead of /1. They also want links that self-destruct after a deadline. In this mission, you will add custom aliases with conflict detection, link expiration with proper HTTP semantics, and a delete endpoint.
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:
- The url-shortener project folder on your Desktop (contains all code, virtual environment, and database).
- The url_shortener.db SQLite database file inside your project folder (free, local, no ongoing cost).
- The FastAPI development server (running locally on port 8000).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your API.
- Your database persists between server restarts. All shortened URLs and click data stay intact.
- Start the server anytime by activating your virtual environment and running fastapi dev main.py from inside the url-shortener folder.
- The SQLite database and virtual environment stay on your machine but use negligible disk space.
Pause - I'll come back to this later
Shut down the running server to free up your terminal and port. Your files and database stay on disk for next time.
- Switch to the terminal where the FastAPI server is running.
- Press Ctrl+C to stop the development server.
- Deactivate your virtual environment by running:
deactivate
- When you are ready to resume, navigate back to your url-shortener folder, activate the virtual environment, and run fastapi dev main.py again.
Delete - I don't want to use this again
Remove all project files and start fresh. There are no cloud resources or accounts to clean up.
- Switch to the terminal where the FastAPI server is running.
- Press Ctrl+C to stop the development server.
- Deactivate your virtual environment by running:
deactivate
- Navigate to your Desktop and delete the entire project folder.
macOS
- Run the following command to delete the project folder:
cd ~/Desktop
rm -rf url-shortener
Windows
- Run the following command to delete the project folder:
cd ~\Desktop
rmdir /s /q url-shortener
- That is everything. No cloud accounts, databases, or services remain.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional URL shortener API from scratch. This is the same system design question that comes up in interviews at major tech companies, and you can now answer it by showing working code.
You've learned how to:
- Build a REST API with FastAPI and implement the Base62 encoding algorithm that powers URL shortener system design.
- Create a SQLite database with two related tables, proper foreign keys, and HTTP redirect handling using 302 status codes with click tracking.
- Build an analytics endpoint that aggregates click data and generate interactive Swagger documentation automatically for live demos.
- Secret Mission: Added custom aliases with conflict detection, link expiration with 410 Gone responses, and a delete endpoint.
Ready to quiz yourself?