pyguides

Build URL Shortener with Flask

What is a URL shortener, and how does Flask build URL routes?

A URL shortener takes a long URL like https://docs.python.org/3/library/secrets.html and returns a short one like http://127.0.0.1:5000/aB3xQ. Clicking the short URL hits a server, which looks up the slug and issues a 302 redirect to the original. That’s the whole product in one sentence: short slug in, long URL out, redirect in between.

You’re going to build URL routes like that one in about 80 lines of Python. The whole app is a Flask service backed by a single SQLite table. No ORM, no frontend framework, no auth. Just two routes, a form, and a small helper for generating slugs.

How do you set up a fresh Flask project?

Make a fresh directory and a virtual environment:

mkdir url_shortener && cd url_shortener
python -m venv .venv
source .venv/bin/activate
pip install "Flask>=3.1,<4"

Pinning Flask to a major version keeps you on a known-good release. The Flask installation guide walks through the same steps with platform-specific notes for Windows and macOS. The flask CLI is installed alongside the package, so flask --version should print the version you just installed.

You’ll use the sqlite3 module from the standard library, so you don’t need any database driver.

Initialize the SQLite database

SQLite ships with Python, so there is nothing to install. Create a single table to store the slug-to-URL mapping, and let the sqlite3 module handle connection management. Three columns are enough: the slug itself, the target URL, and a timestamp so you can prune old links later.

import sqlite3

DB_PATH = "urls.db"


