October DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsSlow PC?RecommendedPC slow today? Run a repair scan before it gets worseResolve common Windows issues and optimize system performance.Scan NowOctober DealsAmazon USDeal season is back - check today's better picksAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
World desk9 min

Build a URL Shortener and ASCII QR Code Generator from Scratch in Python

A complete local Python shortener using SQLite and secure tokens, plus a constrained byte-mode QR encoder that renders a terminal matrix without third-party packages.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

You can build a working local URL shortener and render QR codes in a terminal using only Python’s standard library. The example below stores short links in SQLite, creates hard-to-guess codes with secrets, and implements a deliberately small QR encoder rather than calling a QR package. Its QR encoder supports byte-mode payloads up to 32 UTF-8 bytes; that is enough for the example’s local short URLs, but it is not a general-purpose QR implementation.

What this implementation does—and what it does not

The shortener runs on your machine at http://localhost:8000. Add a destination, start the HTTP server, and visit the resulting /s/<code> address to redirect to it. The mapping persists in a local SQLite database.

As an Amazon Associate I earn from qualifying purchases.

The QR encoder handles Version 2, error-correction level L, byte mode, and a fixed mask pattern. It builds the matrix, including Reed–Solomon error-correction codewords and format information, then prints a terminal-friendly rendering. The QR standard covers more modes, versions, correction levels, and production requirements than this implementation supports. Treat this as a constrained educational encoder, not a standards-complete substitute for a maintained QR library.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Requires Python 3.10 or later and no third-party packages.
  • Accepts only HTTP and HTTPS destinations for new short links.
  • Accepts QR payloads of at most 32 UTF-8 bytes. Longer input is rejected rather than silently truncated.
  • Uses a local-only listening address by default; it is not a public service configuration.

Save the complete program

Save this as shortqr.py. The imports are all from Python’s standard library; in particular, no QR package is used to generate the matrix.

import argparse
import secrets
import sqlite3
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlsplit

DB = "shortlinks.sqlite3"
BASE_URL = "http://localhost:8000"


def connect():
    db = sqlite3.connect(DB)
    db.execute("""CREATE TABLE IF NOT EXISTS links (
        code TEXT PRIMARY KEY,
        destination TEXT NOT NULL
    )""")
    return db


def validate_destination(value):
    if not value or any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127
                        for ch in value):
        raise ValueError("URL must be non-empty and contain no whitespace or control characters")
    try:
        parts = urlsplit(value)
        port = parts.port  # Accessing this also detects malformed port values.
    except ValueError as exc:
        raise ValueError("Malformed URL authority or port") from exc
    if parts.scheme.lower() not in ("http", "https"):
        raise ValueError("Only http and https destinations are accepted")
    if not parts.hostname:
        raise ValueError("URL must include a hostname")
    if parts.username is not None or parts.password is not None:
        raise ValueError("URLs containing embedded credentials are not accepted")
    return value


def add_link(destination):
    destination = validate_destination(destination)
    db = connect()
    try:
        for _ in range(8):
            code = secrets.token_urlsafe(5)
            try:
                with db:
                    db.execute("INSERT INTO links(code, destination) VALUES (?, ?)",
                               (code, destination))
                return code
            except sqlite3.IntegrityError:
                continue
        raise RuntimeError("Could not allocate a unique short code; try again")
    finally:
        db.close()


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        parts = urlsplit(self.path)
        prefix = "/s/"
        if not parts.path.startswith(prefix):
            self.send_error(404, "Short link not found")
            return
        code = parts.path[len(prefix):]
        if not code or "/" in code:
            self.send_error(404, "Short link not found")
            return
        db = connect()
        try:
            row = db.execute("SELECT destination FROM links WHERE code = ?",
                             (code,)).fetchone()
        finally:
            db.close()
        if row is None:
            self.send_error(404, "Short link not found")
            return
        try:
            destination = validate_destination(row[0])
        except ValueError:
            self.send_error(500, "Stored destination is invalid")
            return
        self.send_response(302)
        self.send_header("Location", destination)
        self.end_headers()


def gf_mul(x, y):
    """Multiply bytes in GF(256), using the QR field polynomial 0x11D."""
    z = 0
    for _ in range(8):
        if y & 1:
            z ^= x
        y >>= 1
        x <<= 1
        if x & 0x100:
            x ^= 0x11D
    return z


def rs_remainder(data, degree=10):
    generator = [1]
    root = 1
    for _ in range(degree):
        nxt = [0] * (len(generator) + 1)
        for i, coefficient in enumerate(generator):
            nxt[i] ^= coefficient
            nxt[i + 1] ^= gf_mul(coefficient, root)
        generator = nxt
        root = gf_mul(root, 2)
    remainder = [0] * degree
    for byte in data:
        factor = byte ^ remainder[0]
        remainder = remainder[1:] + [0]
        for i, coefficient in enumerate(generator[1:]):
            remainder[i] ^= gf_mul(coefficient, factor)
    return remainder


