Skip to content
aviral gupta

// A5.3 · ~35 min · Advanced

sqlite3 and parameterised queries

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.

Lesson 3 of 5 in A5 Modern Python and safe code

You will be able to

  • Pass values to SQL with ? and named placeholders, never by building the SQL string
  • Insert many rows with executemany and control transactions with commit, rollback and with con
  • Explain how SQL injection works and why placeholders prevent it
  1. 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__)
  2. Predict · Activity 2 of 7

    Predict before you read on. Nobody is called that, so what does this print?

    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())
  3. Practice · Activity 3 of 7

    Fill in the parameters argument that binds name to the ? placeholder.

    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())
    WHERE name = ?", )
  4. Practice · Activity 4 of 7

    Named placeholders take a dict. What does this print?

    import sqlite3
    
    con = sqlite3.connect(":memory:")
    row = con.execute("SELECT :a + :b", {"a": 2, "b": 3}).fetchone()
    print(row)
  5. Practice · Activity 5 of 7

    Match each piece of the sqlite3 API to what it does.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. The second INSERT breaks the UNIQUE rule, and the error is caught. How many rows are left?

    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])
  7. Apply · Activity 7 of 7

    Mini-task. For a table score(player, points), write add_scores(con, rows), where rows is a list of dicts with the keys player and points, and top(con, minimum), which returns the players with at least minimum points, best first. Use named placeholders for the insert, ? for the query, and with con:. Then try top(con, "50 OR 1=1").

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A small book database

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

Output

['Emma', 'Persuasion']
[]
(2,)
rolled back: UNIQUE constraint failed: book.title
3 books
  • for (title,) in rows unpacks each one-column row tuple.
  • The injection attempt returns [], because the whole string is compared with author.
  • Ulysses was inserted, then rolled back with the duplicate Emma: still 3 books.
  • with con: never closes the connection, so con.close() is called at the end.
Change it and run it

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.

Exercises

Exercise 1 of 2

Fix two injectable queries

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.

Hints
  1. Hint 1

    The SQL becomes a constant: "SELECT title FROM book WHERE author = ? ORDER BY title", with (author,) as the second argument.

  2. Hint 2

    A ? cannot sit inside quotes. Write title LIKE ? and bind f"%{word}%".

  3. Hint 3

    Remember the comma: (author,) is a tuple, (author) is just a string.

Show a solution

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}%",))]
Run it on your computer

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

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Exercise 2 of 2

A transfer that is all or nothing

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.

Hints
  1. Hint 1

    Raise inside the with con: block: the rollback happens because the block ends with an exception.

  2. Hint 2

    cur.rowcount is the number of rows the UPDATE changed; 0 means there is no such account.

  3. Hint 3

    After the first UPDATE, read the new balance with a SELECT and raise ValueError if it is below 0.

Show a solution

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}")
Run it on your computer

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

Run the checks (needs learnrun.py in the same folder):

python learnrun.py test
Download learnrun.py

Common mistakes

Quoting the value yourself

import 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 error

Why, 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.

A string where a tuple belongs

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"].

Named placeholders with a list

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

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Placeholders carry the values

sqlite3.connect(":memory:") opens a database in memory; a file name opens or creates a file. con.execute(sql, parameters) runs one statement. Write a ? where a value belongs and pass the values as a sequence: con.execute("... WHERE name = ?", (name,)); note the comma of the one-item tuple. Named placeholders, :name, take a dict, and extra keys are ignored. The number of values must match, or you get ProgrammingError. Placeholders stand for values only: a table or column name cannot be a parameter, so pick those from a fixed list in your code.

executemany and transactions

con.executemany(sql, rows) runs one INSERT, UPDATE or DELETE per item of rows. By default, such a statement opens a transaction implicitly, and its changes are only saved by con.commit(); con.rollback() throws them away, and closing without commit loses them. The easy way is with con:. It commits when the block ends normally and rolls back when it raises, and the exception still propagates. That makes several statements all-or-nothing, as a bank transfer must be. with con: does not close the connection: call con.close() yourself.

SQL injection, shown and fixed

Built with an f-string, f"... WHERE name = '{name}'" lets the input write SQL: a user who types x' OR '1'='1 closes the quote and adds a condition that is always true, so the query returns every row. The same input breaks harmless names too: O'Brien is a syntax error. With a placeholder, SQLite compiles the statement first and receives the value separately, so a quote in it is just a character. There is no need to escape anything yourself. The same holds for LIKE: put the % signs into the bound value, f"%{word}%", not into the SQL.

Sources

Last reviewed September 29, 2026