Build a URL Shortener in Python from Scratch
Introduction
A URL shortener takes a long URL like https://example.com/blog/articles/how-to-do-something-very-specific and returns a short code like https://short.ly/x7k2p. When someone visits the short URL, they are redirected to the original.
Building one from scratch is a great Python project because it touches several real concepts at once: hashing, data storage, web servers, HTTP redirects, and command-line interfaces.
By the end of this tutorial, you will have a working URL shortener with:
- Short code generation using hashing
- JSON-based persistent storage
- A Flask web server that handles redirects
- Click tracking (how many times a link was visited)
- A command-line interface for managing URLs
All examples are tested on Python 3.12.
How a URL Shortener Works
The core logic is straightforward:
- Accept a long URL as input
- Generate a short code (usually 6-8 characters)
- Store the mapping:
short_code → long_url - When a user visits
yoursite.com/{short_code}, look up the long URL and redirect
The interesting part is generating the short code. There are several approaches:
- Random string — generate a random alphanumeric string
- Hash — hash the URL and take the first N characters
- Counter — encode an incrementing integer in base-62
This tutorial uses hashing because it is deterministic (the same URL always produces the same code) and does not require a database counter.
Step 1: Short Code Generation
import hashlib
import string
ALPHABET = string.ascii_letters + string.digits # a-z A-Z 0-9
def generate_short_code(url: str, length: int = 6) -> str:
"""Generate a short code for a URL using SHA-256 hashing."""
hash_bytes = hashlib.sha256(url.encode()).hexdigest()
# Use the first `length` characters of the hex hash
return hash_bytes[:length]
# Test it
url = "https://example.com/very/long/path/to/something"
code = generate_short_code(url)
print(f"URL: {url}")
print(f"Code: {code}")
Expected output:
URL: https://example.com/very/long/path/to/something
Code: 3b4c2a
The same URL always produces the same code, and different URLs produce different codes (with very rare collisions for 6-character codes).
Step 2: Storage
Store the URL mappings in a JSON file. This keeps the implementation simple — no database required:
import json
import os
from datetime import datetime
STORAGE_FILE = "urls.json"
def load_urls() -> dict:
"""Load URL mappings from the JSON file."""
if not os.path.exists(STORAGE_FILE):
return {}
with open(STORAGE_FILE, "r") as f:
return json.load(f)
def save_urls(urls: dict) -> None:
"""Save URL mappings to the JSON file."""
with open(STORAGE_FILE, "w") as f:
json.dump(urls, f, indent=2)
def add_url(long_url: str, base_url: str = "http://localhost:5000") -> str:
"""Add a URL to storage and return the short URL."""
urls = load_urls()
code = generate_short_code(long_url)
if code not in urls:
urls[code] = {
"long_url": long_url,
"created_at": datetime.now().isoformat(),
"clicks": 0,
}
save_urls(urls)
return f"{base_url}/{code}"
def get_url(code: str) -> dict | None:
"""Look up a short code and return its data."""
urls = load_urls()
return urls.get(code)
def record_click(code: str) -> None:
"""Increment the click counter for a short code."""
urls = load_urls()
if code in urls:
urls[code]["clicks"] += 1
save_urls(urls)
Testing Storage
short_url = add_url("https://example.com/very/long/path")
print(f"Short URL: {short_url}")
data = get_url("3b4c2a")
print(f"Long URL: {data['long_url']}")
print(f"Clicks: {data['clicks']}")
Expected output:
Short URL: http://localhost:5000/3b4c2a
Long URL: https://example.com/very/long/path
Clicks: 0
Step 3: The Flask Web Server
Install Flask:
pip install flask
Create app.py:
from flask import Flask, redirect, abort, jsonify, request
import json
app = Flask(__name__)
@app.route("/<code>")
def redirect_to_url(code: str):
"""Handle short URL redirects."""
data = get_url(code)
if data is None:
abort(404)
record_click(code)
return redirect(data["long_url"], code=301)
@app.route("/api/shorten", methods=["POST"])
def shorten():
"""API endpoint to create a short URL."""
body = request.get_json()
if not body or "url" not in body:
return jsonify({"error": "Missing 'url' field"}), 400
long_url = body["url"]
base_url = request.host_url.rstrip("/")
short_url = add_url(long_url, base_url)
return jsonify({
"short_url": short_url,
"long_url": long_url,
})
@app.route("/api/stats/<code>")
def stats(code: str):
"""Return click statistics for a short code."""
data = get_url(code)
if data is None:
return jsonify({"error": "Code not found"}), 404
return jsonify({
"code": code,
"long_url": data["long_url"],
"clicks": data["clicks"],
"created_at": data["created_at"],
})
@app.route("/api/list")
def list_urls():
"""List all stored URLs."""
urls = load_urls()
return jsonify(urls)
if __name__ == "__main__":
app.run(debug=True)
Running the Server
python app.py
Expected output:
* Running on http://127.0.0.1:5000
* Debug mode: on
Testing the API
In a separate terminal:
# Create a short URL
curl -X POST http://localhost:5000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/very/long/path"}'
Expected output:
{
"long_url": "https://example.com/very/long/path",
"short_url": "http://localhost:5000/3b4c2a"
}
# Check stats
curl http://localhost:5000/api/stats/3b4c2a
Expected output:
{
"clicks": 0,
"code": "3b4c2a",
"created_at": "2026-05-16T14:30:00",
"long_url": "https://example.com/very/long/path"
}
Visit http://localhost:5000/3b4c2a in a browser and you will be redirected to the original URL, and the click counter will increment.
Step 4: Command-Line Interface
Add a simple CLI so you can manage URLs from the terminal:
import sys
def cli():
if len(sys.argv) < 2:
print("Usage: python app.py [shorten|stats|list] [args]")
sys.exit(1)
command = sys.argv[1]
if command == "shorten":
if len(sys.argv) < 3:
print("Usage: python app.py shorten <url>")
sys.exit(1)
long_url = sys.argv[2]
short_url = add_url(long_url)
print(f"Short URL: {short_url}")
elif command == "stats":
if len(sys.argv) < 3:
print("Usage: python app.py stats <code>")
sys.exit(1)
code = sys.argv[2]
data = get_url(code)
if data is None:
print(f"Code '{code}' not found")
else:
print(f"Long URL: {data['long_url']}")
print(f"Clicks: {data['clicks']}")
print(f"Created: {data['created_at']}")
elif command == "list":
urls = load_urls()
if not urls:
print("No URLs stored")
else:
for code, data in urls.items():
print(f"{code} → {data['long_url']} ({data['clicks']} clicks)")
elif command == "serve":
app.run(debug=True)
else:
print(f"Unknown command: {command}")
sys.exit(1)
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] != "serve":
cli()
else:
app.run(debug=True)
CLI Usage
# Shorten a URL
python app.py shorten https://example.com/very/long/path
# Short URL: http://localhost:5000/3b4c2a
# View stats
python app.py stats 3b4c2a
# Long URL: https://example.com/very/long/path
# Clicks: 5
# Created: 2026-05-16T14:30:00
# List all URLs
python app.py list
# 3b4c2a → https://example.com/very/long/path (5 clicks)
# a1b2c3 → https://other.com/page (2 clicks)
Step 5: Handling Collisions
With 6-character hex codes, collisions are unlikely but possible (there are only 16^6 = 16 million possible codes). Add collision handling:
def generate_short_code(url: str, length: int = 6) -> str:
"""Generate a short code, extending length if needed to avoid collisions."""
urls = load_urls()
hash_hex = hashlib.sha256(url.encode()).hexdigest()
for size in range(length, len(hash_hex) + 1):
code = hash_hex[:size]
if code not in urls or urls[code]["long_url"] == url:
return code
# Extremely unlikely to reach here
raise RuntimeError("Could not generate unique code")
If a 6-character code is already taken by a different URL, try 7, then 8, and so on until a unique code is found.
Complete File Structure
url_shortener/
├── app.py ← Flask app + CLI
├── storage.py ← URL storage functions
├── shortener.py ← Code generation
├── urls.json ← Generated automatically
└── requirements.txt
requirements.txt:
flask>=3.0
Going Further
This implementation covers the fundamentals. Real-world URL shorteners add:
- Database storage (SQLite, PostgreSQL) instead of JSON for better performance at scale
- Custom aliases — let users choose their own short codes
- Expiration — URLs that automatically expire after a certain time
- Analytics — track referrers, geographic location, device type
- Rate limiting — prevent abuse of the shorten endpoint
- Authentication — require login to create URLs
Each of these is a natural extension of what is here. The exception handling guide covers how to add robust error handling to the Flask routes.
Wrap-Up
Building a URL shortener from scratch demonstrates how real web applications work: input validation, data storage, HTTP redirects, API design, and command-line tooling. The complete implementation fits in under 150 lines of Python.
The project also connects naturally to other automation workflows. You can combine it with the web scraping guide to automatically shorten links found while scraping, or use the file renaming patterns to batch-process exported URL lists. For questions or future tutorial ideas, get in touch via the Contact page.