URL Shortener in 200 Lines of Python

You can build a fully functional, production-ready URL shortener in under 200 lines of Python. The ingredients are FastAPI for the HTTP layer, SQLite for persistence, and base62 encoding to convert auto-incremented database IDs into short codes. Add a redirect endpoint that issues 301 or 302 responses, a click counter, and rate limiting through SlowAPI middleware, and you have a service that handles millions of URLs on a single server.
The same architecture powers lightweight self-hosted shorteners in production. By the end, you will have a single main.py and a small database.py module that you can deploy behind Caddy
or Nginx as a Docker container or a systemd service.
Architecture and Tech Stack Choices
Before writing any code, the technology choices deserve justification. Picking the wrong stack for a project this small either over-engineers it into a distributed system or under-builds it into something that falls over at a few hundred concurrent users.
FastAPI (currently at v0.135.x as of March 2026) gives you async request handling, automatic OpenAPI documentation, and Pydantic v2 validation
in a single dependency. Flask would work but adds unnecessary overhead for this scope - no template rendering, no session management, no ORM needed. Django would be overkill. FastAPI also generates interactive API docs at /docs out of the box, which means your URL shortener gets a free testing UI.
SQLite is the right database because a URL shortener’s write pattern fits its single-writer model perfectly. You get one INSERT per URL creation and one UPDATE per redirect for the click counter. SQLite handles this without any external process, connection pooling headaches, or configuration files. You will not need PostgreSQL until you exceed roughly 100,000 writes per second, which is far beyond what most self-hosted shorteners ever see. Enable WAL mode
with PRAGMA journal_mode=WAL and you get concurrent reads during writes with no additional infrastructure.
Base62 encoding converts integer IDs to short strings using the character set 0-9a-zA-Z. That is 62 characters, which means a 6-character code gives you 62^6 = 56.8 billion possible short URLs. ID 1 becomes 1, ID 62 becomes 10, ID 238,328 becomes zZz. Unlike base64, base62 avoids the + and / characters that require URL encoding, and skips - and _ that look ambiguous in underlined hyperlinks.
No external cache layer is needed. SQLite’s built-in page cache handles read performance for millions of URLs. When you benchmark a FastAPI + SQLite shortener, you can expect around 5,000-8,000 redirects per second on a single core with Uvicorn , which is more than adequate for any personal or small-team deployment.
The line budget breaks down roughly as follows:
| Component | Lines | Purpose |
|---|---|---|
| Database schema and helpers | ~30 | Table creation, connection, queries |
| Base62 encoding/decoding | ~20 | Integer-to-string conversion |
| FastAPI routes | ~80 | Create, redirect, stats endpoints |
| Rate limiting and config | ~40 | SlowAPI, CORS, environment variables |
| Imports and boilerplate | ~30 | Dependencies, app initialization |
| Total | ~200 |
Database Schema and Base62 Encoding
The shortener’s core is a bijective mapping between integer IDs and short strings. Every new URL gets an auto-incremented ID, and that ID encodes into a short code.
Here is the SQLite schema:
CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_url TEXT NOT NULL,
short_code TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
clicks INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code);The index on short_code is critical. Without it, every redirect triggers a full table scan. With the index, lookups are O(log n) regardless of how many URLs you store.
The base62 encoding function divides the integer by 62 repeatedly, mapping each remainder to a character:
BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
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))
def decode_base62(s: str) -> int:
num = 0
for char in s:
num = num * 62 + BASE62_CHARS.index(char)
return numDecoding reverses the process with positional multiplication. You can also use the pybase62 package from PyPI if you prefer not to write your own, but it is only about 15 lines either way.
Custom alias support is a nice addition. Let users optionally specify their own short code like my-link instead of the auto-generated base62 code. The implementation is simple: check the short_code column for uniqueness before inserting, and fall back to base62 encoding if no custom alias is provided.
For database initialization, use FastAPI’s lifespan event to create the table on startup and close the connection on shutdown:
import sqlite3
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
db = sqlite3.connect("shortener.db", check_same_thread=False)
db.execute("PRAGMA journal_mode=WAL")
db.execute(CREATE_TABLE_SQL)
db.execute(CREATE_INDEX_SQL)
app.state.db = db
yield
db.close()Setting check_same_thread=False is required because FastAPI’s async handlers may run on different threads than the one that created the connection. WAL mode makes this safe for concurrent reads.
FastAPI Routes: Create, Redirect, and Stats
Three endpoints make the entire URL shortener functional. Here is the creation endpoint:
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, HttpUrl
class ShortenRequest(BaseModel):
url: HttpUrl
custom_alias: str | None = None
@app.post("/shorten")
async def create_short_url(request: Request, body: ShortenRequest):
db = request.app.state.db
original_url = str(body.url)
# Prevent self-referential redirects
if BASE_URL in original_url:
raise HTTPException(status_code=422, detail="Cannot shorten URLs pointing to this service")
# Reject excessively long URLs
if len(original_url) > 2048:
raise HTTPException(status_code=422, detail="URL exceeds maximum length of 2048 characters")
if body.custom_alias:
# Check uniqueness
existing = db.execute(
"SELECT id FROM urls WHERE short_code = ?", (body.custom_alias,)
).fetchone()
if existing:
raise HTTPException(status_code=409, detail="Custom alias already taken")
short_code = body.custom_alias
db.execute(
"INSERT INTO urls (original_url, short_code) VALUES (?, ?)",
(original_url, short_code)
)
else:
cursor = db.execute(
"INSERT INTO urls (original_url, short_code) VALUES (?, ?)",
(original_url, "") # Placeholder
)
short_code = encode_base62(cursor.lastrowid)
db.execute(
"UPDATE urls SET short_code = ? WHERE id = ?",
(short_code, cursor.lastrowid)
)
db.commit()
return {"short_url": f"{BASE_URL}/{short_code}", "short_code": short_code}Pydantic’s HttpUrl type validates that incoming URLs have a proper scheme (http or https), rejecting data: and javascript: URIs automatically. The self-referential check prevents infinite redirect loops where someone shortens a URL that points back to the shortener itself.
The redirect endpoint handles the actual URL resolution:
@app.get("/{short_code}")
async def redirect_to_url(request: Request, short_code: str):
db = request.app.state.db
row = db.execute(
"SELECT original_url FROM urls WHERE short_code = ?", (short_code,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Short URL not found")
db.execute(
"UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?", (short_code,)
)
db.commit()
return RedirectResponse(url=row[0], status_code=301)The choice between 301 and 302 status codes matters. A 301 (Moved Permanently) tells browsers and search engines to cache the redirect, which is great for SEO link juice passing but means you cannot track every individual click since browsers skip the shortener after the first visit. A 307 (Temporary Redirect) forces the browser to hit your server every time, which gives you accurate click counts but adds latency. Pick 301 if you care about performance and SEO, 307 if you care about analytics accuracy.
The stats endpoint provides basic analytics without building a separate dashboard:
@app.get("/{short_code}/stats")
async def get_url_stats(request: Request, short_code: str):
db = request.app.state.db
row = db.execute(
"SELECT original_url, short_code, created_at, clicks FROM urls WHERE short_code = ?",
(short_code,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Short URL not found")
return {
"original_url": row[0],
"short_code": row[1],
"created_at": row[2],
"clicks": row[3]
}Error handling is consistent across all endpoints: 404 with a JSON body for unknown short codes, 422 for invalid URLs, and 429 for rate-limited requests.

Rate Limiting and Production Hardening
A public URL shortener without rate limiting is a spam relay. Someone will find it and use it to generate thousands of phishing links in seconds.
Install SlowAPI and wire it up:
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/shorten")
@limiter.limit("10/minute")
async def create_short_url(request: Request, body: ShortenRequest):
# ... same as aboveTen creations per minute per IP address is a reasonable default. The redirect endpoint does not need rate limiting since it should be as fast as possible. SlowAPI wraps the limits
library and returns a 429 response with a Retry-After header automatically.
URL validation should go beyond format checking. Block known malicious URI schemes by rejecting anything that is not http or https. Consider maintaining a blocklist of known malware domains, or integrating with the Google Safe Browsing API
for real-time phishing detection. For a lightweight approach, at minimum reject data:, javascript:, and file: schemes.
For richer analytics, add a separate click log table instead of just a counter:
CREATE TABLE IF NOT EXISTS click_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_id INTEGER NOT NULL,
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_agent TEXT,
referer TEXT,
FOREIGN KEY (url_id) REFERENCES urls(id)
);This lets you answer questions like “which referrers drive the most traffic” and “what percentage of clicks come from mobile devices” without much extra code.
CORS configuration prevents unauthorized frontends from calling your API:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)Use pydantic-settings to load configuration from environment variables:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
base_url: str = "http://localhost:8000"
database_path: str = "shortener.db"
rate_limit: str = "10/minute"
max_url_length: int = 2048
settings = Settings()For deployment, run the app with Uvicorn behind a reverse proxy:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4Put Caddy in front for automatic HTTPS . The whole stack uses under 50 MB of RAM. A minimal Dockerfile is just a few lines:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]Before shipping this image, tighten it with the practices in our Docker image hardening checklist - switch to a non-root user, drop unused Linux capabilities, and pin dependency hashes so the attack surface stays small.
Security Hardening Against Open Redirects
URL shorteners are inherently open redirect services, which makes them attractive to phishing campaigns. An attacker creates a short link pointing to a credential-harvesting page, and the shortened URL looks trustworthy because it comes from your domain.
Mitigation strategies include:
- Blocklist known malicious domains using community-maintained lists or the Google Safe Browsing API
- Add an interstitial page for links flagged as suspicious, showing the destination URL and asking the user to confirm before redirecting
- Log all URL creations with the creator’s IP address for abuse investigations
- Implement URL expiration so old phishing links stop working automatically
- Rate limit aggressively on the creation endpoint to slow down bulk abuse
Extending the Shortener Without Bloating It
Once the core works, several high-value features fit in another 50-100 lines without turning a lean project into a framework.
URL Expiration
Add an expires_at TIMESTAMP column to the urls table. During redirect, check if the current time exceeds expires_at and return a 410 Gone response for expired links. Clean up old entries with a scheduled query:
DELETE FROM urls WHERE expires_at IS NOT NULL AND expires_at < datetime('now');Run this as a background task using FastAPI’s BackgroundTasks or a simple cron job.
QR Code Generation
A GET /{short_code}/qr endpoint using the qrcode
library takes about 10 lines and returns a PNG image of the short URL. This gets a lot of use for print materials, business cards, and conference posters:
import qrcode
from io import BytesIO
from fastapi.responses import StreamingResponse
@app.get("/{short_code}/qr")
async def generate_qr(short_code: str):
img = qrcode.make(f"{settings.base_url}/{short_code}")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
return StreamingResponse(buffer, media_type="image/png")API Key Authentication
For multi-tenant usage, add an X-API-Key header check using FastAPI’s Depends() system. Store keys in an environment variable for simplicity or in a SQLite table for multiple users:
from fastapi import Depends, Security
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key")
async def verify_api_key(api_key: str = Security(api_key_header)):
if api_key not in settings.api_keys:
raise HTTPException(status_code=403, detail="Invalid API key")
return api_keyHealth Monitoring
A /health endpoint that checks SQLite connectivity and returns basic metrics is useful for load balancer health checks:
@app.get("/health")
async def health_check(request: Request):
db = request.app.state.db
count = db.execute("SELECT COUNT(*) FROM urls").fetchone()[0]
return {"status": "healthy", "total_urls": count}SQLite Backup Strategy
For production deployments, you need a backup plan. SQLite’s built-in .backup command creates a consistent snapshot while the database is in use. For continuous replication, Litestream
streams WAL changes to S3-compatible storage in near real-time - every write reaches cloud storage within seconds, and restoring is a single command. Litestream adds no measurable overhead and runs as a sidecar process alongside your application.
Comparison with Existing Solutions
How does a 200-line shortener compare to established self-hosted alternatives? Here is what you gain and what you give up:
| Feature | Your 200-Line Shortener | Shlink | YOURLS | Kutt |
|---|---|---|---|---|
| Language | Python | PHP | PHP | TypeScript |
| Database | SQLite | PostgreSQL/MySQL | MySQL | PostgreSQL |
| Setup complexity | pip install + run | Docker + DB config | LAMP stack | Docker + PostgreSQL |
| API-first | Yes | Yes | Plugin-based | Yes |
| Analytics | Basic clicks | Geolocation, referrers | Click stats | Click stats, browser |
| RAM usage | ~50 MB | ~200 MB | ~100 MB | ~300 MB |
| Lines of code | ~200 | Thousands | Thousands | Thousands |
| Custom domains | Manual DNS | Built-in | Plugin | Built-in |
The 200-line version wins on simplicity, resource usage, and customizability. You understand every line, you can modify anything, and it runs on the smallest VPS available. The existing solutions win on features you might never need - geolocation analytics, multi-domain management, and admin dashboards. If you want a shortener that does exactly what you need and nothing more, building from scratch makes sense.
The Full Picture
The full project consists of main.py (routes, middleware, configuration) and database.py (schema, connection handling, base62 functions). Deploy it with uvicorn behind Caddy, back up your SQLite database with Litestream, and you have a production-ready URL shortener that costs you nothing beyond the server it runs on. The entire codebase fits on a single screen, which means debugging is reading, not searching.
Botmonster Tech