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

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

You can build a working shortener with Python’s standard library: validate destinations, store unpredictable short codes in SQLite, and redirect requests through a small HTTP server. You can also encode a deliberately limited QR Code subset yourself—without a QR package—and render its modules as terminal text. The implementation below supports QR Code version 1-L in byte mode, so it accepts at most 17 UTF-8 bytes per payload. It is a learning implementation, not a general-purpose QR encoder or a production-ready public shortener.

What this implementation does—and where it stops

  • Uses urllib.parse, sqlite3, secrets, and other Python standard-library modules. No third-party QR library generates the symbol.
  • Accepts only absolute HTTP or HTTPS destinations with a hostname and no embedded username or password.
  • Stores a mapping between an eight-character URL-safe token and its destination in SQLite, then redirects known tokens.
  • Creates QR Code version 1-L symbols in byte mode and prints them using doubled ASCII module widths and a four-module quiet zone.
  • Does not implement other QR versions, error-correction levels, QR input modes, or mask selection. The generated matrix has not been scanner-tested here; verify it with an independent decoder before relying on it.

Version 1-L has 19 data codewords. Byte mode uses four mode bits and an eight-bit length field, leaving room for at most 17 payload bytes. The capacity is a poor fit for many full destination URLs, which is why the example encodes the short URL instead. Even a short URL may exceed the limit; the program reports that rather than truncating it.

Build the shortener

Validate destinations before storing them

urllib.parse.urlsplit() separates URL components; it does not certify that an input is safe. Python’s urllib.parse documentation explicitly warns that parsing APIs do not validate inputs. OWASP’s Unvalidated Redirects and Forwards guidance likewise stresses using a URL parser compatible with both the redirect API and browser interpretation. A scheme and hostname check is a reasonable starting policy for this local demonstration, not a complete defense for a public service.

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

This policy rejects whitespace and control characters, missing hostnames, unexpected schemes, embedded credentials, and invalid ports. It deliberately does not rewrite the URL: transformations can change how a browser interprets a destination. A public product needs a stronger, documented policy and a way to respond to abuse.

Save mappings and generate codes

Python’s secrets module is intended for security-sensitive random values; random is not. The script uses URL-safe token generation and a unique database constraint, then retries a bounded number of times if a candidate collides. Unpredictable codes do not replace rate limits, access controls for private links, or abuse monitoring.

Save this as shortener.py and run it with Python 3. The public base URL is fixed to the local address for this example; change it to the externally reachable origin when deploying, and ensure it matches the URL users should receive.

import json
import os
import re
import secrets
import sqlite3
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlsplit

