Forgotten Python Framework #1: Bottle
In an era where bootstrapping a basic web application often requires downloading a huge framework or navigating complex dependency trees, there's a quiet, radical alternative in the Python ecosystem. Bottle. Many new Python developers probably haven't heard of it. And some might think the framework

In an era where bootstrapping a basic web application often requires downloading a huge framework or navigating complex dependency trees, there's a quiet, radical alternative in the Python ecosystem. Bottle. Many new Python developers probably haven't heard of it. And some might think the framework must be forgotten by now. Well! No. Bottle is a fast, simple, and lightweight WSGI micro web framework for Python. It was developed by Marcel Hellkamp, aka defnull, and released in 2009. He built it out of frustration with the heavy, over-engineered frameworks of the time, taking inspiration from a smaller Ruby framework called Sinatra. Later, a developer named Armin Ronacher built Flask, taking inspiration from Bottle in turn. While it's lost the mainstream popularity battle to giants like Flask, Django, and FastAPI, Bottle remains actively maintained. The core philosophy of Bottle is simplicity, and with its one-file approach, it can surprisingly build a variety of projects, from small web apps and scrapers to IoT monitoring dashboards or tools. Why take my word for it? Let's build a URL shortener with it โ and this time, let's actually finish it, instead of leaving it as a toy that falls over the moment someone pokes at it. First, we need to install it in our environment with this command in your terminal: pip install bottle I'm using a global environment, but virtual environments are recommended. from bottle import route, run @route("/") def home(): return "Hello from Bottle!" run(host="localhost", port=8080) Save it as app.py and run it. python app.py Open http://localhost:8080, and that's it. You have a working web application. But that's just the basic setup. Now let's build out our URL shortener โ for real this time. We're going to build a small, actually-usable URL-shortening service. The idea is simple. A user sends us: https://example.com/a-very-long-url Our application gives them: http://localhost:8080/abc123 Visiting /abc123 redirects the user to the original URL. Nothing revolutionary. So let's build it. First, create a directory. Open up your terminal and fire up these commands: mkdir bottle-shortener cd bottle-shortener Create a virtual environment: python -m venv .venv Activate it. On Linux/macOS: source .venv/bin/activate On Windows: .venv\Scripts\activate Install Bottle into the virtual environment: pip install bottle Our initial project can be incredibly small: bottle-shortener/ โโโ app.py โโโ urls.db We'll let SQLite handle persistence. Let's start with the homepage. from bottle import route, run @route("/") def home(): return "URL Shortener" run(host="localhost", port=8080) Bottle uses decorators to define routes. This: @route("/") means: "when someone makes a request to /, call this function." That's a very simple mental model. Now let's create an endpoint for creating short URLs: from bottle import route, request, response @route("/shorten", method="POST") def shorten(): url = request.forms.get("url") response.content_type = "application/json" return {"url": url} A couple of interesting things here. request gives us access to the incoming HTTP request: request.forms.get("url") gets form data. And: response.content_type lets us control the response. Bottle also lets us return plain Python dictionaries and it'll serialize them to JSON automatically. We don't need a database server for this project. SQLite is enough. Create a database helper โ using a context manager this time, so a connection always gets closed even if something inside it blows up: import sqlite3 from contextlib import contextmanager @contextmanager def get_db(): connection = sqlite3.connect("urls.db") connection.row_factory = sqlite3.Row try: yield connection finally: connection.close() Then create our table: def init_db(): with get_db() as db: db.execute(""" CREATE TABLE IF NOT EXISTS urls ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT UNIQUE NOT NULL, url TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) db.commit() We'll call this once, when the application starts. We need a short identifier for every URL. We can use Python's secrets module: import secrets import string def generate_code(length=6): characters = string.ascii_letters + string.digits return "".join( secrets.choice(characters) for _ in range(length) ) Now we can generate values like: a8F2xQ k91LmZ Pq72Nd There's a small catch, though โ nothing stops two different URLs from generating the same code. We'll fix that properly in a minute instead of just hoping it never happens. In the original draft of this project, someone could submit the string hello and we'd happily store it as a "URL." That's not great. Before saving anything, we should check it's actually a URL with a scheme and a host: from urllib.parse import urlparse def is_valid_url(url): parsed = urlparse(url) return parsed.scheme in ("http", "https") and bool(parsed.netloc) is_valid_url("hello") returns False. is_valid_url("https://example.com") returns True. That's enough to keep garbage out without writing a full URL grammar by hand. A 6-character alphanumeric code has billions of possible values, so collisions are rare โ but "rare" isn't "impossible," and a URL shortener that occasionally overwrites someone else's link is a bad URL shortener. So we check before we insert, and retry if we happen to land on a code that's already taken: def generate_unique_code(db, length=6, max_attempts=10): for _ in range(max_attempts): code = generate_code(length) existing = db.execute( "SELECT 1 FROM urls WHERE code = ?", (code,) ).fetchone() if not existing: return code # If we somehow collide ten times in a row, widen the code instead # of failing outright. return generate_unique_code(db, length=length + 1, max_attempts=max_attempts) Our /shorten endpoint now validates, checks for collisions, and saves the URL: @route("/shorten", method="POST") def shorten(): url = request.forms.get("url", "").strip() if not url: response.status = 400 return {"error": "URL is required"} if not is_valid_url(url): response.status = 400 return {"error": "That doesn't look like a valid http(s) URL"} with get_db() as db: code = generate_unique_code(db) db.execute( "INSERT INTO urls (code, url) VALUES (?, ?)", (code, url) ) db.commit() return { "code": code, "short_url": f"http://localhost:8080/{code}" } Now we have a functional URL-shortening API โ one that actually checks its inputs. Now comes the fun part. Someone visits: /abc123 We look up abc123 in the database and redirect them. Bottle gives us a convenient redirect() function: from bottle import redirect, HTTPError @route("/<code>") def visit(code): with get_db() as db: result = db.execute( "SELECT url FROM urls WHERE code = ?", (code,) ).fetchone() if not result: raise HTTPError(404, "Short URL not found") redirect(result["url"]) Bottle's dynamic routes are straightforward: @route("/<code>") means the value in that part of the URL becomes the code argument. So a request to /abc123 calls visit("abc123"). A public "paste any URL, get a link back" endpoint is exactly the kind of thing that gets hammered by scripts the moment it's live. We don't need anything heavyweight โ a simple in-memory sliding window per IP is enough to stop casual abuse: import time _request_log = {} RATE_LIMIT_MAX = 10 RATE_LIMIT_WINDOW = 60 # seconds def is_rate_limited(ip): now = time.time() window_start = now - RATE_LIMIT_WINDOW timestamps = [t for t in _request_log.get(ip, []) if t > window_start] timestamps.append(now) _request_log[ip] = timestamps return len(timestamps) > RATE_LIMIT_MAX It resets if the process restarts and won't survive multiple worker processes without a shared store like Redis โ but for a small tool running as a single process, it does the job. Everything so far has been API-only โ fine for curl, useless for a person with a browser. So the home page now renders a small HTML form, and /shorten responds with HTML by default and JSON when the client asks for it (Accept: application/json). That way the same endpoint works whether a person is filling in a form or a script is calling it. Hardcoding DATABASE = "urls.db" and localhost:8080 is fine for a five-minute experiment, but the moment you want to run this anywhere else, you're editing source code to change a setting. Instead, we read everything from environment variables, with sensible local defaults: import os DATABASE = os.environ.get("SHORTY_DB", "urls.db") HOST = os.environ.get("SHORTY_HOST", "localhost") PORT = int(os.environ.get("SHORTY_PORT", 8080)) BASE_URL = os.environ.get("SHORTY_BASE_URL", f"http://{HOST}:{PORT}") CODE_LENGTH = int(os.environ.get("SHORTY_CODE_LENGTH", 6)) To keep this post short, I've kept the version below to just this basic app โ validation, collisions, config, rate limiting, and a bare-bones HTML form. For the full application with the polished UI, custom aliases, click tracking, and a stats page, check out the repo: Here At this point, our entire application still fits comfortably inside one file โ it's just a more honest file than before. import os import re import sqlite3 import string import secrets import time from contextlib import contextmanager from urllib.parse import urlparse from bottle import route, request, response, redirect, run, template, HTTPError # --------------------------------------------------------------------------- # Configuration (env vars, with sane defaults for local dev) # --------------------------------------------------------------------------- DATABASE = os.environ.get("SHORTY_DB", "urls.db") HOST = os.environ.get("SHORTY_HOST", "localhost") PORT = int(os.environ.get("SHORTY_PORT", 8080)) BASE_URL = os.environ.get("SHORTY_BASE_URL", f"http://{HOST}:{PORT}") CODE_LENGTH = int(os.environ.get("SHORTY_CODE_LENGTH", 6)) # Rate limiting: max requests per window, per IP, for the /shorten endpoint. RATE_LIMIT_MAX = int(os.environ.get("SHORTY_RATE_LIMIT_MAX", 10)) RATE_LIMIT_WINDOW = int(os.environ.get("SHORTY_RATE_LIMIT_WINDOW", 60)) # seconds # --------------------------------------------------------------------------- # Database # --------------------------------------------------------------------------- @contextmanager def get_db(): """Open a connection, hand it over, always close it - even on error.""" connection = sqlite3.connect(DATABASE) connection.row_factory = sqlite3.Row try: yield connection finally: connection.close() def init_db(): with get_db() as db: db.execute(""" CREATE TABLE IF NOT EXISTS urls ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT UNIQUE NOT NULL, url TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) """) db.commit() # --------------------------------------------------------------------------- # URL validation # --------------------------------------------------------------------------- def is_valid_url(url): """Require a real http(s) URL with a network location, not just any string.""" try: parsed = urlparse(url) except ValueError: return False return parsed.scheme in ("http", "https") and bool(parsed.netloc) # --------------------------------------------------------------------------- # Short code generation (with collision handling) # --------------------------------------------------------------------------- def generate_code(length=CODE_LENGTH): characters = string.ascii_letters + string.digits return "".join(secrets.choice(characters) for _ in range(length)) def generate_unique_code(db, length=CODE_LENGTH, max_attempts=10): for _ in range(max_attempts): code = generate_code(length) existing = db.execute( "SELECT 1 FROM urls WHERE code = ?", (code,) ).fetchone() if not existing: return code # Astronomically unlikely with a 6-character alphanumeric code, but if # every attempt collides, widen the code instead of failing silently. return generate_unique_code(db, length=length + 1, max_attempts=max_attempts) # --------------------------------------------------------------------------- # Rate limiting (simple in-memory sliding window, keyed by IP) # --------------------------------------------------------------------------- _request_log = {} def is_rate_limited(ip): now = time.time() window_start = now - RATE_LIMIT_WINDOW timestamps = [t for t in _request_log.get(ip, []) if t > window_start] timestamps.append(now) _request_log[ip] = timestamps return len(timestamps) > RATE_LIMIT_MAX # --------------------------------------------------------------------------- # Templates (kept inline so the whole app still lives in one file) # --------------------------------------------------------------------------- PAGE = """ <!doctype html> <html> <head> <meta charset="utf-8"> <title>ShortyURL</title> <style> body { font-family: sans-serif; max-width: 560px; margin: 4rem auto; color: #222; } input[type=url] { width: 70%; padding: .5rem; } button { padding: .5rem 1rem; } .result { margin-top: 1.5rem; padding: 1rem; background: #f4f4f4; border-radius: 6px; } .error { color: #b00020; margin-top: 1rem; } code { background: #eee; padding: .1rem .3rem; border-radius: 4px; } </style> </head> <body> <h1>ShortyURL</h1> <p>Paste a long URL, get a short one back.</p> <form method="post" action="/shorten"> <input type="url" name="url" placeholder="https://example.com/a-very-long-url" required> <button type="submit">Shorten</button> </form> % if short_url: <div class="result"> Short link: <a href="{{short_url}}">{{short_url}}</a> </div> % end % if error: <div class="error">{{error}}</div> % end </body> </html> """ # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @route("/") def home(): return template(PAGE, short_url=None, error=None) @route("/shorten", method="POST") def shorten(): ip = request.remote_addr if is_rate_limited(ip): wants_json = "application/json" in request.headers.get("Accept", "") if wants_json: response.status = 429 return {"error": "Too many requests. Try again shortly."} response.status = 429 return template(PAGE, short_url=None, error="Too many requests. Try again in a minute.") url = request.forms.get("url", "").strip() wants_json = "application/json" in request.headers.get("Accept", "") if not url: response.status = 400 if wants_json: return {"error": "URL is required"} return template(PAGE, short_url=None, error="URL is required.") if not is_valid_url(url): response.status = 400 if wants_json: return {"error": "That doesn't look like a valid http(s) URL"} return template(PAGE, short_url=None, error="That doesn't look like a valid http(s) URL.") with get_db() as db: code = generate_unique_code(db) db.execute( "INSERT INTO urls (code, url) VALUES (?, ?)", (code, url) ) db.commit() short_url = f"{BASE_URL}/{code}" if wants_json: return {"code": code, "short_url": short_url} return template(PAGE, short_url=short_url, error=None) @route("/<code>") def visit(code): # Keep this route from swallowing static-ish paths like favicon requests. if not re.fullmatch(r"[A-Za-z0-9]+", code): raise HTTPError(404, "Short URL not found") with get_db() as db: result = db.execute( "SELECT url FROM urls WHERE code = ?", (code,) ).fetchone() if not result: raise HTTPError(404, "Short URL not found") redirect(result["url"]) # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": init_db() run(host=HOST, port=PORT, debug=os.environ.get("SHORTY_DEBUG") == "1") Save that as app.py, then: pip install bottle python app.py Open http://localhost:8080 in a browser. You'll get a form โ paste a long URL in, hit "Shorten," and it hands back a working short link on the same page. Click it, and it redirects you straight to the original URL. If you'd rather drive it from the command line or a script, send Accept: application/json and you get JSON back instead of HTML: curl -s -H "Accept: application/json" \ -d "url=https://example.com/a-very-long-url" \ http://localhost:8080/shorten {"code": "a8F2xQ", "short_url": "http://localhost:8080/a8F2xQ"} Then visiting http://localhost:8080/a8F2xQ redirects you to the original URL. Feed it garbage instead of a URL and it tells you so instead of silently storing it: curl -s -X POST -d "url=hello" http://localhost:8080/shorten # {"error": "That doesn't look like a valid http(s) URL"} Everything about where it runs is configurable through environment variables, no code edits required: Variable Default What it controls SHORTY_DB urls.db Path to the SQLite database file SHORTY_HOST localhost Host the server binds to SHORTY_PORT 8080 Port the server binds to SHORTY_BASE_URL http://<host>:<port> Base used when building short links SHORTY_CODE_LENGTH 6 Starting length of generated codes SHORTY_RATE_LIMIT_MAX 10 Requests per IP per window before a 429 SHORTY_RATE_LIMIT_WINDOW 60 Window length, in seconds SHORTY_DEBUG unset Set to 1 to run Bottle's debug mode For example, to run it on a different port with a shorter rate-limit window while testing: SHORTY_PORT=9000 SHORTY_RATE_LIMIT_MAX=3 SHORTY_RATE_LIMIT_WINDOW=30 python app.py Even after all that, this still isn't something you'd point the whole internet at without a second look. Worth naming honestly: Database management. Opening a fresh SQLite connection per request is fine for the traffic a tool like this gets. A busier app would want a connection pool or a proper ORM. Authentication. There's no concept of ownership here โ anyone can shorten anything, and nobody can log in to manage or delete their own links. Deployment. Bottle's built-in server (run()) is a development server. Behind real traffic, you'd put it behind a production WSGI server like gunicorn or waitress, and probably a reverse proxy in front of that. Multi-process rate limiting. The in-memory limiter works for a single process. Scale to multiple workers and each one keeps its own counters โ you'd want Redis or a similar shared store. None of that is a knock on Bottle. It's just the honest line between "a small, working tool" and "a service." Is Bottle still a good framework? Yes. After working through this project, Bottle's biggest strength becomes obvious: it gets out of your way. But it has its trade-offs too. The same simplicity that makes Bottle attractive can become its biggest weakness. Once an application grows, you're responsible for a lot of architectural decisions: Where does authentication live? How do I structure services? Where do models go? How should validation work? How do I organize a large application? What third-party packages should I use? A larger framework answers many of those questions for you. Bottle mostly says: That's your problem. For experienced developers, that's sometimes liberating. For beginners building their first serious application, it can be overwhelming. This is the question I actually wanted to answer. After building something real with it, I wouldn't call Bottle useless or dead. I'd just call it specialized. If I'm building a large platform with complex authentication, many models, an admin system, background jobs, multiple applications, or a large team โ I'd reach for something more batteries-included, like Django or Flask. But if I need a tiny API, a webhook service, a prototype, a small internal tool, a lightweight microservice, a teaching project, or a quick HTTP server โ Bottle suddenly makes a lot of sense. And that's perhaps why it feels forgotten. Not because the framework stopped being useful โ the ecosystem simply moved its attention elsewhere. Bottle reminded me of something that's easy to forget when working with modern frameworks: more features don't automatically mean a better framework. Sometimes the best tool is the one that gives you exactly what you need and then gets out of your way. Bottle is small, simple, and doesn't try to solve every possible web-development problem. And that's precisely why it can still be fun to use. So maybe Bottle isn't really a forgotten framework after all. Maybe it's just been sitting quietly in the corner while everyone else argues about which modern Python framework is the fastest. And honestly? I think Bottle is still worth remembering. Bottle isn't the only Python web framework that's been overshadowed by today's popular choices. Tornado is also a victim of these powerful newer frameworks. Where Bottle says: Keep it small. Tornado says: Let's talk about asynchronous networking. That's it for today. Do you think Bottle needs a comeback in the modern era? And Why? Your opinions down in the comments. Well, C'ya in the next one
Key Takeaways
- โขIn an era where bootstrapping a basic web application often requires downloading a huge framework or navigating complex dependency trees, there's a quiet, radical alternative in the Python ecosystem
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



