Level 5 · In the Wild

Databases with SQLite 🗄️

A real SQL database ships with Python, lives in a single file, and needs no server. It is the most under-used tool in the standard library.

When a file is not enough

Reach for a database when you need any of:

SQLite gives you all of that in a file, with no server to install or administer. It is in your phone, your browser, and on most aircraft. The official docs claim it is the most widely deployed database engine in the world, and that is probably true.

Creating and inserting

import sqlite3

conn = sqlite3.connect("ship.db")
conn.execute("""
    CREATE TABLE IF NOT EXISTS crew (
        id       INTEGER PRIMARY KEY,
        name     TEXT NOT NULL,
        role     TEXT NOT NULL DEFAULT 'deckhand',
        pay      INTEGER NOT NULL CHECK (pay >= 0),
        joined   TEXT NOT NULL
    )
""")

conn.execute(
    "INSERT INTO crew (name, role, pay, joined) VALUES (?, ?, ?, ?)",
    ("Guybrush", "captain", 100, "1990-10-15"),
)
conn.executemany(
    "INSERT INTO crew (name, role, pay, joined) VALUES (?, ?, ?, ?)",
    [
        ("Elaine", "governor", 250, "1990-10-15"),
        ("Otis", "lookout", 40, "1991-01-03"),
        ("Meathook", "lookout", 45, "1991-06-20"),
    ],
)
conn.commit()

print(conn.execute("SELECT COUNT(*) FROM crew").fetchone()[0], "crew")
conn.close()
4 crew
💉 The question marks are not optional

Never build SQL with f-strings. f"... WHERE name = '{{name}}'" with name set to ' OR 1=1 -- returns your whole table, and worse inputs can drop it. Passing values as a separate tuple means the database treats them strictly as data, never as code. This is the single most famous vulnerability in web software and it is entirely preventable by typing ?.

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT)")
conn.execute("INSERT INTO users VALUES ('Guybrush')")
conn.execute("INSERT INTO users VALUES ('Elaine')")

attack = "' OR '1'='1"

# The safe way: the input is data, so it simply matches nothing
safe = conn.execute("SELECT * FROM users WHERE name = ?", (attack,)).fetchall()
print("parameterised:", safe)

# The unsafe way, shown once so you recognise it
unsafe = conn.execute(f"SELECT * FROM users WHERE name = '{attack}'").fetchall()
print("f-string:     ", unsafe)
parameterised: []
f-string:      [('Guybrush',), ('Elaine',)]

The second query returned every row, because the input closed the quote and added its own condition. That is SQL injection, demonstrated in four lines.

Querying

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE crew (id INTEGER PRIMARY KEY, name TEXT, role TEXT, pay INTEGER)")
conn.executemany("INSERT INTO crew (name, role, pay) VALUES (?, ?, ?)", [
    ("Guybrush", "captain", 100),
    ("Elaine", "governor", 250),
    ("Otis", "lookout", 40),
    ("Meathook", "lookout", 45),
])

for row in conn.execute("SELECT name, pay FROM crew WHERE pay > ? ORDER BY pay DESC", (50,)):
    print(f"{row[0]:10} {row[1]}")

print("---")
print(conn.execute("SELECT COUNT(*), SUM(pay), AVG(pay) FROM crew").fetchone())

print("---")
for role, count, total in conn.execute(
    "SELECT role, COUNT(*), SUM(pay) FROM crew GROUP BY role ORDER BY SUM(pay) DESC"
):
    print(f"{role:10} {count} people, {total} total")
Elaine     250
Guybrush   100
---
(4, 435, 108.75)
---
governor   1 people, 250 total
captain    1 people, 100 total
lookout    2 people, 85 total

Rows as dictionaries, which is far nicer

import sqlite3

conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row          # the one line worth remembering
conn.execute("CREATE TABLE crew (name TEXT, role TEXT, pay INTEGER)")
conn.execute("INSERT INTO crew VALUES ('Guybrush', 'captain', 100)")

row = conn.execute("SELECT * FROM crew").fetchone()

