ZetCode

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.

simple.py
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.

json_response.py
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:

dynamic_routes.py
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').

get_request.py
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.

public/index.html
<!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.

static_files.py
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.

post_form.py
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.

post_json.py
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.

errors.py
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.

create_db.py
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.

views/show_cars.tpl
<!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.

database.py
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.

hooks.py
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

Python Bottle documentation

In this article we have used Bottle to create simple web applications in Python.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.