DB_PATH = "shortlinks.sqlite3"
BASE_URL = os.environ.get("SHORTENER_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
CODE_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")


def connect():
    return sqlite3.connect(DB_PATH)


def initialize():
    with connect() as db:
        db.execute("""CREATE TABLE IF NOT EXISTS links (
            code TEXT PRIMARY KEY,
            destination TEXT NOT NULL
        )""")


def validate_destination(value):
    if not isinstance(value, str) or not value:
        raise ValueError("destination must be a non-empty URL string")
    if any(ord(ch) < 0x20 or ch.isspace() for ch in value):
        raise ValueError("destination cannot contain whitespace or control characters")
    try:
        parts = urlsplit(value)
        # Accessing .port also detects malformed or out-of-range port numbers.
        _ = parts.port
    except ValueError as exc:
        raise ValueError("destination has a malformed authority or port") from exc
    if parts.scheme.lower() not in ("http", "https"):
        raise ValueError("only http and https destinations are allowed")
    if not parts.netloc or not parts.hostname:
        raise ValueError("destination must include a hostname")
    if parts.username is not None or parts.password is not None:
        raise ValueError("embedded credentials are not allowed")
    return value


def create_short_link(destination):
    destination = validate_destination(destination)
    for _ in range(8):
        code = secrets.token_urlsafe(6)
        try:
            with connect() as db:
                db.execute("INSERT INTO links(code, destination) VALUES (?, ?)",
                           (code, destination))
            return BASE_URL + "/" + code
        except sqlite3.IntegrityError:
            # A code collision is rare, but uniqueness is enforced by SQLite.
            continue
    raise RuntimeError("could not allocate a unique code after 8 attempts")


class ShortenerHandler(BaseHTTPRequestHandler):
    def reply(self, status, payload):
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        if self.path != "/api/shorten":
            self.reply(404, {"error": "not found"})
            return
        try:
            length = int(self.headers.get("Content-Length", "0"))
        except ValueError:
            self.reply(400, {"error": "invalid Content-Length"})
            return
        if length <= 0 or length > 8192:
            self.reply(413, {"error": "request body must be 1–8192 bytes"})
            return
        try:
            data = json.loads(self.rfile.read(length))
            short_url = create_short_link(data.get("url"))
        except (UnicodeDecodeError, json.JSONDecodeError, AttributeError):
            self.reply(400, {"error": "send a JSON object containing a url string"})
        except ValueError as exc:
            self.reply(400, {"error": str(exc)})
        except RuntimeError as exc:
            self.reply(503, {"error": str(exc)})
        else:
            self.reply(201, {"short_url": short_url})

    def do_GET(self):
        code = self.path[1:] if self.path.startswith("/") else ""
        if not CODE_RE.fullmatch(code):
            self.reply(404, {"error": "short link not found"})
            return
        with connect() as db:
            row = db.execute("SELECT destination FROM links WHERE code = ?",
                             (code,)).fetchone()
        if row is None:
            self.reply(404, {"error": "short link not found"})
            return
        # Recheck in case the database was edited outside this application.
        try:
            destination = validate_destination(row[0])
        except ValueError:
            self.reply(410, {"error": "stored destination is no longer permitted"})
            return
        self.send_response(302)
        self.send_header("Location", destination)
        self.send_header("Content-Length", "0")
        self.end_headers()


if __name__ == "__main__":
    initialize()
    print("Listening on http://127.0.0.1:8000")
    HTTPServer(("127.0.0.1", 8000), ShortenerHandler).serve_forever()

Try the HTTP endpoint

Start the server with python shortener.py. In another terminal, send a JSON request:

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -i -X POST http://127.0.0.1:8000/api/shorten 
  -H 'Content-Type: application/json' 
  -d '{"url":"https://example.org/path"}'

A successful response is HTTP 201 with a JSON short_url. Open that URL to exercise the redirect. An unknown code returns 404. The example binds to loopback, so other machines cannot reach it; the request handler is also single-threaded and has no rate limiting, authentication, TLS termination, abuse reporting, or operational monitoring. Do not expose it as a public service unchanged.

Encode a limited QR Code without a package

What the encoder has to do

A QR symbol is not just text arranged in a square. ISO/IEC 18004:2024, the fourth edition published in August 2024, covers encoding, symbol formats and dimensions, error correction, decoding, and production quality. This small encoder implements one narrow path through those rules: version 1-L, byte mode, one Reed–Solomon block, and mask pattern 0. It rejects longer payloads rather than silently creating a malformed or truncated symbol.

Save the following as qr_ascii.py. The code constructs the data bitstream, adds error-correction bytes in the QR finite field, draws fixed patterns, places masked data bits, writes format information, and renders a quiet zone. The rendering doubles each module horizontally to help compensate for terminal glyph proportions; use a monospaced terminal.

SIZE = 21  # QR Code version 1 is 21 x 21 modules.


def gf_tables():
    exp = [0] * 512
    log = [0] * 256
    value = 1
    for i in range(255):
        exp[i] = value
        log[value] = i
        value <<= 1
        if value & 0x100:
            value ^= 0x11D
    for i in range(255, 512):
        exp[i] = exp[i - 255]
    return exp, log


GF_EXP, GF_LOG = gf_tables()


def gf_mul(a, b):
    if a == 0 or b == 0:
        return 0
    return GF_EXP[GF_LOG[a] + GF_LOG[b]]


def poly_multiply(a, b):
    result = [0] * (len(a) + len(b) - 1)
    for i, x in enumerate(a):
        for j, y in enumerate(b):
            result[i + j] ^= gf_mul(x, y)
    return result


def reed_solomon_remainder(data, degree=7):
    generator = [1]
    for i in range(degree):
        generator = poly_multiply(generator, [1, GF_EXP[i]])
    remainder = [0] * degree
    for byte in data:
        factor = byte ^ remainder[0]
        remainder = remainder[1:] + [0]
        for i in range(degree):
            remainder[i] ^= gf_mul(generator[i + 1], factor)
    return remainder


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


def make_codewords(text):
    payload = text.encode("utf-8")
    if len(payload) > 17:
        raise ValueError("version 1-L byte mode supports at most 17 UTF-8 bytes")
    bits = []
    append_bits(bits, 0b0100, 4)  # byte mode
    append_bits(bits, len(payload), 8)
    for byte in payload:
        append_bits(bits, byte, 8)
    capacity = 19 * 8
    bits.extend([0] * min(4, capacity - len(bits)))  # terminator
    while len(bits) % 8:
        bits.append(0)
    data = []
    for offset in range(0, len(bits), 8):
        byte = 0
        for bit in bits[offset:offset + 8]:
            byte = (byte << 1) | bit
        data.append(byte)
    pad = (0xEC, 0x11)
    while len(data) < 19:
        data.append(pad[(len(data) - ((len(bits) + 7) // 8)) % 2])
    return data + reed_solomon_remainder(data)


def set_finder(matrix, top, left):
    for dy in range(-1, 8):
        for dx in range(-1, 8):
            row, col = top + dy, left + dx
            if 0 <= row < SIZE and 0 <= col < SIZE:
                if 0 <= dy < 7 and 0 <= dx < 7:
                    dark = (dy in (0, 6) or dx in (0, 6) or
                            (2 <= dy <= 4 and 2 <= dx <= 4))
                    matrix[row][col] = dark
                else:
                    matrix[row][col] = False  # finder separator


def format_bits(mask=0):
    # Error-correction level L has format indicator 01.
    value = (0b01 << 3) | mask
    remainder = value << 10
    for bit in range(14, 9, -1):
        if (remainder >> bit) & 1:
            remainder ^= 0x537 << (bit - 10)
    return ((value << 10) | remainder) ^ 0x5412


def draw_format(matrix, mask=0):
    bits = format_bits(mask)
    first = ([(i, 8) for i in range(6)] + [(7, 8), (8, 8), (8, 7)] +
             [(8, 14 - i) for i in range(9, 15)])
    second = ([(8, SIZE - 1 - i) for i in range(8)] +
              [(SIZE - 15 + i, 8) for i in range(8, 15)])
    for i, (row, col) in enumerate(first):
        matrix[row][col] = bool((bits >> i) & 1)
    for i, (row, col) in enumerate(second):
        matrix[row][col] = bool((bits >> i) & 1)
    matrix[SIZE - 8][8] = True  # fixed dark module


def make_matrix(text):
    codewords = make_codewords(text)
    stream = []
    for byte in codewords:
        append_bits(stream, byte, 8)
    matrix = [[None] * SIZE for _ in range(SIZE)]
    set_finder(matrix, 0, 0)
    set_finder(matrix, 0, SIZE - 7)
    set_finder(matrix, SIZE - 7, 0)
    for i in range(8, SIZE - 8):
        timing_dark = (i % 2 == 0)
        if matrix[6][i] is None:
            matrix[6][i] = timing_dark
        if matrix[i][6] is None:
            matrix[i][6] = timing_dark
    # Reserve the format-information cells before placing payload bits.
    format_positions = ([(i, 8) for i in range(6)] + [(7, 8), (8, 8), (8, 7)] +
                        [(8, 14 - i) for i in range(9, 15)] +
                        [(8, SIZE - 1 - i) for i in range(8)] +
                        [(SIZE - 15 + i, 8) for i in range(8, 15)])
    for row, col in format_positions:
        matrix[row][col] = False
    matrix[SIZE - 8][8] = True

    bit_index = 0
    upward = True
    right = SIZE - 1
    while right > 0:
        if right == 6:
            right -= 1  # timing column is never a data column
        rows = range(SIZE - 1, -1, -1) if upward else range(SIZE)
        for row in rows:
            for col in (right, right - 1):
                if matrix[row][col] is None:
                    bit = stream[bit_index] if bit_index < len(stream) else 0
                    bit_index += 1
                    # Mask pattern 0: invert when row + column is even.
                    matrix[row][col] = bool(bit ^ ((row + col) % 2 == 0))
        upward = not upward
        right -= 2
    draw_format(matrix, mask=0)
    return matrix


def render_ascii(matrix):
    border = 4
    width = len(matrix) + border * 2
    lines = []
    for row in [[False] * border + line + [False] * border for line in matrix]:
        lines.append("".join("██" if cell else "  " for cell in row))
    blank = "  " * width
    return "n".join([blank] * border + lines + [blank] * border)


if __name__ == "__main__":
    import sys
    if len(sys.argv) != 2:
        raise SystemExit("usage: python qr_ascii.py SHORT_URL")
    print(render_ascii(make_matrix(sys.argv[1])))

Generate a short link, then render it

After creating a short link through the endpoint, pass that exact URL as one shell argument:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
python qr_ascii.py 'http://127.0.0.1:8000/AbCdEf_g'

The local URL shown is 28 ASCII bytes, so it exceeds this encoder’s 17-byte limit and will correctly raise an error. A shorter deployed origin and compact code might fit, but measure the UTF-8 byte length rather than counting visible characters. To support realistic longer URLs, implement additional QR versions and their block layouts, alignment patterns, capacities, and other required rules, or use a maintained QR library instead of claiming this subset handles arbitrary links.

For reference, the third-party qrcode project documents error-correction levels L, M, Q, and H with approximate capacities of up to 7%, 15%, 25%, and 30%, respectively. Those are package documentation values, not measurements of this implementation. Using that package to produce the QR matrix would no longer be a from-scratch encoder.

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

Security and reliability decisions before deployment

Redirect safety is an application policy

A URL shortener can conceal a phishing destination, and a QR code does not make its payload trustworthy. For a public service, define which destinations users may shorten, add rate controls and abuse reporting, and decide how to handle malicious or removed links. Parsing alone does not stop open-redirect abuse. If another process or administrator can change stored records, revalidate destinations before redirecting, as the sample does.

Storage and operational behavior

SQLite persists mappings across process restarts using a standard-library module. An in-memory dictionary is simpler for a throwaway demonstration but loses every mapping when the process stops. The sample’s database contains destinations in plaintext; protect the file and backups appropriately, and consider privacy and retention requirements for your use case. A public deployment also needs a production-capable server setup, TLS, observability, and operational safeguards; those are intentionally outside this local example.

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

Test what matters

  • Submit a valid HTTP and HTTPS URL, then confirm the stored code redirects to the original destination.
  • Check that unsupported schemes, missing hostnames, credentials, malformed ports, whitespace, and control characters are rejected.
  • Request an unknown code and confirm a 404; verify invalid request JSON and oversized bodies receive client errors.
  • Check that QR input above the supported byte limit fails clearly instead of truncating.
  • Decode generated symbols with an independent QR reader before asserting scanner compatibility or using them in a workflow.

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.