Build Your Own Link Shortener with Flask


Hello, tech enthusiasts! 👋

Your team sends long links all the time: in SMS messages, in printed flyers, in emails. A link like https://www.example.com/offers/summer/2026/product-page?ref=flyer is hard to type and looks messy. A short link like https://go.example.com/Xy7a9Q is much nicer.

An online service could do it, but then someone else holds your links and click numbers. Today we build our own in one Python file!

What you need

  • Python 3.9 or newer (https://www.python.org)
  • Flask, a small web framework: pip install flask
  • SQLite, a database that lives in a single file (it is already part of Python)

A web framework is like a ready kitchen: the stove and the pots are there, you only cook the food.

Step 1: The plan

Our app does four things:

  1. Creates a short code for a long link.
  2. Sends visitors from the short link to the long link (a redirect). We use code 302, “temporary move”, so browsers do not remember it forever.
  3. Counts every click.
  4. Has a small admin page, protected by a password.

One safety rule: if any link can be shortened, spammers will use your domain to hide bad websites. So we only allow targets on our own domains.

Step 2: The code

Create a file called app.py:

import hmac
import os
import secrets
import sqlite3
from urllib.parse import urlparse

from flask import Flask, Response, abort, g, redirect, request
from markupsafe import escape

app = Flask(__name__)
DB_FILE = "links.db"
# Only links to these sites can be shortened
ALLOWED_HOSTS = {"example.com", "www.example.com", "shop.example.com"}
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")


def init_db():
    with sqlite3.connect(DB_FILE) as conn:
        conn.execute("""CREATE TABLE IF NOT EXISTS links (
            code TEXT PRIMARY KEY,
            target TEXT NOT NULL,
            clicks INTEGER NOT NULL DEFAULT 0)""")


def db():
    # One database connection per request
    if "db" not in g:
        g.db = sqlite3.connect(DB_FILE)
        g.db.row_factory = sqlite3.Row
    return g.db


@app.teardown_appcontext
def close_db(error):
    conn = g.pop("db", None)
    if conn is not None:
        conn.close()


def is_admin():
    auth = request.authorization
    if not ADMIN_PASSWORD or auth is None or auth.password is None:
        return False
    # compare_digest is safe against timing attacks
    return hmac.compare_digest(auth.password.encode(), ADMIN_PASSWORD.encode())


def target_ok(url):
    parts = urlparse(url)
    return parts.scheme in ("http", "https") and parts.hostname in ALLOWED_HOSTS


@app.route("/admin", methods=["GET", "POST"])
def admin():
    if not is_admin():
        return Response("Login required", 401,
                        {"WWW-Authenticate": 'Basic realm="admin"'})
    message = ""
    if request.method == "POST":
        target = request.form.get("target", "").strip()
        if not target_ok(target):
            message = "Only links to our own websites are allowed."
        else:
            for _ in range(5):  # try again if the random code already exists
                code = secrets.token_urlsafe(4)
                try:
                    db().execute("INSERT INTO links (code, target) VALUES (?, ?)",
                                 (code, target))
                    db().commit()
                    message = f"Short link: {request.host_url}{code}"
                    break
                except sqlite3.IntegrityError:
                    continue
    rows = db().execute(
        "SELECT code, target, clicks FROM links ORDER BY clicks DESC").fetchall()
    items = "".join(
        f"<li>{escape(r['code'])} &rarr; {escape(r['target'])} ({r['clicks']} clicks)</li>"
        for r in rows)
    return f"""<h1>Link shortener</h1>
<p>{escape(message)}</p>
<form method="post">
  <input name="target" size="60" placeholder="https://www.example.com/page">
  <button>Shorten</button>
</form>
<ul>{items}</ul>"""


@app.route("/<code>")
def follow(code):
    row = db().execute("SELECT target FROM links WHERE code = ?", (code,)).fetchone()
    if row is None:
        abort(404)
    db().execute("UPDATE links SET clicks = clicks + 1 WHERE code = ?", (code,))
    db().commit()
    return redirect(row["target"], code=302)


init_db()

if __name__ == "__main__":
    app.run()

A few notes:

  • secrets.token_urlsafe(4) makes a random code of about 6 letters and numbers, like Xy7a9Q.
  • The ? marks in the SQL keep the database safe from “SQL injection”, a trick where an attacker hides commands inside the text.
  • escape() stops anyone from putting HTML or scripts into your admin page.
  • urlparse(...).hostname reads the real site name. So a tricky link like https://example.com@bad-site.test is blocked, because its real host is bad-site.test.

Step 3: Run it

Set a long password, then start the app:

export ADMIN_PASSWORD='choose-a-long-random-password'
python app.py

Open http://127.0.0.1:5000/admin in your browser. It asks for a username and password. Type any username and your password. Paste a long link, click Shorten, and try the short link. Then refresh the admin page and see the click count go up.

Step 4: Deploy it

The built-in Flask server is only for testing. For real use:

  • Run the app with gunicorn: pip install gunicorn, then gunicorn -w 2 -b 127.0.0.1:8000 app:app.
  • Put a web server like nginx in front, with HTTPS. This is a must. The admin password is sent with every request, and without HTTPS anyone on the network could read it.
  • Use a short domain or subdomain, like go.example.com.
  • Back up links.db regularly. It holds all your links.

Common problems

  • The admin page keeps asking for the password. The ADMIN_PASSWORD variable is empty in the terminal where the app runs. Set it again in the same window.
  • “Only links to our own websites” for a correct link. Add the exact host name to ALLOWED_HOSTS, for example shop.example.com.
  • “database is locked” errors. Many writers at once can cause this. Keep the number of workers small.

Wrap-up

In about 100 lines you have short links, click counts, a protected admin page and protection against abuse.

Next idea: add a column for the date of each click, and make a small weekly report of the top links for your marketing team. 🚀