def append_bits(bits, value, count):
    for shift in range(count - 1, -1, -1):
        bits.append((value >> shift) & 1)


def qr_matrix(payload):
    """Encode up to 32 UTF-8 bytes as a Version 2-L QR matrix."""
    raw = payload.encode("utf-8")
    if len(raw) > 32:
        raise ValueError("Version 2-L byte mode supports at most 32 UTF-8 bytes")

    # Version 2-L has 34 data codewords and 10 error-correction codewords.
    bits = []
    append_bits(bits, 0b0100, 4)       # Byte-mode indicator
    append_bits(bits, len(raw), 8)     # Character count for Versions 1–9
    for byte in raw:
        append_bits(bits, byte, 8)
    capacity = 34 * 8
    bits.extend([0] * min(4, capacity - len(bits)))
    while len(bits) % 8:
        bits.append(0)
    data = []
    for i in range(0, len(bits), 8):
        value = 0
        for bit in bits[i:i + 8]:
            value = (value << 1) | bit
        data.append(value)
    pad = (0xEC, 0x11)
    while len(data) < 34:
        data.append(pad[(len(data) - ((len(bits) - 4 - 8 - 8 * len(raw) + 7) // 8)) % 2])
    codewords = data + rs_remainder(data)
    stream = []
    for byte in codewords:
        append_bits(stream, byte, 8)

    size = 25
    grid = [[None] * size for _ in range(size)]

    def set_function(x, y, dark):
        grid[y][x] = bool(dark)

    def finder(cx, cy):
        for dy in range(-4, 5):
            for dx in range(-4, 5):
                x, y = cx + dx, cy + dy
                if 0 <= x < size and 0 <= y < size:
                    distance = max(abs(dx), abs(dy))
                    set_function(x, y, distance != 2 and distance != 4)

    finder(3, 3)
    finder(size - 4, 3)
    finder(3, size - 4)

    # Timing patterns.
    for i in range(8, size - 8):
        set_function(i, 6, i % 2 == 0)
        set_function(6, i, i % 2 == 0)

    # Version 2 alignment pattern, centered at (18, 18).
    for dy in range(-2, 3):
        for dx in range(-2, 3):
            set_function(18 + dx, 18 + dy,
                         max(abs(dx), abs(dy)) != 1)

    # Reserve the format-information modules before placing data.
    for i in range(6):
        set_function(8, i, False)
    set_function(8, 7, False)
    set_function(8, 8, False)
    set_function(7, 8, False)
    for i in range(9, 15):
        set_function(14 - i, 8, False)
    for i in range(8):
        set_function(size - 1 - i, 8, False)
    for i in range(8, 15):
        set_function(8, size - 15 + i, False)
    set_function(8, size - 8, True)  # Fixed dark module

    # Place data in the standard two-column zigzag, skipping function modules.
    bit_index = 0
    upward = True
    right = size - 1
    while right >= 1:
        if right == 6:
            right = 5
        for vert in range(size):
            y = size - 1 - vert if upward else vert
            for x in (right, right - 1):
                if grid[y][x] is None:
                    bit = stream[bit_index] if bit_index < len(stream) else 0
                    bit_index += 1
                    # Fixed mask pattern 0: (row + column) is even.
                    grid[y][x] = bool(bit ^ int((x + y) % 2 == 0))
        upward = not upward
        right -= 2

    # Format: error correction L (01), mask pattern 0 (000).
    format_data = 0b01000
    remainder = format_data
    for _ in range(10):
        remainder = (remainder << 1) ^ (0x537 if remainder & 0x200 else 0)
    format_bits = ((format_data << 10) | remainder) ^ 0x5412
    def format_bit(i):
        return bool((format_bits >> i) & 1)
    for i in range(6):
        set_function(8, i, format_bit(i))
    set_function(8, 7, format_bit(6))
    set_function(8, 8, format_bit(7))
    set_function(7, 8, format_bit(8))
    for i in range(9, 15):
        set_function(14 - i, 8, format_bit(i))
    for i in range(8):
        set_function(size - 1 - i, 8, format_bit(i))
    for i in range(8, 15):
        set_function(8, size - 15 + i, format_bit(i))
    return grid


def print_qr(payload):
    matrix = qr_matrix(payload)
    border = " " * 8
    print(border)
    for row in matrix:
        print(border + "".join("██" if cell else "  " for cell in row) + border)
    print(border)


def main():
    parser = argparse.ArgumentParser(description="Local URL shortener and small ASCII QR encoder")
    sub = parser.add_subparsers(dest="command", required=True)
    add = sub.add_parser("add", help="store a destination and print its short URL")
    add.add_argument("url")
    serve = sub.add_parser("serve", help="run the local redirect server")
    serve.add_argument("--host", default="127.0.0.1")
    serve.add_argument("--port", type=int, default=8000)
    qr = sub.add_parser("qr", help="render a payload of at most 32 UTF-8 bytes")
    qr.add_argument("payload")
    args = parser.parse_args()

    if args.command == "add":
        code = add_link(args.url)
        print(f"{BASE_URL}/s/{code}")
    elif args.command == "serve":
        server = HTTPServer((args.host, args.port), Handler)
        print(f"Serving on http://{args.host}:{args.port}")
        try:
            server.serve_forever()
        except KeyboardInterrupt:
            pass
        finally:
            server.server_close()
    else:
        try:
            print_qr(args.payload)
        except ValueError as exc:
            print(exc, file=sys.stderr)
            raise SystemExit(2)


if __name__ == "__main__":
    main()

Run the shortener locally

Add a destination

In a terminal, create a link. Use a real HTTP or HTTPS destination in place of the example:

python shortqr.py add https://example.org/page

The program prints a short URL such as http://localhost:8000/s/AbCdEfG. The code is generated with secrets.token_urlsafe(5); the database enforces uniqueness, and the program retries a collision up to eight times. SQLite stores the destination in shortlinks.sqlite3 in the current directory.

Start the redirect server

python shortqr.py serve

Open the printed short URL while the server is running. Existing codes redirect with HTTP 302; unknown or malformed codes receive a 404 response. Stop the server with Ctrl+C. The default bind address, 127.0.0.1, makes it reachable only from the same machine.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Generate and display a QR code

Pass the printed short URL directly to the QR subcommand. The example short URL is designed to fit the encoder’s 32-byte limit:

python shortqr.py qr "http://localhost:8000/s/AbCdEfG"

Use a terminal font with fixed-width glyphs. Each module is rendered as two characters wide to reduce distortion from glyph proportions, with a four-module quiet zone around the symbol. The encoder uses a single fixed mask instead of scoring all masks for visual balance, so appearance is not optimized. Scan the rendered result with the device and environment you intend to use; appearance alone does not establish scanner compatibility.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

How the QR encoder fits together

Data and error correction

The encoder converts UTF-8 bytes into QR byte mode, adds the byte count, terminator and padding, then creates ten Reed–Solomon error-correction codewords over GF(256). Version 2-L has 34 data codewords and 10 error-correction codewords in this implementation. The mode and count fields are encoded explicitly; payloads beyond 32 bytes are rejected.

Matrix construction

The 25-by-25 grid reserves the three finder patterns, separators, timing patterns, the Version 2 alignment pattern, format information and fixed dark module. Data bits are then placed in the QR zigzag pattern, masked with pattern 0, and paired with format bits for correction level L and that mask. Seven remainder modules are handled as zero data bits before masking.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

This compact implementation intentionally supports one QR version and one correction level. The third-party qrcode project documents L, M, Q and H correction levels with approximate capacities of up to 7%, 15%, 25% and 30% respectively; those are package documentation figures, not measurements of this program. A general-purpose QR encoder needs broader version selection and additional standard-defined handling. ISO/IEC 18004:2024, published in August 2024, specifies QR symbology including encoding, dimensions, error correction and production quality.

URL validation and public-service risks

Python’s urllib.parse.urlsplit() separates URL components; it does not establish that a URL is safe or valid. The program applies a narrow policy after parsing: an HTTP or HTTPS scheme, a hostname, no embedded username or password, and no whitespace or control characters. It also accesses parts.port so malformed port values are rejected.

That policy is a starting point for this local example, not a guarantee against phishing or every browser/parser discrepancy. OWASP recommends using a URL parser compatible with both the redirect API and browser interpretation when handling redirects. A public shortener also needs operational controls such as rate limits, abuse reporting and monitoring; unpredictable codes do not provide those protections. A QR code only encodes its payload, so users should still inspect destinations before opening them.

Useful changes and their trade-offs

Persisting links

SQLite keeps mappings after the process exits, unlike an in-memory dictionary, while avoiding an external database service. Back up the database if its mappings matter. The example does not implement deletion, administrative access, or concurrent deployment tuning.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Changing the QR payload limit

Do not simply raise the 32-byte check: the matrix version, data capacity, error-correction block structure and alignment patterns must all change consistently. For arbitrary URLs or production output, use a maintained QR library; that is a sensible alternative, but it would no longer be a from-scratch QR encoder.

Making the service public

Changing --host can expose the HTTP server beyond the local machine, but this tutorial is not a deployment guide. Before making a shortener publicly reachable, add abuse controls, safe operational logging, a review and reporting path, and a robust URL policy. Since a shortener conceals the destination behind a redirect, it can be used to disguise hostile links.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Crashes, No Sound, or Screen Glitches?Free driver scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.