Python Bottle
last modified October 30, 2026 Python 3.15
In this article we show how to create simple web applications in Python with the Bottle micro web framework.
Bottle
Bottle is a fast, simple, and lightweight WSGI micro web framework for Python. It is distributed as a single file module and has no dependencies other than the Python standard library. It includes routing, a template engine, utilities for forms, files, cookies, and headers, and a built-in development server.
The Web Server Gateway Interface (WSGI) is a calling convention for web servers to forward requests to web applications written in Python. A Bottle application can run on any WSGI server, such as Gunicorn or Waitress, in production.
Bottle is installed with the following command. The examples use Bottle 0.13.
$ pip install bottle
Simple example
In the following example, we create a simple Bottle application.
from bottle import route, run
@route('/message')
def hello():
return 'Today is a beautiful day\n'
run(host='localhost', port=8080, debug=True)
The application sends a message to the client.
from bottle import route, run
We import the route decorator and the run
function. The route decorator binds a function to a URL path.
The run function starts the development server.
@route('/message')
def hello():
return 'Today is a beautiful day\n'
A route maps a URL to a function. The returned string becomes the body of the response.
run(host='localhost', port=8080, debug=True)
We start the server on port 8080 in debug mode, which shows detailed error pages. Debug mode must not be used in production.
We start the server in the background and send requests with
curl. The server prints a start-up message and one log line per
request. A path without a route returns 404.
$ python simple.py &
Bottle v0.13.4 server starting up (using WSGIRefServer())...
Listening on http://localhost:8080/
Hit Ctrl-C to quit.
$ curl localhost:8080/message
Today is a beautiful day
127.0.0.1 - - [30/Oct/2026 20:10:24] "GET /message HTTP/1.1" 200 25
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/nothing
404
127.0.0.1 - - [30/Oct/2026 20:10:24] "GET /nothing HTTP/1.1" 404 740
JSON responses
Web applications often send responses in JSON format. Bottle automatically converts returned dictionaries to JSON.
from bottle import route, run, response
import json
cars = [
{'name': 'Audi', 'price': 52642},
{'name': 'Mercedes', 'price': 57127},
{'name': 'Skoda', 'price': 9000},
{'name': 'Volvo', 'price': 29000},
]
@route('/cars')
def get_cars():
return {'data': cars}
@route('/cars/list')
def get_list():
response.content_type = 'application/json'
return json.dumps(cars)
run(host='localhost', port=8080, quiet=True)
The application sends data about cars to the client.
@route('/cars')
def get_cars():
return {'data': cars}
A returned dictionary is serialized to JSON and the
Content-Type header is set to application/json.
@route('/cars/list')
def get_list():
response.content_type = 'application/json'
return json.dumps(cars)
Lists are not converted automatically, because Bottle would treat them as an
iterable of body chunks. We serialize the list ourselves and set the content
type on the global response object.
The quiet=True option of run turns off the
start-up message and the request log.
$ python json_response.py &
$ curl -i localhost:8080/cars
HTTP/1.0 200 OK
Date: Fri, 30 Oct 2026 19:10:25 GMT
Server: WSGIServer CPython/3.15.2
Content-Type: application/json
Content-Length: 151
{"data": [{"name": "Audi", "price": 52642}, {"name": "Mercedes", "price": 57127}, {"name": "Skoda", "price": 9000}, {"name": "Volvo", "price": 29000}]}
$ curl localhost:8080/cars/list
[{"name": "Audi", "price": 52642}, {"name": "Mercedes", "price": 57127}, {"name": "Skoda", "price": 9000}, {"name": "Volvo", "price": 29000}]
Dynamic routes and filters
Routes that contain wildcards are called dynamic routes. A wildcard is a name
in angle brackets, such as <name>, which matches one or
more characters up to the next slash. The matched value is passed to the
function as an argument.
Filters restrict and convert wildcards:
:intmatches signed digits and converts them to an integer:floatmatches a decimal number and converts it to a float:pathmatches characters including slashes:rematches a custom regular expression
from bottle import route, run
@route('/users/<name>')
def user(name):
return f'User: {name}\n'
@route('/items/<item_id:int>')
def item(item_id):
return f'Item {item_id} ({type(item_id).__name__})\n'
@route('/price/<value:float>')
def price(value):
return f'Price with VAT: {value * 1.23:.2f}\n'
@route('/files/<filepath:path>')
def files(filepath):
return f'File path: {filepath}\n'
@route('/codes/<code:re:[A-Z]{3}>')
def code(code):
return f'Currency code: {code}\n'
run(host='localhost', port=8080, quiet=True)
The example uses all four filters.
@route('/items/<item_id:int>')
def item(item_id):
The item_id argument is an integer. A path such as
/items/abc does not match the route and returns 404.
@route('/codes/<code:re:[A-Z]{3}>')
The regex filter accepts three uppercase letters only.
$ python dynamic_routes.py &
$ curl localhost:8080/users/john
User: john
$ curl localhost:8080/items/42
Item 42 (int)
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/items/abc
404
$ curl localhost:8080/price/10.5
Price with VAT: 12.91
$ curl localhost:8080/files/docs/2026/report.pdf
File path: docs/2026/report.pdf
$ curl localhost:8080/codes/EUR
Currency code: EUR
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/codes/eur
404
Query strings
The HTTP GET method requests a representation of a resource. Parameters are
sent in the query string, which Bottle parses into
request.query. The @get decorator is a shortcut for
@route(..., method='GET').
from bottle import get, run, request
@get('/msg')
def message():
name = request.query.name or 'guest'
age = request.query.get('age', type=int)
tags = request.query.getall('tag')
msg = f'{name} is {age} years old' if age else f'Hello {name}'
return f'{msg}; tags: {tags}\n'
run(host='localhost', port=8080, quiet=True)
The application builds a message from the query parameters.
name = request.query.name or 'guest'
Attribute access returns the value or an empty string if the parameter is missing. It also decodes the value as UTF-8.
age = request.query.get('age', type=int)
tags = request.query.getall('tag')
The get method can convert the value; if the conversion fails,
it returns the default None. The getall method
returns all values of a repeated parameter.
The -G option of curl sends the URL-encoded data in
the query string.
$ python get_request.py & $ curl 'localhost:8080/msg?name=Peter&age=34' Peter is 34 years old; tags: [] $ curl 'localhost:8080/msg?tag=a&tag=b' Hello guest; tags: ['a', 'b'] $ curl 'localhost:8080/msg?name=Lucy&age=old' Hello Lucy; tags: [] $ curl -G localhost:8080/msg --data-urlencode 'name=Zuzana Nováková' Hello Zuzana Nováková; tags: []
Static files
The static_file function serves files from a directory. It sets
the content type, the size, and caching headers, and it refuses paths that
lead outside the root directory.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Home page</title>
</head>
<body>
<form method="post" action="/doform">
<label for="name">Name:</label>
<input type="text" id="name" name="name">
<label for="occupation">Occupation:</label>
<input type="text" id="occupation" name="occupation">
<button type="submit">Submit</button>
</form>
</body>
</html>
This is the home page in the public directory. The directory also
contains a style.css file.
from bottle import route, run, static_file
@route('/')
def index():
return static_file('index.html', root='./public')
@route('/static/<filepath:path>')
def server_static(filepath):
return static_file(filepath, root='./public')
run(host='localhost', port=8080, quiet=True)
We serve the home page and all files under /static/.
@route('/static/<filepath:path>')
def server_static(filepath):
return static_file(filepath, root='./public')
The path filter allows subdirectories. The root
parameter is the directory where the files are looked up.
A missing file returns 404. An attempt to escape the root directory with
.. returns 403 Forbidden.
$ python static_files.py &
$ curl -s localhost:8080/ | head -4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
$ curl -I localhost:8080/static/style.css
HTTP/1.0 200 OK
Server: WSGIServer CPython/3.15.2
Content-Type: text/css; charset=UTF-8
Content-Length: 34
Last-Modified: Fri, 30 Oct 2026 19:09:13 GMT
Date: Fri, 30 Oct 2026 19:10:27 GMT
Etag: 6f433145817c7c69616dfc2fe0134775bfc11dba
Accept-Ranges: bytes
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/static/missing.css
404
$ curl -s -o /dev/null -w '%{http_code}\n' --path-as-is localhost:8080/static/../static_files.py
403
Forms
Form data sent with the POST method are available in
request.forms. The home page from the previous example contains
a form with name and occupation fields.
from bottle import route, post, run, request, static_file
@route('/')
def index():
return static_file('index.html', root='./public')
@post('/doform')
def process():
name = request.forms.name
occupation = request.forms.occupation
return f'Your name is {name} and you are a(n) {occupation}\n'
run(host='localhost', port=8080, quiet=True)
We serve the form and process the submitted data.
@post('/doform')
def process():
name = request.forms.name
occupation = request.forms.occupation
The @post decorator maps the function to POST requests. As with
the query string, attribute access returns decoded strings.
We submit the form data with curl -d, the way a browser does.
$ python post_form.py & $ curl -d 'name=Peter&occupation=teacher' localhost:8080/doform Your name is Peter and you are a(n) teacher $ curl --data-urlencode 'name=Ján' --data-urlencode 'occupation=programátor' localhost:8080/doform Your name is Ján and you are a(n) programátor
JSON requests
REST APIs receive JSON in the request body. Bottle parses it into
request.json when the Content-Type is
application/json.
from bottle import post, run, request, response
users = []
@post('/users')
def create_user():
data = request.json
if not data or 'name' not in data:
response.status = 400
return {'error': 'name is required'}
user = {'id': len(users) + 1, 'name': data['name']}
users.append(user)
response.status = 201
return user
run(host='localhost', port=8080, quiet=True)
The application creates users.
if not data or 'name' not in data:
response.status = 400
return {'error': 'name is required'}
We validate the input and return 400 Bad Request with an error message when the name is missing.
response.status = 201 return user
A successful request returns 201 Created and the new user.
$ python post_json.py &
$ curl -i -H 'Content-Type: application/json' -d '{"name": "John Doe"}' localhost:8080/users
HTTP/1.0 201 Created
Date: Fri, 30 Oct 2026 19:10:28 GMT
Server: WSGIServer CPython/3.15.2
Content-Type: application/json
Content-Length: 29
{"id": 1, "name": "John Doe"}
$ curl -H 'Content-Type: application/json' -d '{"email": "x@example.com"}' localhost:8080/users
{"error": "name is required"}
Errors and redirects
The abort function stops a request with an HTTP error, and
redirect sends the client to another URL. The
@error decorator defines a custom error page.
from bottle import route, run, error, abort, redirect
users = {1: 'John Doe', 2: 'Roger Roe'}
@route('/users/<uid:int>')
def get_user(uid):
if uid not in users:
abort(404, f'No user with id {uid}')
return f'{users[uid]}\n'
@route('/old-users/<uid:int>')
def old_user(uid):
redirect(f'/users/{uid}')
@error(404)
def error404(err):
return f'404 - {err.body}\n'
run(host='localhost', port=8080, quiet=True)
We return users by id and redirect old URLs to new ones.
if uid not in users:
abort(404, f'No user with id {uid}')
The abort function raises an HTTPError, so no
return is needed.
@error(404)
def error404(err):
return f'404 - {err.body}\n'
The handler replaces Bottle's default HTML error page. The
body attribute holds the message from abort, or a
default message for paths without a route.
redirect(f'/users/{uid}')
For HTTP/1.1 clients, redirect uses the 303 See Other status.
The -L option makes curl follow the redirect.
$ python errors.py & $ curl localhost:8080/users/1 John Doe $ curl localhost:8080/users/7 404 - No user with id 7 $ curl localhost:8080/nowhere 404 - Not found: '/nowhere' $ curl -i localhost:8080/old-users/2 HTTP/1.0 303 See Other Date: Fri, 30 Oct 2026 19:10:29 GMT Server: WSGIServer CPython/3.15.2 Location: http://localhost:8080/users/2 Content-Length: 0 Content-Type: text/html; charset=UTF-8 $ curl -L localhost:8080/old-users/2 Roger Roe
Templates and a database
Bottle has a built-in template engine called SimpleTemplate. Templates are
looked up in the views directory. Python code is embedded in
lines that start with %, and expressions are written in
{{ }}; their values are HTML-escaped.
import sqlite3
cars = [('Audi', 52642), ('Mercedes', 57127), ('Skoda', 9000),
('Volvo', 29000), ('Bentley', 350000), ('Citroen', 21000)]
with sqlite3.connect('cars.db') as con:
con.execute('DROP TABLE IF EXISTS cars')
con.execute('CREATE TABLE cars(id INTEGER PRIMARY KEY, name TEXT, price INTEGER)')
con.executemany('INSERT INTO cars(name, price) VALUES(?, ?)', cars)
con.close()
print(f'{len(cars)} cars inserted')
The script creates an SQLite database with a table of cars.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{title}}</title>
</head>
<body>
<table>
<tr><th>Name</th><th>Price</th></tr>
% for car in cars:
<tr><td>{{car['name']}}</td><td>{{f"{car['price']:,}"}}</td></tr>
% end
</table>
% if not cars:
<p>No cars found.</p>
% end
</body>
</html>
The template generates a table from the cars list. Blocks are
closed with % end. The price is formatted with thousands
separators.
import sqlite3
from bottle import route, run, request, template
def query(sql, params=()):
with sqlite3.connect('cars.db') as con:
con.row_factory = sqlite3.Row
rows = [dict(row) for row in con.execute(sql, params)]
con.close()
return rows
@route('/api/cars')
def api_cars():
max_price = request.query.get('max', default=10**9, type=int)
cars = query('SELECT name, price FROM cars WHERE price <= ? ORDER BY price',
(max_price,))
return {'count': len(cars), 'cars': cars}
@route('/cars')
def show_cars():
cars = query('SELECT name, price FROM cars ORDER BY name')
return template('show_cars', title='Cars', cars=cars)
run(host='localhost', port=8080, quiet=True)
The application serves the cars as JSON and as an HTML page.
con.row_factory = sqlite3.Row rows = [dict(row) for row in con.execute(sql, params)]
With sqlite3.Row, rows can be converted to dictionaries, which
Bottle turns into JSON and the template accesses by key. The query uses a
placeholder instead of string formatting to prevent SQL injection.
return template('show_cars', title='Cars', cars=cars)
The template function renders views/show_cars.tpl
with the given variables.
$ python create_db.py
6 cars inserted
$ python database.py &
$ curl 'localhost:8080/api/cars?max=25000'
{"count": 2, "cars": [{"name": "Skoda", "price": 9000}, {"name": "Citroen", "price": 21000}]}
$ curl -s localhost:8080/cars | sed -n '8,13p'
<table>
<tr><th>Name</th><th>Price</th></tr>
<tr><td>Audi</td><td>52,642</td></tr>
<tr><td>Bentley</td><td>350,000</td></tr>
<tr><td>Citroen</td><td>21,000</td></tr>
<tr><td>Mercedes</td><td>57,127</td></tr>
Hooks
Hooks run code before or after every request. They are useful for timing,
headers such as CORS, or database connections. The example also uses an
explicit Bottle application object instead of the global default
application.
import time
from bottle import Bottle, request, response
app = Bottle()
@app.hook('before_request')
def start_timer():
request.environ['start'] = time.perf_counter()
@app.hook('after_request')
def add_headers():
elapsed = time.perf_counter() - request.environ['start']
response.set_header('Access-Control-Allow-Origin', '*')
response.set_header('X-Elapsed-Ms', f'{elapsed * 1000:.1f}')
@app.route('/slow')
def slow():
time.sleep(0.2)
return 'done\n'
if __name__ == '__main__':
app.run(host='localhost', port=8080, quiet=True)
We measure the processing time of each request and add headers to the response.
@app.hook('before_request')
def start_timer():
request.environ['start'] = time.perf_counter()
The before_request hook stores the start time in the WSGI
environment of the request.
@app.hook('after_request')
def add_headers():
...
response.set_header('X-Elapsed-Ms', f'{elapsed * 1000:.1f}')
The after_request hook adds a CORS header and the elapsed time
in milliseconds.
$ python hooks.py & $ curl -i localhost:8080/slow HTTP/1.0 200 OK Date: Fri, 30 Oct 2026 19:10:30 GMT Server: WSGIServer CPython/3.15.2 Access-Control-Allow-Origin: * X-Elapsed-Ms: 200.3 Content-Length: 5 Content-Type: text/html; charset=UTF-8 done
Source
In this article we have used Bottle to create simple web applications in Python.
Author
List all Python tutorials.