print(row["name"], row["pay"])
print(dict(row))
print(row.keys())
Guybrush 100
{'name': 'Guybrush', 'role': 'captain', 'pay': 100}
['name', 'role', 'pay']

Relationships: the actual point of SQL

import sqlite3

conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")      # SQLite needs asking, every connection

conn.executescript("""
    CREATE TABLE ships (
        id   INTEGER PRIMARY KEY,
        name TEXT NOT NULL UNIQUE
    );
    CREATE TABLE crew (
        id      INTEGER PRIMARY KEY,
        name    TEXT NOT NULL,
        ship_id INTEGER REFERENCES ships(id) ON DELETE CASCADE
    );
    INSERT INTO ships (name) VALUES ('Sea Monkey'), ('Flying Dutchman');
    INSERT INTO crew (name, ship_id) VALUES
        ('Guybrush', 1), ('Otis', 1), ('LeChuck', 2);
""")

for row in conn.execute("""
    SELECT ships.name AS ship, COUNT(crew.id) AS crew_count
    FROM ships
    LEFT JOIN crew ON crew.ship_id = ships.id
    GROUP BY ships.id
    ORDER BY crew_count DESC
"""):
    print(f"{row['ship']:16} {row['crew_count']} crew")

print("---")
for row in conn.execute("""
    SELECT crew.name, ships.name AS ship
    FROM crew JOIN ships ON crew.ship_id = ships.id
    WHERE ships.name = ?
""", ("Sea Monkey",)):
    print(f"{row['name']} sails the {row['ship']}")
Sea Monkey       2 crew
Flying Dutchman  1 crew
---
Guybrush sails the Sea Monkey
Otis sails the Sea Monkey

A JOIN answers a question that would otherwise be two loops and a dictionary in Python, and the database does it with an index instead of scanning. This is what SQL is for, and it is why "just use a JSON file" stops working around a few thousand records.

Transactions: all or nothing

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER)")
conn.executemany("INSERT INTO accounts VALUES (?, ?)",
                 [("Guybrush", 100), ("Elaine", 250)])
conn.commit()


def transfer(conn, sender, recipient, amount):
    """Move money. Either both sides happen or neither does."""
    try:
        with conn:          # commits on success, rolls back on any exception
            balance = conn.execute(
                "SELECT balance FROM accounts WHERE name = ?", (sender,)
            ).fetchone()[0]
            if balance < amount:
                raise ValueError(f"{sender} has only {balance}")
            conn.execute("UPDATE accounts SET balance = balance - ? WHERE name = ?",
                         (amount, sender))
            conn.execute("UPDATE accounts SET balance = balance + ? WHERE name = ?",
                         (amount, recipient))
        return "ok"
    except ValueError as err:
        return f"refused: {err}"


print(transfer(conn, "Guybrush", "Elaine", 50))
print(transfer(conn, "Guybrush", "Elaine", 500))
print(dict(conn.execute("SELECT name, balance FROM accounts ORDER BY name").fetchall()))
ok
refused: Guybrush has only 50
{'Elaine': 300, 'Guybrush': 50}

with conn: is a transaction. If anything inside raises, every change is undone. Without it, a crash between the two UPDATE statements would destroy money, which is the classic illustration of why databases have transactions at all.

Indexes: the difference between instant and unusable

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, user_id INTEGER, action TEXT)")
conn.executemany("INSERT INTO events (user_id, action) VALUES (?, ?)",
                 [(i % 100, "click") for i in range(20000)])

plan_before = conn.execute(
    "EXPLAIN QUERY PLAN SELECT * FROM events WHERE user_id = 42"
).fetchone()[-1]

conn.execute("CREATE INDEX idx_events_user ON events(user_id)")

plan_after = conn.execute(
    "EXPLAIN QUERY PLAN SELECT * FROM events WHERE user_id = 42"
).fetchone()[-1]

print("before:", plan_before)
print("after: ", plan_after)
before: SCAN events
after:  SEARCH events USING INDEX idx_events_user (user_id=?)

