Beginner · Lesson 44 of 94

Databases with sqlite3

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

A database is where data goes when more than one part of a program, or more than one program, needs to read and change it reliably. Files of JSON work until you need to find one record among a million, update it without rewriting the file, or let two processes write at once. SQLite is a complete relational database that lives in a single file, ships inside Python as the sqlite3 module, and is used in production more than any other database in the world, because it is inside every phone and browser.

Databases with sqlite3: Diagram 1
Databases with sqlite3 · Figure 1 · Open for full size

Tables are the unit of storage. A table has named columns with types, and each row is one record; it is the CSV shape with rules attached. SQL is the language you talk to it in, and four statements do most of the work: CREATE TABLE defines the shape, INSERT adds rows, SELECT reads them with WHERE to filter and ORDER BY to sort, and UPDATE and DELETE change and remove. SQL keywords are conventionally written in upper case so they stand out from your own names.

Statement Job Python call
CREATE TABLE define columns and types conn.execute("CREATE TABLE ...")
INSERT add a row conn.execute("INSERT ... VALUES (?, ?)", (a, b))
SELECT read rows, filter, sort conn.execute("SELECT ... WHERE x = ?", (v,)).fetchall()
UPDATE change matching rows conn.execute("UPDATE ... SET ... WHERE ...", (...))
DELETE remove matching rows conn.execute("DELETE FROM ... WHERE ...", (...))
COMMIT make changes permanent conn.commit() or a with conn: block

The rule that matters most is how values reach the query. Never build SQL with f-strings or concatenation from anything a user typed. f"... WHERE name = '{name}'" lets a name like x' OR '1'='1 rewrite the query, which is SQL injection, the oldest and still most common web vulnerability. Instead, put a ? where each value goes and pass the values as a tuple: the database driver inserts them safely, and it is also faster because the query text is reused.

Changes are grouped into transactions. Nothing you insert or update is permanent until commit(); if something fails halfway, rollback() discards the partial work, so the database never holds half of an operation. Using the connection as a context manager, with conn:, commits on success and rolls back on an exception. Closing the connection when you are done releases the file.

Databases with sqlite3: INSERT order → UPDATE stock → commit: both saved → rollback: neither saved
Databases with sqlite3 · Figure 2 · Open for full size

When is a database the right tool rather than JSON or CSV? When you need to look records up by a field quickly, change one record without touching the rest, keep related tables consistent, or have several readers and writers. When the data is small, read once, and written by one script, a JSON file is simpler and fine. SQLite sits between: one file, no server, but full SQL, which makes it the right first database for almost every project.

Code

Create a database and a table, insert rows with parameters, and read them back. An in-memory database is used so the example leaves no file; replace ":memory:" with a filename to persist.

# create_insert.py
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("""
    CREATE TABLE expenses (
        id INTEGER PRIMARY KEY,
        description TEXT NOT NULL,
        amount REAL NOT NULL,
        day TEXT NOT NULL
    )
""")

rows = [("rent", 950.0, "2026-09-01"), ("coffee", 3.5, "2026-09-02"), ("internet", 40.0, "2026-09-03")]
conn.executemany("INSERT INTO expenses (description, amount, day) VALUES (?, ?, ?)", rows)
conn.commit()

for row in conn.execute("SELECT id, description, amount FROM expenses ORDER BY amount DESC"):
    print(row)
print(conn.execute("SELECT COUNT(*), ROUND(SUM(amount), 2) FROM expenses").fetchone())
conn.close()
Output
(1, 'rent', 950.0)
(3, 'internet', 40.0)
(2, 'coffee', 3.5)
(3, 993.5)

Filtering with WHERE, rows as dictionaries via row_factory, and update and delete inside a with conn: transaction.

# query_update.py
import sqlite3

conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE expenses (id INTEGER PRIMARY KEY, description TEXT, amount REAL, day TEXT)")
conn.executemany("INSERT INTO expenses (description, amount, day) VALUES (?, ?, ?)",
                 [("rent", 950.0, "2026-09-01"), ("coffee", 3.5, "2026-09-02"), ("internet", 40.0, "2026-09-03")])

big = conn.execute("SELECT description, amount FROM expenses WHERE amount > ? ORDER BY day", (10,)).fetchall()
print([(r["description"], r["amount"]) for r in big])

with conn:
    conn.execute("UPDATE expenses SET amount = ? WHERE description = ?", (45.0, "internet"))
    conn.execute("DELETE FROM expenses WHERE description = ?", ("coffee",))

for r in conn.execute("SELECT description, amount FROM expenses ORDER BY id"):
    print(dict(r))
Output
[('rent', 950.0), ('internet', 40.0)]
{'description': 'rent', 'amount': 950.0}
{'description': 'internet', 'amount': 45.0}

Why parameters, demonstrated. The f-string version lets a crafted input return every row; the parameter version treats the same input as a plain, harmless string.

# injection.py
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, secret TEXT)")
conn.executemany("INSERT INTO users VALUES (?, ?)", [("ada", "s3cret"), ("linus", "hunter2")])

user_input = "nobody' OR '1'='1"

unsafe = conn.execute(f"SELECT name FROM users WHERE name = '{user_input}'").fetchall()
print("f-string:", unsafe)

safe = conn.execute("SELECT name FROM users WHERE name = ?", (user_input,)).fetchall()
print("parameter:", safe)
Output
f-string: [('ada',), ('linus',)]
parameter: []

A transaction that fails halfway rolls back, so the database is left exactly as it was. The CHECK constraint makes the second statement fail on purpose.

# transaction.py
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE stock (item TEXT PRIMARY KEY, qty INTEGER CHECK (qty >= 0))")
conn.execute("CREATE TABLE orders (item TEXT, qty INTEGER)")
conn.execute("INSERT INTO stock VALUES ('pen', 2)")
conn.commit()

try:
    with conn:
        conn.execute("INSERT INTO orders VALUES ('pen', 5)")
        conn.execute("UPDATE stock SET qty = qty - 5 WHERE item = 'pen'")
except sqlite3.IntegrityError as err:
    print("rolled back:", err)

print(conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0], "orders")
print(conn.execute("SELECT qty FROM stock WHERE item = 'pen'").fetchone()[0], "pens in stock")
Output
rolled back: CHECK constraint failed: qty >= 0
0 orders
2 pens in stock

Try it yourself

Create notes_db.py that connects to notes.db on disk, creates a notes table if it does not exist, and offers functions add(title, body), search(word) using WHERE body LIKE ? with f"%{word}%" as the parameter value, and delete(note_id). Add three notes, search for a word, delete one, and print the remaining count. Run the script twice and notice the notes persist between runs.

Common mistakes

What's next

You can store and query data properly. Next you will pick up the standard library's specialized containers and iteration tools, collections and itertools, which shorten a surprising amount of everyday code.

Course overview