Warm-up · Activity 1 of 7
Warm-up from the previous lesson: what does this print?
import tomllib
limit = tomllib.loads("limit = 10")["limit"]
print(limit + 1, type(limit).__name__)// A5.3 · ~35 min · Advanced
After this lesson you can query SQLite from Python with ? and named placeholders, insert many rows with executemany, make changes all-or-nothing with transactions, and explain why building SQL from strings invites SQL injection.
You will be able to
Warm-up · Activity 1 of 7
import tomllib
limit = tomllib.loads("limit = 10")["limit"]
print(limit + 1, type(limit).__name__)Predict · Activity 2 of 7
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users(name, secret)")
con.executemany("INSERT INTO users VALUES (?, ?)", [("ada", "a1"), ("bob", "b2")])
name = "nobody' OR '1'='1" # typed into a search box
sql = f"SELECT name FROM users WHERE name = '{name}'" # the mistake
print(con.execute(sql).fetchall())Practice · Activity 3 of 7
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users(name, secret)")
con.executemany("INSERT INTO users VALUES (?, ?)", [("ada", "a1"), ("bob", "b2")])
name = "ada"
rows = con.execute("SELECT secret FROM users WHERE name = ?", ____)
print(rows.fetchall())Practice · Activity 4 of 7
import sqlite3
con = sqlite3.connect(":memory:")
row = con.execute("SELECT :a + :b", {"a": 2, "b": 3}).fetchone()
print(row)Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT UNIQUE)")
try:
with con:
con.execute("INSERT INTO t(name) VALUES (?)", ("a",))
con.execute("INSERT INTO t(name) VALUES (?)", ("a",))
except sqlite3.IntegrityError:
pass
print(con.execute("SELECT count(*) FROM t").fetchone()[0])Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
The program creates a table in memory and inserts three books with executemany inside with con:. by_author() passes the author as a parameter, so an injection attempt is just a name that matches nothing. A named placeholder fills :year. Finally, a transaction with one good and one duplicate book fails, and with con: rolls back both inserts, so the table still holds three books.
main.py
import sqlite3
con = sqlite3.connect(":memory:") # a database that lives in memory only
con.execute("CREATE TABLE book(title TEXT UNIQUE, author TEXT, year INTEGER)")
books = [
("Dune", "Frank Herbert", 1965),
("Emma", "Jane Austen", 1815),
("Persuasion", "Jane Austen", 1817),
]
with con: # commits when the block succeeds
con.executemany("INSERT INTO book VALUES (?, ?, ?)", books)
def by_author(author: str) -> list[str]:
rows = con.execute("SELECT title FROM book WHERE author = ? ORDER BY year", (author,))
return [title for (title,) in rows]
print(by_author("Jane Austen"))
print(by_author("x' OR '1'='1")) # just text: no author has this name
query = "SELECT count(*) FROM book WHERE year < :year"
print(con.execute(query, {"year": 1900}).fetchone())
try:
with con: # rolls back when the block raises
con.execute("INSERT INTO book VALUES (?, ?, ?)", ("Ulysses", "James Joyce", 1922))
con.execute("INSERT INTO book VALUES (?, ?, ?)", ("Emma", "Someone Else", 2000))
except sqlite3.IntegrityError as err:
print("rolled back:", err)
print(con.execute("SELECT count(*) FROM book").fetchone()[0], "books")
con.close()
Run it with
python main.pyOutput
['Emma', 'Persuasion']
[]
(2,)
rolled back: UNIQUE constraint failed: book.title
3 booksTab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercise 1 of 2
Both functions in main.py build SQL with f-strings. An author such as O'Brien crashes them, and a crafted input returns every title. Rewrite them with ? placeholders. search_titles(con, word) must still find titles containing word anywhere, so the % wildcards now belong in the value you bind.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
The SQL becomes a constant: "SELECT title FROM book WHERE author = ? ORDER BY title", with (author,) as the second argument.
A ? cannot sit inside quotes. Write title LIKE ? and bind f"%{word}%".
Remember the comma: (author,) is a tuple, (author) is just a string.
One way to solve it. Yours can look different and still pass the checks.
import sqlite3
def find_by_author(con: sqlite3.Connection, author: str) -> list[str]:
"""Titles by exactly this author, in title order."""
sql = "SELECT title FROM book WHERE author = ? ORDER BY title"
return [row[0] for row in con.execute(sql, (author,))]
def search_titles(con: sqlite3.Connection, word: str) -> list[str]:
"""Titles that contain word anywhere, in title order."""
sql = "SELECT title FROM book WHERE title LIKE ? ORDER BY title"
return [row[0] for row in con.execute(sql, (f"%{word}%",))]
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import sqlite3
def find_by_author(con: sqlite3.Connection, author: str) -> list[str]:
"""Titles by exactly this author, in title order."""
sql = f"SELECT title FROM book WHERE author = '{author}' ORDER BY title"
return [row[0] for row in con.execute(sql)]
def search_titles(con: sqlite3.Connection, word: str) -> list[str]:
"""Titles that contain word anywhere, in title order."""
sql = f"SELECT title FROM book WHERE title LIKE '%{word}%' ORDER BY title"
return [row[0] for row in con.execute(sql)]
test_main.py
import sqlite3
from main import find_by_author, search_titles
def make_db():
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE book(title TEXT, author TEXT)")
con.executemany(
"INSERT INTO book VALUES (?, ?)",
[
("Emma", "Jane Austen"),
("Persuasion", "Jane Austen"),
("Dune", "Frank Herbert"),
("At Swim-Two-Birds", "Flann O'Brien"),
],
)
return con
def test_author():
"""find_by_author finds both Austen titles"""
got = find_by_author(make_db(), "Jane Austen")
assert got == ["Emma", "Persuasion"], f"find_by_author gave {got!r}"
def test_apostrophe():
"""A name with an apostrophe works"""
got = find_by_author(make_db(), "Flann O'Brien")
assert got == ["At Swim-Two-Birds"], f"find_by_author gave {got!r}"
def test_author_injection():
"""An injection attempt is just a name that matches nothing"""
got = find_by_author(make_db(), "x' OR '1'='1")
assert got == [], f"find_by_author returned {got!r}: bind the value with ?"
def test_search():
"""search_titles finds titles containing the word"""
got = search_titles(make_db(), "u")
assert got == ["Dune", "Persuasion"], f"search_titles gave {got!r}"
def test_search_injection():
"""Quotes in the search word cannot change the query"""
got = search_titles(make_db(), "' OR 1=1 OR title = '")
assert got == [], f"search_titles returned {got!r}: put the % signs into the bound value"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
transfer(con, src, dst, amount) moves money between rows of account(name, balance). Make it safe: run both updates inside with con:, raise ValueError if src or dst does not exist (check cur.rowcount) or if src would go below zero. Any ValueError inside the block must roll back every change, and a successful transfer must be committed.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Raise inside the with con: block: the rollback happens because the block ends with an exception.
cur.rowcount is the number of rows the UPDATE changed; 0 means there is no such account.
After the first UPDATE, read the new balance with a SELECT and raise ValueError if it is below 0.
One way to solve it. Yours can look different and still pass the checks.
import sqlite3
def transfer(con: sqlite3.Connection, src: str, dst: str, amount: int) -> None:
"""Move amount from src to dst: both updates happen, or neither does."""
if amount <= 0:
raise ValueError("amount must be positive")
with con: # commit on success, roll back if anything below raises
cur = con.execute("UPDATE account SET balance = balance - ? WHERE name = ?", (amount, src))
if cur.rowcount != 1:
raise ValueError(f"no account {src!r}")
(left,) = con.execute("SELECT balance FROM account WHERE name = ?", (src,)).fetchone()
if left < 0:
raise ValueError(f"{src!r} has too little money")
cur = con.execute("UPDATE account SET balance = balance + ? WHERE name = ?", (amount, dst))
if cur.rowcount != 1:
raise ValueError(f"no account {dst!r}")
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import sqlite3
def transfer(con: sqlite3.Connection, src: str, dst: str, amount: int) -> None:
"""Move amount from src to dst: both updates happen, or neither does."""
# Nothing is committed, and nothing stops an overdraft or an unknown account.
con.execute("UPDATE account SET balance = balance - ? WHERE name = ?", (amount, src))
con.execute("UPDATE account SET balance = balance + ? WHERE name = ?", (amount, dst))
test_main.py
import sqlite3
from main import transfer
def make_db():
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE account(name TEXT PRIMARY KEY, balance INTEGER)")
con.executemany("INSERT INTO account VALUES (?, ?)", [("ada", 100), ("bob", 50)])
con.commit()
return con
def balances(con):
return dict(con.execute("SELECT name, balance FROM account"))
def raises_value_error(con, *args):
try:
transfer(con, *args)
except ValueError:
return True
return False
def test_moves_money():
"""30 moves from ada to bob"""
con = make_db()
transfer(con, "ada", "bob", 30)
got = balances(con)
assert got == {"ada": 70, "bob": 80}, f"balances are {got!r}"
def test_committed():
"""The transfer is committed"""
con = make_db()
transfer(con, "ada", "bob", 30)
assert not con.in_transaction, "a transaction is still open: use with con: so it is committed"
def test_overdraft():
"""Too little money raises ValueError and changes nothing"""
con = make_db()
raised = raises_value_error(con, "ada", "bob", 500)
got = balances(con)
assert raised and got == {"ada": 100, "bob": 50}, f"raised ValueError: {raised}, balances {got!r}"
def test_unknown_account():
"""An unknown receiver raises ValueError and ada keeps her money"""
con = make_db()
raised = raises_value_error(con, "ada", "eve", 10)
got = balances(con)
assert raised and got == {"ada": 100, "bob": 50}, f"raised ValueError: {raised}, balances {got!r}: roll back the first update"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyimport sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users(name)")
name = "O'Brien"
con.execute(f"SELECT * FROM users WHERE name = '{name}'")
What Python prints
sqlite3.OperationalError: near "Brien": syntax errorWhy, and the fix
The apostrophe in O'Brien ends the SQL string early, and SQLite then reads Brien as SQL. Doubling quotes by hand is fragile. Use a placeholder instead: con.execute("SELECT * FROM users WHERE name = ?", (name,)). The value never becomes part of the SQL text.
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users(name)")
con.execute("SELECT * FROM users WHERE name = ?", "ada")
What Python prints
sqlite3.ProgrammingError: Incorrect number of bindings supplied. The current statement uses 1, and there are 3 supplied.Why, and the fix
parameters must be a sequence of values, and a str is a sequence of characters: "ada" supplies three values. Pass a tuple, ("ada",), with the comma, or a list, ["ada"].
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("SELECT :name", ["ada"])
What Python prints
sqlite3.ProgrammingError: Binding 1 (':name') is a named parameter, but you supplied a sequence which requires nameless (qmark) placeholders.Why, and the fix
A named placeholder is looked up by name, so it needs a dict: con.execute("SELECT :name", {"name": "ada"}). Use a list or tuple only with ? placeholders.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.