def get_conn() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS urls (
            slug TEXT PRIMARY KEY,
            target TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
        """
    )
    return conn

sqlite3.connect returns a connection, and the CREATE TABLE IF NOT EXISTS makes the call idempotent: running it twice is fine. Opening a new connection per request is the simplest pattern that works. Swap to a connection pool later if you need more throughput.

The ? placeholders in INSERT statements are how you avoid SQL injection. Never build SQL by string-formatting user input.

How do you generate secure short slugs?

The slug has to be unpredictable enough that strangers can’t enumerate your short links. Use secrets.token_urlsafe, not random.choice:

import secrets


def make_slug() -> str:
    return secrets.token_urlsafe(4)  # ~6 URL-safe characters

secrets.token_urlsafe(4) returns 4 random bytes encoded with URL-safe Base64, which works out to about 6 characters. The Python docs recommend the secrets module over random for anything security-related, and short URLs fall in that bucket. See the secrets module guide for the full reasoning.

If you want longer IDs, pass 6 or 8. With 4 bytes (32 bits of entropy) you get around 4.3 billion possible slugs before collision probability starts to bite. That’s plenty for a personal project.

How do you build URL shorten routes with Flask?

The shorten route takes a URL from a form, validates it, generates a slug, and stores the mapping. Here’s the full thing:

from datetime import datetime, timezone
from urllib.parse import urlparse

from flask import Flask, abort, redirect, render_template_string, request

app = Flask(__name__)


def is_valid_url(url: str) -> bool:
    try:
        parts = urlparse(url)
    except ValueError:
        return False
    return parts.scheme in {"http", "https"} and bool(parts.netloc)


@app.route("/shorten", methods=["POST"])
def shorten():
    url = (request.form.get("url") or "").strip()
    if not is_valid_url(url):
        return render_template_string(INDEX_HTML, error="Enter a valid http(s) URL."), 400

    conn = get_conn()
    try:
        for _ in range(5):
            slug = make_slug()
            try:
                conn.execute(
                    "INSERT INTO urls(slug, target, created_at) VALUES (?, ?, ?)",
                    (slug, url, datetime.now(timezone.utc).isoformat()),
                )
                conn.commit()
                break
            except sqlite3.IntegrityError:
                conn.rollback()
                continue
        else:
            return render_template_string(INDEX_HTML, error="Could not allocate a slug; try again."), 500

        short_url = f"{request.host_url}{slug}"
        return render_template_string(INDEX_HTML, short_url=short_url)
    finally:
        conn.close()

Three things worth pointing out:

  1. Validation is structural, not fetch-based. urlparse only checks that the URL has a scheme and a host. A “real” shortener that fetches targets also needs SSRF protection, because a user could submit http://localhost:6379/ to probe internal services on the same network. That’s out of scope for the beginner build.
  2. Collision retry is a for/else. A random slug has a small chance of colliding with an existing one. If that happens, the INSERT raises sqlite3.IntegrityError and the loop tries again. Five attempts is more than enough; if all five collide, the else branch returns a 500.
  3. request.host_url ends with a trailing slash. That’s why the short URL is f"{request.host_url}{slug}", not f"{request.host_url}/{slug}".

Build the redirect route

The redirect route is the second half. Look up the slug, return a 404 if it’s missing, otherwise send the user on their way:

@app.route("/<string:slug>")
def go(slug: str):
    conn = get_conn()
    try:
        row = conn.execute("SELECT target FROM urls WHERE slug = ?", (slug,)).fetchone()
    finally:
        conn.close()
    if row is None:
        abort(404)
    return redirect(row[0], code=302)

A 302 redirect is the right default. Browsers cache 301s aggressively, which makes analytics harder and prevents you from changing the target later. Stick with 302 unless you specifically want permanent redirects.

The HTML form

The form lives in a Jinja template string, so the whole project stays in a single file. render_template_string auto-escapes Jinja variables, so user input can’t inject HTML.

INDEX_HTML = """
<!doctype html>
<title>URL Shortener</title>
<h1>URL Shortener</h1>
<form method="post" action="{{ url_for('shorten') }}">
  <input name="url" placeholder="https://example.com/long" size="50" required>
  <button type="submit">Shorten</button>
</form>
{% if short_url %}
  <p>Short link: <a href="{{ short_url }}">{{ short_url }}</a></p>
{% endif %}
{% if error %}
  <p style="color: red">{{ error }}</p>
{% endif %}
"""


@app.route("/", methods=["GET"])
def index():
    return render_template_string(INDEX_HTML)

For a slightly larger project you’d move this to templates/index.html and use render_template instead. The single-file form is fine for a tutorial.

How do you run and smoke-test the shortener?

Save everything as app.py, then start the dev server:

flask --app app run --debug

--debug turns on the reloader and the Werkzeug debugger. The server listens on http://127.0.0.1:5000 by default. Open it in a browser, paste a long URL, and you’ll get a short one back.

A quick smoke test from another terminal:

$ curl -i -X POST -d "url=https://docs.python.org/3/" http://127.0.0.1:5000/shorten
HTTP/1.1 200 OK
# output includes a link to http://127.0.0.1:5000/AbCd12

$ curl -I http://127.0.0.1:5000/AbCd12
HTTP/1.1 302 FOUND
Location: https://docs.python.org/3/

If port 5000 is taken (macOS AirPlay Receiver used to claim it), pass --port 5001 to the run command.

What are the common pitfalls to avoid?

Most of the mistakes you’ll hit on your first URL shortener come from the things you didn’t write down: the random module, the request host, and the SQLite connection. These are the three that show up most often in code review, and the fix for each is small. None of them are subtle once you’ve seen them once, but they’re easy to miss the first time you wire up a fresh Flask app from scratch, especially if you copy patterns from older tutorials that predate secrets and modern Flask request handling.

  • Don’t use random for slugs. The random module is for simulations, not security. secrets is the same API surface with a secure source of randomness.
  • Don’t concatenate URLs with "http://" + request.host. request.host doesn’t include the scheme, and reverse proxies can rewrite it. request.host_url does the right thing in most setups.
  • Don’t skip the try/finally around conn.close(). A long-lived dev server can hold hundreds of file handles if every request leaks a connection.

Improvements you can build next

Once you have a basic Flask URL shortener running on localhost, you’ll quickly find features you want to add: the ability to pick your own slug, a way to count clicks for analytics, and some basic throttling so a single user can’t fill the database. The list below walks through each one in order of complexity, starting with a one-line schema change and ending with a real deployment. Pick the ones that match your goal; a personal link shortener needs almost none of them, while a public service needs all of them.

  • Custom aliases. Read an optional alias field from the form and store it instead of a generated slug.
  • Click counts. Add a clicks INTEGER DEFAULT 0 column and UPDATE urls SET clicks = clicks + 1 WHERE slug = ? inside the redirect route.
  • Rate limiting. Per-IP throttling stops a single user from filling your database. The requests library guide shows the kind of HTTP plumbing you’d need.
  • Environment-based config. Move the database path and a FLASK_SECRET_KEY into a .env file with the python-dotenv guide.
  • Deployment. The deploying Python apps tutorial walks through putting a Flask app on a real server.

For more on the storage side, the SQLite guide covers transactions, indexes, and migrations. The Flask basics tutorial is a good companion if you want more on routing and templates.

What the final project looks like

When everything is wired up, the project tree is just three entries: a virtual environment folder, the app.py you built up section by section, and a urls.db file that SQLite creates on the first request. No templates folder, no static assets, no config files. That compactness is the point — when something is small enough to read in one sitting, it’s small enough to actually understand.

url_shortener/
├── .venv/
├── app.py
└── urls.db        # created on first request

Conclusion

You now have a working Flask URL shortener. The whole stack fits in one file, uses only Flask plus the standard library, and gives you a foundation to extend: click counts, custom slugs, auth, deployment. From here you can build URL redirect services for production traffic, wire up analytics on click counts, or layer in a small admin UI for managing saved links without changing the shape of the data model. The interesting design decisions are all in two functions, is_valid_url and make_slug, and the rest is plumbing.

If you want to go deeper on the storage side, the sqlite3 module reference is a good next stop. For URL handling, the urllib.parse module reference documents every attribute urlparse returns.

Frequently asked questions

How long should a URL shortener slug be?

For personal projects, secrets.token_urlsafe(4) gives you about 6 characters and roughly 4.3 billion possible IDs. For production traffic, bump that to 6 or 8 bytes so collisions stay rare even at millions of links. The URL gets longer, but the chance of two slugs ever matching drops from “noticeable” to “essentially zero.”

Should a URL shortener use SQLite or PostgreSQL?

SQLite handles tens of thousands of reads per second on a single disk, which is plenty for a tutorial or a small team link shortener. Switch to PostgreSQL when you need concurrent writers or replication across multiple servers. The Flask app stays the same; only the connection setup and the SQL dialect need to change.

What HTTP status code should a URL shortener return?

A 302 redirect is the right default because browsers don’t cache it aggressively. A 301 makes the destination permanent and gets cached by the browser, which kills your analytics and prevents you from changing the target later. Stick with 302 unless you have a specific reason to commit to a permanent redirect.

Can you build URL shortener with Flask without a database?

You can store slugs in an in-memory dictionary for prototypes, but the data is lost on every restart and you can’t share it across worker processes. A single SQLite file is a better default for anything you want to keep around, and it’s the same sqlite3 module you already have in the standard library.

How do you prevent a URL shortener from being abused?

For a beginner build, the structural validation in is_valid_url is enough to catch obvious garbage. If you start fetching targets to generate link previews, add an SSRF blocklist so users can’t submit URLs pointing at internal services like http://localhost:6379/. For a public service, layer in per-IP rate limiting on top of that.

See also