SCAN means it read every row. SEARCH USING INDEX means it jumped straight there. On twenty thousand rows the difference is small; on twenty million it is the difference between a page that loads and a page that times out. Index the columns you filter and join on, and EXPLAIN QUERY PLAN tells you whether it worked.

Beyond SQLite

ToolUse when
SQLiteOne machine, one writer at a time, up to many gigabytes. Most personal projects, forever
PostgreSQLSeveral writers, a network, real users. The default serious choice
MySQL / MariaDBSimilar; often what a host gives you
An ORM (SQLAlchemy, Django, SQLModel)You want Python objects instead of SQL strings, and migrations
RedisCaching and ephemeral data, not durable storage
🧭 On ORMs

An object-relational mapper lets you write session.query(Crew).filter(Crew.pay > 50) instead of SQL. They are genuinely useful for large applications and they hide what the database is doing, which is fine until it is not. Learn enough SQL to read what your ORM generates: the SQLAlchemy docs are excellent, and every serious backend job expects SQL literacy.

Exercise 1

Build a small library database

Two tables, authors and books, with a foreign key. Insert a few rows and produce a report of each author with their book count.

Reveal solution
import sqlite3

conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")

conn.executescript("""
    CREATE TABLE authors (
        id   INTEGER PRIMARY KEY,
        name TEXT NOT NULL UNIQUE
    );
    CREATE TABLE books (
        id        INTEGER PRIMARY KEY,
        title     TEXT NOT NULL,
        year      INTEGER,
        author_id INTEGER NOT NULL REFERENCES authors(id)
    );
""")

conn.executemany("INSERT INTO authors (name) VALUES (?)",
                 [("Herbert",), ("Gibson",), ("Unpublished",)])
conn.executemany("INSERT INTO books (title, year, author_id) VALUES (?, ?, ?)", [
    ("Dune", 1965, 1),
    ("Dune Messiah", 1969, 1),
    ("Neuromancer", 1984, 2),
])
conn.commit()

for row in conn.execute("""
    SELECT authors.name, COUNT(books.id) AS n, MIN(books.year) AS first
    FROM authors
    LEFT JOIN books ON books.author_id = authors.id
    GROUP BY authors.id
    ORDER BY n DESC, authors.name
"""):
    first = row["first"] or "-"
    print(f"{row['name']:12} {row['n']} books, first {first}")
Herbert      2 books, first 1965
Gibson       1 books, first 1984
Unpublished  0 books, first -

The LEFT JOIN is what includes the author with no books. A plain JOIN would silently drop them, which is one of the most common reporting bugs there is.

Exercise 2

Fix the injection

Rewrite this safely, and explain what an attacker could do with it.

def find_user(conn, username):
    return conn.execute(
        f"SELECT * FROM users WHERE username = '{username}'"
    ).fetchall()
Reveal solution
def find_user(conn, username):
    """Find a user by name. The value is passed as data, never as SQL."""
    return conn.execute(
        "SELECT * FROM users WHERE username = ?", (username,)
    ).fetchall()

With the original, username set to ' OR '1'='1 returns every user. Set to '; DROP TABLE users; -- it would try to delete the table (SQLite's execute blocks multiple statements, which is a lucky accident rather than a defence; most database drivers do not).

The habit to build: if a value came from outside your program, it goes in the tuple, never in the string. There is no exception to this rule.

Exercise 3

When would you not use SQLite?

Name three situations where SQLite is the wrong choice, and what you would use instead.

Reveal solution
  1. Several servers writing at once. SQLite allows one writer at a time and the file must be on local disk; on a network filesystem the locking is unreliable. Use PostgreSQL.
  2. You need per-user access control inside the database. SQLite has no users or permissions: anyone who can read the file has everything.
  3. Very high concurrent write throughput, such as thousands of writes a second from many clients. WAL mode helps a lot, but this is what client-server databases are built for.

What is not a good reason: 'it is only a toy database'. It handles hundreds of gigabytes, it is used in production by enormous companies, and for a single-machine application it is often the better engineering choice, because there is no server to secure, back up or keep running.

+100 XP