Build a URL Shortener API with FastAPI
Build a REST API that shortens URLs using Base62 encoding and SQLite.
Introduction
30 Second Summary
Every time you click a shortened link on social media or in a text message, a URL shortener is doing the work behind the scenes. It maps a long, unwieldy web address to a compact code that redirects visitors to the original destination.
In this project, you will build a URL shortener API from scratch using Python and FastAPI. You will design endpoints that shorten URLs, redirect visitors, store data persistently, and validate input.
What You'll Build
You will paste any URL into the Swagger UI, get back a short code like http://localhost:8000/2, and watch your browser redirect to the original destination when you visit that link.
By the end of this project, you'll have:
- A working POST endpoint where you submit any URL and receive a compact short code. Visit that short code in your browser and it redirects you straight to the original destination.
- Persistent storage using SQLite so your shortened URLs survive server restarts. Stop the server, restart it, and your links still work.
- Input validation that rejects malformed URLs and returns proper HTTP error codes. Try submitting garbage and watch the API reject it cleanly.
- Secret Mission: Add click tracking analytics to count how many times each short URL has been visited.
Are there any prerequisites?
Basic Python knowledge (functions, dictionaries, loops) is all you need. No prior web development or API experience is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your goals.
Set Up Your Project
Every API needs a home. Before you can build endpoints that shorten URLs and redirect visitors, you need a project folder, the FastAPI framework, and a running development server.
In this step, you'll create your project structure, install FastAPI, and write a tiny starter app. By the end, you'll have a live server with auto-generated interactive documentation you can test right in the browser.
In this step, get ready to:
- Create a project directory with a virtual environment and install FastAPI.
- Write a starter app with a single endpoint.
- Run the dev server and confirm the Swagger UI works.
Create a project directory and virtual environment
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir url-shortener
cd url-shortener
- Create a virtual environment by running this command:
python3 -m venv .venv
- Activate the virtual environment by running:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
Your terminal prompt now shows (.venv) at the beginning. This confirms the virtual environment is active and any packages you install stay isolated to this project.
- Install FastAPI and its development server by running:
pip install "fastapi[standard]"
You'll see pip download and install several packages. The output ends with a line confirming all packages were installed successfully.
What does fastapi[standard] include?
The [standard] extra installs uvicorn (the server that runs your app) and the fastapi CLI tool that provides the fastapi dev command you'll use next.
Seeing permission errors during install?
Make sure your terminal prompt starts with (.venv). If it does not, the virtual environment is not active. Re-run the activate command for your platform above.
Help me fix this permission error
Create the starter app
With FastAPI installed, you can write a minimal app that serves a single endpoint. This gives you a working server to verify everything is wired up correctly.
- Open the url-shortener folder in VS Code by clicking File in the menu bar, then Open Folder.
- Navigate to your Desktop, select the url-shortener folder, and click Open.
- If VS Code asks whether you trust the authors of this folder, click Yes, I trust the authors.
- In the Explorer panel on the left, hover over the folder name and click the New File icon.
- Type main.py and press Enter to create the file.
- Paste the following code into main.py:
from fastapi import FastAPI
# Create the FastAPI app instance with a title
app = FastAPI(title="URL Shortener")
# Define a root endpoint that returns a welcome message
@app.get("/")
def root():
return {"message": "Welcome to the URL Shortener API. Visit /docs for interactive documentation."}
What does this code do?
The FastAPI() constructor creates your application. The title parameter sets the name that appears in the auto-generated documentation.
The @app.get("/") decorator registers the root() function as the handler for GET requests to /. When someone visits that path, FastAPI returns the dictionary as JSON automatically.
- Save main.py with Cmd+S (macOS) or Ctrl+S (Windows).
- Open the integrated terminal by pressing Ctrl+`.
- Activate your virtual environment in this new terminal:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
- Start the development server by running:
fastapi dev main.py
What to expect from this command
This command starts a development server with auto-reload enabled. It takes over your terminal and keeps running until you stop it with Ctrl+C.
You'll see output showing the server is running at http://127.0.0.1:8000 with documentation at http://127.0.0.1:8000/docs.
✔️ I see the server started at http://127.0.0.1:8000
Your server is running. Leave this terminal open and move on to the next substep.
ⓧ I see an error
If you see a message about fastapi not being found, your virtual environment is not active. Check that your prompt shows (.venv) and re-run the activate command above.
If you see an error about the module not being found, confirm that main.py exists in the same folder your terminal is in. Run ls to check.
Help me debug this server error
Explore the auto-generated docs
FastAPI automatically generates interactive API documentation called Swagger UI. You can use it to test every endpoint without writing any client code.
- Open your web browser.
- Navigate to http://127.0.0.1:8000/docs.
You should see the Swagger UI page with your app title "URL Shortener" at the top and a single GET / endpoint listed.
- Click the GET / row to expand it.
- Click Try it out.
- Click Execute.
Before you look at the response, what do you think the server will return?
In the Response body section below, you should see:
{"message": "Welcome to the URL Shortener API. Visit /docs for interactive documentation."}
Not seeing the Swagger UI?
Make sure the server is still running in your terminal. If you accidentally closed it, run fastapi dev main.py again.
Double-check the URL is exactly http://127.0.0.1:8000/docs with no trailing spaces or typos.
Help me get the Swagger UI working
✔️ 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
Here is the complete main.py file at this point:
from fastapi import FastAPI
app = FastAPI(title="URL Shortener")
@app.get("/")
def root():
return {"message": "Welcome to the URL Shortener API. Visit /docs for interactive documentation."}
Your development environment is ready and your first endpoint is live. Next up, you'll build the core feature: a POST endpoint that takes a long URL and returns a short code.
Build Your First Endpoint - Shorten a URL
The core value of a URL shortener is turning a long, unwieldy link into something compact and shareable. Right now your API has a single welcome endpoint that does nothing useful.
In this step, you will build a POST endpoint that accepts any URL and returns a short code. You will use Base62 encoding to generate codes from a counter, which makes collisions mathematically impossible.
In this step, get ready to:
- Define a request schema and Base62 encoding function.
- Create a POST /shorten endpoint that returns a short code.
- Test the endpoint live in Swagger UI.
Define the request schema and encoding logic
Before you can accept URLs, you need three things: a schema to validate incoming requests, an encoding function to generate short codes, and a place to store the mappings.
- In main.py, find the line that says from fastapi import FastAPI.
- Add a new import line directly below it so your imports look like this:
from fastapi import FastAPI
from pydantic import BaseModel
What is Pydantic?
Pydantic is a data validation library that FastAPI uses under the hood. When you define a BaseModel, FastAPI automatically validates incoming request data against your schema and returns clear errors if the data does not match.
- Now find the line app = FastAPI(title="URL Shortener").
- Add the following code below it, but above the existing @app.get("/") endpoint:
# Characters used for Base62 encoding (0-9, a-z, A-Z)
BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE_URL = "http://localhost:8000"
# In-memory storage: short_code -> original URL
url_store = {}
counter = 1
def encode_base62(num: int) -> str:
"""Convert an integer to a Base62 short string."""
if num == 0:
return BASE62_CHARS[0]
result = []
while num > 0:
num, remainder = divmod(num, 62)
result.append(BASE62_CHARS[remainder])
return "".join(reversed(result))
# Schema for incoming URL requests
class URLRequest(BaseModel):
url: str
What does this code do?
BASE62_CHARS defines the 62 URL-safe characters (digits, lowercase, uppercase) used to build short codes. BASE_URL is the base address for constructing full short URLs.
The encode_base62 function converts an integer (like 1, 2, 3) into a compact string (like 1, 2, 3). Because each integer maps to exactly one string, collisions are impossible.
url_store is a plain dictionary that maps short codes to original URLs. The counter increments with each new URL so every code is unique.
- Save main.py.
- Check your terminal. The dev server should reload without errors.
Seeing a syntax error in the terminal?
- Make sure the new code sits between app = FastAPI(title="URL Shortener") and the @app.get("/") decorator. Code outside a function must be at the top level (no accidental indentation).
- Confirm the from pydantic import BaseModel line is at the very top of the file, below the FastAPI import.
Help me debug a syntax error in my URL shortener main.py
Create the POST /shorten endpoint
You have the encoding logic and schema ready. Now you need an endpoint that ties them together: accept a URL, generate a short code, store the mapping, and return the result.
- In main.py, scroll to the bottom of the file (after the existing root() function).
- Add the POST endpoint by pasting this code at the end of the file:
@app.post("/shorten")
def shorten_url(request: URLRequest):
global counter
# Generate a short code from the current counter value
short_code = encode_base62(counter)
# Store the mapping: short_code -> original URL
url_store[short_code] = request.url
counter += 1
return {"short_url": f"{BASE_URL}/{short_code}", "short_code": short_code}
What does this endpoint do?
- @app.post("/shorten") registers a POST route at /shorten. POST is the standard HTTP method for creating new resources.
- request: URLRequest tells FastAPI to parse the JSON body and validate it against your Pydantic schema.
- The function encodes the current counter value into a Base62 string, saves the mapping in url_store, then bumps the counter so the next URL gets a different code.
- The response includes both the full short_url (with the domain) and the raw short_code for flexibility.
- Save main.py.
- Switch to your browser and refresh http://127.0.0.1:8000/docs.
- You should see a new green POST /shorten section in the Swagger UI alongside the existing GET endpoint.
Don't see the POST /shorten endpoint?
- Check your terminal for errors. A typo in the decorator or function signature will prevent the server from reloading.
- Make sure the @app.post("/shorten") decorator is directly above def shorten_url with no blank line between them.
- Try a hard refresh in your browser with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
Help me figure out why my POST endpoint isn't appearing
Test the shorten endpoint in Swagger UI
Your endpoint is live. Time to send it a real URL and see what comes back.
- In the Swagger UI, click the green POST /shorten row to expand it.
- Click Try it out on the right side.
- Replace the contents of the request body with:
{
"url": "https://www.amazon.com/dp/B0DFDJQH6G"
}
Before you hit Execute, what do you think the response will contain? What short code do you expect for the very first URL?
- Click Execute.
You should see a 200 response with a JSON body like this:
{
"short_url": "http://localhost:8000/1",
"short_code": "1"
}
The counter started at 1, and Base62-encoding 1 produces the character 1. Your first URL got the shortest possible code.
- Submit a second URL (any valid URL) and confirm the short code increments to 2.
Why Base62 instead of random strings?
Random strings risk collisions. You would need to check the database on every insert to confirm the code is unique. Base62 encoding of sequential IDs is bijective: each ID maps to exactly one code, and each code maps back to exactly one ID.
This is a classic system design trade-off. In an interview, you can explain that counter-based encoding eliminates collision detection at the cost of predictable codes.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and your server reloaded without errors.
ⓧ I'd like to double check the full code
Scroll up and confirm your main.py contains these sections in order:
- Imports: FastAPI and BaseModel.
- App initialization: app = FastAPI(title="URL Shortener").
- Constants: BASE62_CHARS and BASE_URL.
- Storage: url_store dictionary and counter variable.
- The encode_base62 function.
- The URLRequest class inheriting from BaseModel.
- The existing GET / endpoint.
- The new POST /shorten endpoint.
You have a working POST endpoint that shortens URLs. Next up, you will build the redirect endpoint so visiting a short code actually sends you to the original destination.
Add the Redirect Endpoint
In the previous step, you built the POST endpoint that turns long URLs into short codes. That is only half the story.
A short code is useless unless visiting it takes you to the original URL. This step completes the core loop: shorten a URL, then visit the short version and land at the destination.
In this step, get ready to:
- Create a GET endpoint that resolves short codes into redirects.
- Handle the case where a short code does not exist.
- Test the full shorten-then-redirect loop end to end.
Create the GET /{short_code} endpoint
When someone visits a shortened URL, your server needs to look up the original URL and send the visitor there. In HTTP, this is called a redirect.
FastAPI provides a RedirectResponse class that sends the browser to a different URL. You also need HTTPException to return proper error codes when something goes wrong.
- In main.py, find the import line at the very top of the file:
from fastapi import FastAPI
- Replace it with these two lines that add HTTPException and RedirectResponse:
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
What are these new imports?
- HTTPException lets you return HTTP error codes (like 404) with a custom message.
- RedirectResponse tells the browser to navigate to a different URL instead of returning data.
- Scroll to the bottom of main.py, below the shorten_url function.
- Add the redirect endpoint by pasting this code:
@app.get("/{short_code}")
def redirect_url(short_code: str):
# Look up the short code in our store
return RedirectResponse(url=url_store[short_code], status_code=307)
What does this endpoint do?
When someone visits http://localhost:8000/1, FastAPI captures 1 as the short_code parameter.
The function looks up that code in url_store and returns a RedirectResponse with status code 307. The browser follows the redirect and lands on the original URL.
Why 307 instead of 301?
A 301 (Moved Permanently) tells the browser to cache the redirect. Future visits skip your server entirely, so you lose the ability to track clicks.
A 307 (Temporary Redirect) forces the browser to check with your server every time. This keeps you in the loop for analytics.
- Save main.py.
- Switch to the Swagger UI at http://127.0.0.1:8000/docs and POST a URL to /shorten (for example, https://www.amazon.com/dp/B0DFDJQH6G).
- Copy the short_url value from the response (it looks like http://localhost:8000/1).
- Paste that URL into a new browser tab and press Enter.
Your browser redirects to the original Amazon page. The core loop works.
Not seeing a redirect?
- Make sure you saved main.py after adding the new endpoint. The dev server auto-reloads on save.
- Check that you are pasting the full short_url value (including http://localhost:8000/) into the browser address bar.
- If you restarted the server between shortening and visiting, the in-memory store was cleared. Shorten a fresh URL and try again.
help me debug why my redirect endpoint is not working
Handle missing short codes
Right now, visiting a short code that does not exist in url_store would crash your server with a Python KeyError. A production API should return a clear 404 error instead.
- In main.py, find the redirect_url function you just added. It currently looks like this:
@app.get("/{short_code}")
def redirect_url(short_code: str):
# Look up the short code in our store
return RedirectResponse(url=url_store[short_code], status_code=307)
- Replace the entire function body with this version that checks for missing codes first:
@app.get("/{short_code}")
def redirect_url(short_code: str):
# Return 404 if the short code does not exist
if short_code not in url_store:
raise HTTPException(status_code=404, detail="Short URL not found")
# Redirect to the original URL
return RedirectResponse(url=url_store[short_code], status_code=307)
What does the 404 check do?
Before attempting the redirect, the function checks whether short_code exists in the dictionary. If it does not, HTTPException raises a 404 response with the message Short URL not found.
This gives API consumers a clear JSON error instead of an unhandled server crash.
- Save main.py.
Test the full loop
Before you run the final test, predict what you think will happen when you visit a short code that was never created.
- Open a new browser tab and visit http://localhost:8000/zzz.
You see a JSON response with {"detail":"Short URL not found"} and a 404 status. Your error handling works.
- Now test the happy path. Return to the Swagger UI at http://127.0.0.1:8000/docs.
- POST a URL like https://www.amazon.com/dp/B0DFDJQH6G to /shorten.
- Copy the short_url from the JSON response.
- Paste it into a new browser tab and press Enter.
Your browser lands on the original Amazon page. The full shorten-then-redirect loop is working end to end.
Seeing a KeyError instead of a 404?
- Make sure you replaced the entire redirect_url function body with the version that includes the if short_code not in url_store check.
- Confirm you saved main.py after the edit. The auto-reload log line in your terminal confirms the server restarted.
help me fix my redirect endpoint that crashes with a KeyError
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and confirmed both the redirect and the 404 error work.
ⓧ I'd like to double check the full code
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
app = FastAPI(title="URL Shortener")
BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
BASE_URL = "http://localhost:8000"
url_store = {}
counter = 1
def encode_base62(num: int) -> str:
if num == 0:
return BASE62_CHARS[0]
result = []
while num > 0:
num, remainder = divmod(num, 62)
result.append(BASE62_CHARS[remainder])
return "".join(reversed(result))
class URLRequest(BaseModel):
url: str
@app.get("/")
def root():
return {"message": "Welcome to the URL Shortener API. Visit /docs for interactive documentation."}
@app.post("/shorten")
def shorten_url(request: URLRequest):
global counter
short_code = encode_base62(counter)
url_store[short_code] = request.url
counter += 1
return {"short_url": f"{BASE_URL}/{short_code}", "short_code": short_code}
@app.get("/{short_code}")
def redirect_url(short_code: str):
if short_code not in url_store:
raise HTTPException(status_code=404, detail="Short URL not found")
return RedirectResponse(url=url_store[short_code], status_code=307)
Your URL shortener now has a complete core loop. Next up, you will make your shortened URLs survive server restarts by adding database persistence.
Add Database Persistence
Your URL shortener works end-to-end. You can shorten a URL and watch the redirect happen in your browser. But there is a fatal flaw hiding in plain sight.
Every URL you have shortened lives in a Python dictionary. The moment your server process stops, that dictionary is erased from memory. In this step, you will replace the in-memory dictionary with SQLite, a lightweight database built into Python's standard library.
In this step, get ready to:
- Set up a SQLite database to store your URL mappings.
- Update the POST /shorten endpoint to write to the database.
- Update the GET /{short_code} endpoint to read from the database.
See the problem firsthand
Before you fix anything, you need to see the problem with your own eyes. What do you think happens to the URLs you shortened in the previous step if you restart the server?
- Switch to the terminal where your server is running.
- Stop the server by pressing Ctrl+C.
- Restart the server by running:
fastapi dev main.py
- Open your browser and visit http://127.0.0.1:8000/1 (or any short code you created earlier).
You get a 404 error. Every URL you shortened is gone. The in-memory dictionary was wiped clean the instant the Python process stopped. This is the gap that SQLite fixes.
Set up the SQLite database
Python ships with a built-in sqlite3 module, so you do not need to install anything. You just need two helper functions: one to open a connection and one to create the table on startup.
- In main.py, find the very first line:
from fastapi import FastAPI, HTTPException
- Add this import above it, so it becomes the new first line of the file:
import sqlite3
- Now scroll down to the end of the encode_base62 function. Below it, add the following two functions and startup call:
def get_db():
conn = sqlite3.connect("shortener.db")
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute(
"""
CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_url TEXT NOT NULL,
short_code TEXT UNIQUE NOT NULL,
clicks INTEGER DEFAULT 0
)
"""
)
conn.commit()
conn.close()
init_db()
What does this code do?
These two functions set up your database layer:
- get_db() opens a connection to a file called shortener.db in your project folder. The row_factory = sqlite3.Row line lets you access columns by name (like row["original_url"]) instead of by index number.
- init_db() creates the urls table if it does not already exist. The table has an auto-incrementing id, columns for the original URL and short code, and a clicks counter you will use later.
- Calling init_db() at module level means the table is created the moment your server starts.
- Save main.py.
- Your dev server will auto-reload. Confirm the database file was created by running this in a new terminal:
ls shortener.db
You should see shortener.db listed. SQLite created the file when init_db() ran on startup.
Don't see shortener.db?
- Make sure you saved main.py after adding the new code. The server only reloads on save.
- Check your terminal for error messages. A typo in the SQL (like a missing comma between column definitions) will prevent the table from being created.
- Make sure you are running ls from inside the url-shortener folder, not your Desktop.
Why isn't my shortener.db file appearing after saving?
Update the POST /shorten endpoint to use the database
With the database ready, you can swap out the in-memory dictionary. The new approach inserts a row, grabs the auto-generated ID, encodes it to Base62, then updates the row with the final short code.
- In main.py, find these two lines (between BASE_URL and the encode_base62 function):
url_store = {}
counter = 1
- Delete both lines entirely. The database replaces them.
- Now find your existing shorten_url function:
@app.post("/shorten")
def shorten_url(request: URLRequest):
global counter
short_code = encode_base62(counter)
url_store[short_code] = request.url
counter += 1
return {"short_url": f"{BASE_URL}/{short_code}", "short_code": short_code}
- Replace the entire function with this database-backed version:
@app.post("/shorten")
def shorten_url(request: URLRequest):
original_url = request.url
# Insert a row with an empty short_code placeholder
conn = get_db()
cursor = conn.execute(
"INSERT INTO urls (original_url, short_code) VALUES (?, ?)",
(original_url, ""),
)
# Grab the auto-generated ID and encode it
row_id = cursor.lastrowid
short_code = encode_base62(row_id)
# Update the row with the real short code
conn.execute(
"UPDATE urls SET short_code = ? WHERE id = ?",
(short_code, row_id),
)
conn.commit()
conn.close()
return {"short_url": f"{BASE_URL}/{short_code}", "short_code": short_code}
Why insert then update?
The short code is derived from the database row's auto-incremented ID. You cannot know the ID until after the row is inserted. So the function inserts first with an empty placeholder, reads back the ID, encodes it, then updates the row with the final short code.
This guarantees every short code is unique without needing collision detection. Each database ID maps to exactly one Base62 string.
- Save main.py. The server auto-reloads.
- Open http://127.0.0.1:8000/docs in your browser.
- Expand the POST /shorten endpoint, click Try it out, enter a URL like https://www.amazon.com/dp/B0DFDJQH6G, and click Execute.
You should see a response with a short_url and short_code just like before. The difference is invisible from the outside. Your URL is now stored in the database instead of a dictionary.
Getting an Internal Server Error?
- Check your terminal for the traceback. If you see NameError: name 'url_store' is not defined, you still have old code referencing the dictionary somewhere. Make sure you replaced the entire shorten_url function.
- If you see NameError: name 'get_db' is not defined, make sure your get_db() function is defined above the endpoint (after encode_base62).
Help me debug my Internal Server Error after updating the shorten endpoint.
Update the GET /{short_code} endpoint to query the database
The redirect endpoint still references the old dictionary. You need to swap it to query the database and look up the original URL by short code.
- In main.py, find your existing redirect_url function:
@app.get("/{short_code}")
def redirect_url(short_code: str):
if short_code not in url_store:
raise HTTPException(status_code=404, detail="Short URL not found")
return RedirectResponse(url=url_store[short_code], status_code=307)
- Replace the entire function with this database-backed version:
@app.get("/{short_code}")
def redirect_url(short_code: str):
conn = get_db()
row = conn.execute(
"SELECT original_url FROM urls WHERE short_code = ?",
(short_code,),
).fetchone()
if row is None:
conn.close()
raise HTTPException(status_code=404, detail="Short URL not found")
# Track each visit by incrementing the click counter
conn.execute(
"UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?",
(short_code,),
)
conn.commit()
conn.close()
return RedirectResponse(url=row["original_url"], status_code=307)
What does this code do?
- fetchone() returns the first matching row, or None if no row matches. This replaces the dictionary in check.
- The clicks = clicks + 1 update increments a counter every time someone visits a short URL. You will use this data in the Secret Mission later.
- row["original_url"] works because you set conn.row_factory = sqlite3.Row in get_db(). Without it, you would need row[0] instead.
- Save main.py.
Now for the real test. Before you run this, predict: if you stop the server and restart it, will the URL you shortened a moment ago still work?
- First, shorten a fresh URL in Swagger UI at http://127.0.0.1:8000/docs (use any URL you like). Note the short_code in the response.
- Stop the server with Ctrl+C.
- Restart the server by running:
fastapi dev main.py
- Open a new browser tab and visit http://127.0.0.1:8000/ followed by the short code you noted (for example, http://127.0.0.1:8000/1).
Your browser redirects to the original URL. The short code survived the restart because it lives in shortener.db now, not in memory.
Still getting a 404 after restarting?
- Make sure you shortened a new URL AFTER updating the endpoint to use the database. URLs from previous steps were stored in the old dictionary and are permanently lost.
- Check that shortener.db still exists in your project folder. If you accidentally deleted it, shorten a fresh URL and try again.
- Confirm the short code in your browser URL matches exactly what the API returned (codes are case-sensitive).
My short URL still returns 404 after restarting the server.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and your redirect is working after a restart.
ⓧ I'd like to double check the full code
Your main.py should contain these sections in order. Scroll up and confirm each one is present:
- import sqlite3 as the very first line.
- The from fastapi, from fastapi.responses, and from pydantic imports.
- app, BASE62_CHARS, and BASE_URL definitions (no url_store or counter).
- The encode_base62 function.
- The get_db() and init_db() functions, followed by the init_db() call.
- The URLRequest class.
- The root() endpoint.
- The updated shorten_url() function using get_db(), INSERT, then UPDATE.
- The updated redirect_url() function querying the database and incrementing clicks.
Your URLs now survive any number of restarts. Next up, you will add input validation to reject garbage data before it reaches the database.
Add Input Validation
Your URL shortener works end to end, but it has a serious gap. Right now, someone could POST not-a-url or even an empty string and your API would happily store it in the database.
Proper input validation is what separates a toy project from production-quality code. In this step, you'll add type-level URL validation with Pydantic, length limits, and self-reference protection so only legitimate URLs make it into your database.
In this step, get ready to:
- Upgrade the URLRequest model to reject non-URL inputs automatically.
- Add length and self-reference guards to the POST endpoint.
- Test edge cases to confirm invalid inputs are rejected.
Validate URL format
Pydantic ships with a built-in HttpUrl type that automatically rejects any string that is not a valid HTTP or HTTPS URL. By swapping your model's plain str field for HttpUrl, FastAPI handles format validation for you with zero custom code.
- In main.py, find this import line near the top of the file:
from pydantic import BaseModel
- Add HttpUrl to the import so the line looks like this:
from pydantic import BaseModel, HttpUrl
- Now scroll down to the URLRequest class and find this line:
url: str
- Replace str with HttpUrl so it looks like this:
url: HttpUrl
- Finally, scroll to the shorten_url function and find this line:
original_url = request.url
- Replace it with this line, wrapping request.url in str():
original_url = str(request.url)
Why wrap in str()?
When you use HttpUrl, Pydantic parses the input into a URL object instead of a plain string. Your database expects a plain string, so str(request.url) converts the validated URL object back into a storable string.
- Save main.py.
- Switch to the Swagger UI at http://127.0.0.1:8000/docs and try the POST /shorten endpoint with this body:
{"url": "not-a-url"}
You should see a 422 response with a validation error explaining the URL is invalid.
Still seeing a 200 response?
Make sure you changed url: str to url: HttpUrl in the URLRequest class, not somewhere else. The dev server auto-reloads on save, so check your terminal for any import errors.
Help me debug this
Add length and self-reference checks
HttpUrl catches malformed URLs, but you also need business logic validation. A URL that is technically valid but 10,000 characters long could bloat your database. A URL that points back to your own service would create an infinite redirect loop.
- In main.py, find the line original_url = str(request.url) inside shorten_url.
- Add these validation checks directly below that line, before the conn = get_db() call:
if len(original_url) > 2048:
raise HTTPException(status_code=422, detail="URL exceeds maximum length of 2048 characters")
if "localhost:8000" in original_url:
raise HTTPException(status_code=422, detail="Cannot shorten URLs pointing to this service")
What do these checks do?
The length check rejects URLs over 2048 characters. This is a common industry limit that matches browser address bar constraints.
The self-reference check prevents redirect loops. If someone shortened http://localhost:8000/abc, visiting the short link would redirect to another short link, creating an infinite loop.
- Save main.py.
- In Swagger UI, try POST /shorten with this body:
{"url": "http://localhost:8000/abc"}
You should see a 422 response with the detail message about self-referential URLs.
Getting a 500 error instead of 422?
Check that your if statements are inside the shorten_url function (indented with 4 spaces) and placed after original_url = str(request.url) but before conn = get_db().
Help me debug this error
Test edge cases
Your validation is in place. Before you move on, confirm it handles all the edge cases correctly while still allowing valid URLs through.
- Before you run these tests, predict which ones you expect to fail and which should succeed.
- In Swagger UI, try POST /shorten with each of these bodies one at a time:
{"url": "not-a-url"}
{"url": ""}
{"url": "http://localhost:8000/abc"}
- All three should return 422 errors with descriptive messages.
- Now confirm the happy path still works by posting a valid URL:
{"url": "https://www.amazon.com/dp/B0DFDJQH6G"}
You should receive a 200 response with a short_url and short_code just like before. Your validation only blocks bad input. Legitimate URLs pass through unchanged.
Why 422 and not 400?
FastAPI uses HTTP 422 (Unprocessable Entity) for validation errors by convention. It means the server understood the request format but the content failed validation rules. This is the same status code Pydantic's built-in HttpUrl validation returns, so your custom checks match that pattern.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and your dev server is still running.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these changes is present in your main.py, in order:
- The import line reads from pydantic import BaseModel, HttpUrl.
- The URLRequest class has url: HttpUrl (not url: str).
- The shorten_url function starts with original_url = str(request.url).
- The length check (if len(original_url) > 2048) comes next.
- The self-reference check (if "localhost:8000" in original_url) follows.
- Then the conn = get_db() line and the rest of the database logic.
Your API now rejects garbage input at the door. Any request that is not a valid HTTP URL, is too long, or points back at your own service gets bounced with a clear error message. That is the kind of defensive coding that Amazon expects in production systems.
Secret mission
Add Click Tracking Analytics
Real URL shorteners track how often each link is clicked. Your API already increments a click counter on every redirect. Can you build an analytics endpoint that exposes those numbers as JSON?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Python and SQLite, 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 folder on your Desktop (contains main.py, the virtual environment, and the database).
- The shortener.db SQLite database file storing your shortened URLs.
- The .venv Python virtual environment with FastAPI installed.
Keep everything running
No action needed. Choose this if you plan to keep iterating on the URL shortener or want to revisit it soon.
- Your shortener.db database file persists in your project folder with all your shortened URLs intact.
- Restart the server anytime by activating your virtual environment and running fastapi dev main.py.
- The dev server stops automatically when you close your terminal or press Ctrl+C.
Pause - I'll come back to this later
Stop the running server to free up your terminal. Your project files and database remain on disk, ready for next time.
- Press Ctrl+C in your terminal to stop the FastAPI dev server.
- Deactivate your virtual environment by running:
deactivate
When you come back, navigate to your project folder, reactivate with source .venv/bin/activate, and run fastapi dev main.py.
Delete - I don't want to use this again
Remove all project files, the database, and the virtual environment. This cannot be undone.
- Press Ctrl+C in your terminal to stop the dev server if it is still running.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the entire project folder by running:
macOS
cd ~/Desktop
rm -rf url-shortener
Windows
cd ~\Desktop
rmdir /s /q url-shortener
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see url-shortener listed.
This removes main.py, shortener.db, and the .venv virtual environment in one step. Nothing else was installed outside this folder.
Nice Work!
Nice Work!
You just built a fully functional URL shortener API from scratch using FastAPI and SQLite. This is a classic system design project that demonstrates end-to-end backend ownership, from encoding algorithms to database persistence to error handling.
You've learned how to:
- Build a REST API from scratch with FastAPI, using Base62 encoding to generate collision-free short codes from database IDs.
- Design a SQLite persistence layer and 307 redirect logic so shortened URLs survive server restarts and redirect visitors correctly.
- Implement input validation using Pydantic to reject malformed URLs and prevent self-referential redirect loops.
- Built click tracking analytics (Secret Mission) to count how many times each short URL is visited and expose the data through a stats endpoint.
Ready to quiz yourself?