Python Module 8 – Files, paths and data formats
SQLite databases with sqlite3
Create SQLite tables in Python, insert, query and update rows with sqlite3, pass values through placeholders, and commit or roll back transactions correctly.
What you will learn
- Create tables and insert, query, update and delete rows with sqlite3
- Predict how SQLite stores dates and values that do not match a column's type
- Use placeholders so that values can never change the SQL, and an allowlist for names
- Group statements into a transaction that commits or rolls back as one
- Explain what a second writer and a second thread meet, and keep writes fast
Before you start
What you’ll learn · Before you start
What you will learn
- Create tables and insert, query, update and delete rows with sqlite3
- Predict how SQLite stores dates and values that do not match a column's type
- Use placeholders so that values can never change the SQL, and an allowlist for names
- Group statements into a transaction that commits or rolls back as one
- Explain what a second writer and a second thread meet, and keep writes fast
Before you start
On this page
Four books, three loans and one question, which books are overdue and by how many days, need no database server.
SQLite keeps a complete SQL database in a single file, and Python ships with a driver for it, the sqlite3
module: sqlite3.connect("app.db") opens the file, creating it if needed, execute() runs SQL, fetchone() and
fetchall() read results, and commit() saves your changes.
SQLite suits programs that keep their own data: desktop and mobile apps, command-line tools, tests, prototypes, caches and small websites. You will build that library database, see how SQLite stores dates and types, pass every value through placeholders, get transaction: A group of SQL statements that a database applies together or not at all: commit() makes every change permanent, and rollback() or an error undoes all of them. Other connections see the changes only after the commit. right, because a database that saves half of an operation is worse than one that saves nothing, and watch what happens when two connections write at once.
A connection runs SQL, and commit() keeps the result
The example creates a database file with two tables, fills them, asks which books are overdue and records a return:
import sqlite3
from pathlib import Path
print("SQLite library:", sqlite3.sqlite_version)
Path("library.db").unlink(missing_ok=True) # start from an empty database on every run
con = sqlite3.connect("library.db") # a file; ":memory:" gives a database that lives only in memory
con.execute("""CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS loans (
book_id INTEGER NOT NULL REFERENCES books(id),
member TEXT NOT NULL,
due TEXT NOT NULL)""")
books = [
("Malgudi Days", "R. K. Narayan"),
("The Guide", "R. K. Narayan"),
("Wings of Fire", "A. P. J. Abdul Kalam"),
("Train to Pakistan", "Khushwant Singh"),
]
con.executemany("INSERT INTO books (title, author) VALUES (?, ?)", books)
loans = [(1, "asha", "2026-03-01"), (3, "ravi", "2026-03-20"), (4, "meera", "2026-02-25")]
con.executemany("INSERT INTO loans (book_id, member, due) VALUES (?, ?, ?)", loans)
con.commit() # the INSERTs opened a transaction; without commit() they would be lost
con.row_factory = sqlite3.Row # rows that can be read by column name
query = """
SELECT b.title, l.member, CAST(julianday(:today) - julianday(l.due) AS INTEGER) AS days_late
FROM loans AS l JOIN books AS b ON b.id = l.book_id
WHERE l.due < :today
ORDER BY days_late DESC"""
for row in con.execute(query, {"today": "2026-03-10"}):
print(f"{row['title']:<18} {row['member']:<6} {row['days_late']} days late")
cur = con.execute("DELETE FROM loans WHERE book_id = ?", (4,)) # Meera returns her book
con.commit()
print("loans removed:", cur.rowcount)
print("books by Narayan:", con.execute("SELECT count(*) FROM books WHERE author = ?", ("R. K. Narayan",)).fetchone()[0])
con.close() Output
SQLite library: 3.51.0 Train to Pakistan meera 13 days late Malgudi Days asha 9 days late loans removed: 1 books by Narayan: 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 library.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Version note
The first line differs in the browser: Pyodide’s Python is built with SQLite 3.39.0, while this lesson’s CPython 3.14.8 uses SQLite 3.51.0. Everything else prints the same. When an example must run in both, it sticks to SQL that 3.39.0 already supports.
In your browser, Pyodide 314.0.7 (CPython 3.14.2) prints:
SQLite library: 3.39.0 Train to Pakistan meera 13 days late Malgudi Days asha 9 days late loans removed: 1 books by Narayan: 2
Step through what each call did, from connect() to close(), and watch what the connection holds back until
commit():
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Open the file. sqlite3.connect("library.db") opens the database file, creating it because the example deleted the old one first, and returns a connection.
- Create two tables. Two CREATE TABLE statements, run with con.execute(), make the tables books and loans in the file.
- Insert in a transaction. executemany() inserts 4 books and 3 loans. The first INSERT opened a transaction, so the rows are not saved yet. No other connection sees them, and closing now would discard them.
- commit(). con.commit() ends the transaction and writes the 7 rows to the file.
- A query by name. With row_factory set to sqlite3.Row, the overdue query returns rows that can be read by column name. Two loans are past their due date, Meera's by 13 days and Asha's by 9.
- Delete and count. A DELETE removes Meera's loan, and its cursor's rowcount is 1. commit() saves the change, and a count finds 2 books by R. K. Narayan.
- Close. con.close() closes the connection. Everything that was committed stays in library.db for the next program that opens it.
sqlite3.connect(path)returns a connection. A path opens or creates a file;":memory:"gives a private database that disappears when the connection closes, which is ideal for tests.con.execute(sql, parameters)runs one statement and returns a The object that runs a statement and holds its result rows, which a loop or fetchone(), fetchmany() and fetchall() read. con.execute() in sqlite3 creates one for you and returns it.. You can loop over the cursor, or callfetchone()for one row andfetchall()for a list of them. Rows are tuples by default.executemany(sql, rows)runs the same statement for every item of a sequence, the quick way to insert many rows.con.commit()saves. TheINSERTstatements opened a transaction, and until it is committed nobody else sees the rows, and closing the connection would discard them.con.row_factory = sqlite3.Rowgives rows you can read by column name, as inrow["title"], and still by position. The names are matched without regard to case.cur.rowcountsays how many rows anUPDATEorDELETEchanged, andcur.lastrowidgives the id of the row anINSERTcreated.
Checkpoint
Check: Running SQL and saving it
SQLite has no date type, and a declared type is only a preference
The overdue query of the first example shows how to keep dates in SQLite: there is no date type, so the example
stores them as ISO 8601 text such as YYYY-MM-DD, which sorts correctly as text, and uses SQLite’s julianday()
function to subtract them. SQLite’s date functions also work with Julian day numbers and Unix times, but text is the
form a person can read. Its columns are flexible in general: a column declared INTEGER prefers integers but will
store text you give it, unless the table is declared STRICT. This program puts five values into a column declared
INTEGER, asks typeof() how each one was stored, then tries the same in a STRICT table:
import sqlite3
con = sqlite3.connect(":memory:")
# A column declared INTEGER prefers integers, but it stores what it cannot convert as it is.
con.execute("CREATE TABLE loose (n INTEGER)")
con.executemany("INSERT INTO loose VALUES (?)", [(42,), ("42",), ("forty-two",), (4.5,), (None,)])
for value, kind in con.execute("SELECT n, typeof(n) FROM loose"):
print(f"{value!r:<12} stored as {kind}")
# A STRICT table refuses a value of the wrong type (SQLite 3.37.0 and newer).
con.execute("CREATE TABLE tight (n INTEGER) STRICT")
try:
con.execute("INSERT INTO tight VALUES (?)", ("forty-two",))
except sqlite3.IntegrityError as err:
print("STRICT table:", err)
# There is no date type: ISO 8601 text sorts in date order, and julianday() does the arithmetic.
due = ["2026-03-01", "2026-02-25", "2026-03-20"]
print("sorted as text:", sorted(due))
days = con.execute("SELECT julianday(?) - julianday(?)", ("2026-03-10", "2026-02-25")).fetchone()[0]
print("days between:", days)
con.close() Output
42 stored as integer 42 stored as integer 'forty-two' stored as text 4.5 stored as real None stored as null STRICT table: cannot store TEXT value in INTEGER column tight.n sorted as text: ['2026-02-25', '2026-03-01', '2026-03-20'] days between: 13.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_and_dates.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The first five lines of the output, with the reason for each:
| Value inserted | Stored as | Why |
|---|---|---|
42 |
integer | it is an integer already |
'42' |
integer | text that converts to an integer without loss |
'forty-two' |
text | it does not convert, so it is kept as it is |
4.5 |
real | as an integer it would lose its .5 |
None |
null | NULL is a storage class of its own |
SQLite calls this preference type affinity: SQLite's preference for a type in a column declared INTEGER, REAL, TEXT or NUMERIC: a value that converts without loss is stored as that type, and anything else is kept as it is. A STRICT table refuses values it cannot convert instead.: a column’s declared type is a preference,
not a rule. A STRICT table, which SQLite has offered since version 3.37.0, still converts what it can without
loss, but refuses a value it cannot convert, such as 'forty-two', with IntegrityError. The dates sort correctly
as text because ISO 8601 puts the year first, then the month and the day, and julianday() turns each into a day
number, so subtracting two of them gives 13 days. Press Edit, change the second date of the last query and run it
again: the difference follows.
Checkpoint
Check: Dates and types in SQLite
A value formatted into SQL can rewrite the query
The tempting way to put a value into a query is an f-string. It fails on the first value that contains a quote, and the failure is a warning sign of something worse:
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE members (name TEXT, city TEXT)")
con.executemany("INSERT INTO members VALUES (?, ?)", [("Asha", "Pune"), ("Sean O'Brien", "Dublin")])
name = "Sean O'Brien" # an ordinary value with a quote in it
# Formatting the value into the SQL text makes the quote part of the SQL.
sql = f"SELECT city FROM members WHERE name = '{name}'"
print(sql)
try:
con.execute(sql)
except sqlite3.OperationalError as err:
print("OperationalError:", err)
# A placeholder sends the value separately: it is never read as SQL, whatever it contains.
print(con.execute("SELECT city FROM members WHERE name = ?", (name,)).fetchone())
print(con.execute("SELECT city FROM members WHERE name = :name", {"name": name}).fetchone())
# Placeholders stand for values only, never for table or column names: check those against an allowlist.
SORTABLE = {"name", "city"}
column = "city"
if column not in SORTABLE:
raise ValueError(f"cannot sort by {column!r}")
print([row[0] for row in con.execute(f"SELECT name FROM members ORDER BY {column}")])
# execute() runs exactly one statement.
try:
con.execute("SELECT 1; SELECT 2")
except sqlite3.ProgrammingError as err:
print("ProgrammingError:", err) Output
SELECT city FROM members WHERE name = 'Sean O'Brien'
OperationalError: near "Brien": syntax error
('Dublin',)
('Dublin',)
["Sean O'Brien", 'Asha']
ProgrammingError: You can only execute one statement at a time.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 quotes_and_placeholders.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Formatted into the text, Sean O'Brien ends the SQL string literal after O and leaves Brien' as stray SQL, so
SQLite reports a syntax error. That is the harmless case. The same mechanism lets a value that someone writes on
purpose change what a query does, for example to match every row or to skip a check, which is called
SQL injection: A flaw where text from outside is put into the text of an SQL statement, so that crafted input changes what the statement does, for example to match every row. Placeholders prevent it, because values travel apart from the SQL. and is one of the most common security flaws in applications. Escaping
quotes by hand is not a fix, because the rules differ between databases and are easy to get wrong.
Try it
Press Edit, change name to "D'Souza" and run the example again. The formatted query fails the same way, near
"Souza", while both placeholder lines print None, because no member has that name.
The fix is to never put values into SQL text at all. With a placeholder (SQL): A marker such as ? or :name where a value goes in an SQL statement. The driver sends the values apart from the SQL text, and the database binds them to the compiled statement as data., the program sends the SQL and the values separately, and SQLite compiles the statement before it sees any value:
A value formatted into SQL, and the same value passed as a parameter
Text description of the diagram
The diagram shows two paths, one above the other.
- Formatted into the text: the program builds the SQL with an f-string, such as WHERE name = '{name}'. SQLite then compiles text that already contains the value, so a quote inside the value ends the string early and changes the SQL. At best that is a syntax error; at worst the input rewrites the query.
- Passed as a parameter: the program writes WHERE name = ? and passes the value separately, as a tuple. SQLite compiles the text with the question mark in it, and binds the value to the compiled statement afterwards, as data. Whatever characters the value contains, it can never become part of the SQL.
sqlite3 accepts two placeholder styles. ? (the qmark style) takes a sequence of values in order, and :name (the
named style) takes a dictionary. Since Python 3.14, mixing them up, named placeholders with a sequence, raises
ProgrammingError instead of a warning. Three more rules come from the example:
- Placeholders stand for values only. A table or column name cannot be a parameter. When the user chooses a sort
column, check the name against a fixed set of names your program knows, an allowlist: A list of what is accepted, such as the URL schemes http and https; anything not on it is refused. Unlike a denylist, it also refuses values nobody thought of. More in “XML and HTML parsing without security holes”,
as
SORTABLEdoes, and only then put it in the SQL. execute()runs exactly one statement, and refuses a string that holds two.executescript()runs several, and is meant for SQL you wrote yourself, such as a schema file, never for text that contains user input.- Validate inputs as well. Placeholders stop values from changing the SQL, but a quantity should still be a positive number and a name should still have a sensible length. The OWASP cheat sheet on preventing SQL injection puts parameterised queries first, calls escaping input by hand fragile, and adds allowlist validation as a further layer and as the defence for names that cannot be parameters.
Checkpoint
Check: Placeholders and allowlists
with con: commits a transaction, or rolls all of it back
A transaction groups statements so that they take effect together or not at all. A transfer between two wallets is the classic case: the credit and the debit must both happen, or neither. The connection is a context manager: An object with __enter__() and __exit__() methods. The with statement calls __enter__() before its block and __exit__() after it, however the block ends, so a file is closed or a lock released even after an error. More in “Context managers and the with statement” for exactly this:
import sqlite3
from contextlib import closing
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE wallets (owner TEXT PRIMARY KEY, balance INTEGER NOT NULL CHECK (balance >= 0))")
con.executemany("INSERT INTO wallets VALUES (?, ?)", [("asha", 500), ("ravi", 100)])
con.commit()
def transfer(con, payer, payee, amount):
with con: # commits when the block succeeds, rolls back when it raises
con.execute("UPDATE wallets SET balance = balance + ? WHERE owner = ?", (amount, payee))
con.execute("UPDATE wallets SET balance = balance - ? WHERE owner = ?", (amount, payer))
def balances(con):
return dict(con.execute("SELECT owner, balance FROM wallets ORDER BY owner"))
transfer(con, "asha", "ravi", 200)
print("after 200:", balances(con))
try:
transfer(con, "asha", "ravi", 400) # Asha has only 300 left: the CHECK constraint fails
except sqlite3.IntegrityError as err:
print("IntegrityError:", err)
print("after the failed 400:", balances(con)) # Ravi's credit was rolled back too
print("still open:", con.execute("SELECT count(*) FROM wallets").fetchone()[0], "wallets") # with did not close it
con.close()
# closing() closes the connection at the end of its block; the inner with handles the transaction.
with closing(sqlite3.connect(":memory:")) as con2:
with con2:
con2.execute("CREATE TABLE t (x)")
con2.execute("INSERT INTO t VALUES (1)")
print("rows in t:", con2.execute("SELECT count(*) FROM t").fetchone()[0])
try:
con2.execute("SELECT 1")
except sqlite3.ProgrammingError as err:
print("ProgrammingError:", err) Output
after 200: {'asha': 300, 'ravi': 300}
IntegrityError: CHECK constraint failed: balance >= 0
after the failed 400: {'asha': 300, 'ravi': 300}
still open: 2 wallets
rows in t: 1
ProgrammingError: Cannot operate on a closed database.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 transaction.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Step through both transfers and watch the balances inside each transaction:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Credit Ravi. transfer(con, "asha", "ravi", 200) first adds 200 to Ravi's wallet. The UPDATE opens a transaction, so the change is not saved yet.
- Debit Asha. The second UPDATE takes 200 from Asha. Both changes now wait in the same transaction.
- Commit. The with block ends without an error, so with con commits both changes together.
- Credit 400. The second transfer adds 400 to Ravi first, inside a new transaction.
- The CHECK fails. Taking 400 from Asha's 300 would leave −100, so the CHECK (balance >= 0) constraint stops the second UPDATE with IntegrityError.
- Roll back. The error leaves the with block, so with con rolls the whole transaction back, Ravi's credit included. The balances are 300 and 300 again.
- Still open. with con managed the transaction, not the connection, so con still answers and counts 2 wallets. Only close(), or contextlib.closing(), closes it.
The second transfer adds 400 to Ravi’s wallet, then fails the table’s CHECK (balance >= 0) constraint on Asha’s.
Because both statements ran inside with con:, the IntegrityError rolled back the whole transaction, Ravi’s credit
included, and the balances are exactly what they were before. When the block ends without an error, with con:
commits.
Version note
Python 3.14 removed sqlite3.version and sqlite3.version_info, which described the module rather than the
library; read sqlite3.sqlite_version for the SQLite version, as the first example does. Python 3.15 makes every
sqlite3.connect() parameter except the database keyword-only, so pass them by name, as in
sqlite3.connect(path, timeout=10).
Two details catch people out. First, with con: manages the transaction, not the connection: the documentation says
it neither opens a new transaction nor closes the connection, which is why con still answers after the block.
Close connections yourself with con.close(), or with contextlib.closing(), as the end of the example does; since
Python 3.13 a connection that is garbage-collected without close() emits a ResourceWarning. Second, by default
sqlite3 opens transactions implicitly, before an INSERT, UPDATE, DELETE or REPLACE, so a SELECT on its own
starts none. Python 3.12 added the autocommit argument to connect(), and the documentation recommends
autocommit=False, which keeps a transaction open at all times as the DB-API standard expects. Either way, commit
explicitly or use with con:.
Key idea
Put the statements that belong together in one with con: block: it commits all of them when the block succeeds
and rolls all of them back when it raises, but it leaves the connection open.
Checkpoint
Check: Transactions with with con
Two connections cannot write at once, so keep transactions short
SQLite allows one writer at a time. Any other connection that tries to write must wait until the lock is released, by
default up to 5 seconds (the timeout argument of connect()), and then raises OperationalError: database is locked. This program
opens two connections to one file and lets them collide, with a timeout of 0.2 seconds so that the run is quick. It
also starts a thread, which the Python that runs in your browser cannot do, so this page shows its recorded output:
import sqlite3
import threading
from pathlib import Path
Path("shop.db").unlink(missing_ok=True) # start from an empty database on every run
a = sqlite3.connect("shop.db")
a.execute("CREATE TABLE stock (item TEXT, qty INTEGER)")
a.commit()
b = sqlite3.connect("shop.db", timeout=0.2) # waits at most 0.2 s for a lock, instead of 5 s
a.execute("INSERT INTO stock VALUES ('tea', 40)") # A's transaction is open: A holds the write lock
print("A is writing, in a transaction:", a.in_transaction)
try:
b.execute("INSERT INTO stock VALUES ('rice', 25)") # B waits 0.2 s for the lock, then gives up
except sqlite3.OperationalError as err:
print("B:", err)
b.rollback()
print("B reads:", b.execute("SELECT count(*) FROM stock").fetchone()[0], "rows") # A's row is not committed yet
a.commit() # the lock is free again
b.execute("INSERT INTO stock VALUES ('rice', 25)")
b.commit()
print("after both commits:", a.execute("SELECT item, qty FROM stock ORDER BY item").fetchall())
def use_from_another_thread():
try:
a.execute("SELECT 1")
except sqlite3.ProgrammingError as err:
print("another thread:", str(err).split(".")[0])
worker = threading.Thread(target=use_from_another_thread)
worker.start()
worker.join()
a.close()
b.close() Output
A is writing, in a transaction: True
B: database is locked
B reads: 0 rows
after both commits: [('rice', 25), ('tea', 40)]
another thread: SQLite objects created in a thread can only be used in that same thread
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_writer.py
Step through the run and watch who holds the write lock on shop.db:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- A writes. A inserts tea, 40. The INSERT opens A's transaction, and A now holds the database's write lock until it commits.
- B waits. B inserts rice, 25, and needs the same lock. It waits for it, up to its timeout of 0.2 seconds; the default is 5 seconds.
- B gives up. The lock is still A's when the 0.2 seconds are over, so B raises OperationalError, database is locked.
- B rolls back. The failed INSERT had already begun B's transaction. B rolls it back, so it keeps no lock that A's commit would have to wait for.
- B can still read. Reading needs no write lock, so B counts the rows. It sees 0, because A's row is not committed yet.
- A commits. A's commit writes tea to the file and frees the write lock.
- B tries again. Now B's INSERT and commit go through, and the table holds rice, 25 and tea, 40.
- Another thread. A thread that did not create A tries to use it. sqlite3 refuses with ProgrammingError, because check_same_thread is True by default.
B’s rollback() is not decoration. The failed INSERT had already begun B’s transaction, and the read that follows
would have kept a read lock inside it; SQLite’s locking documentation says a writer may have to wait until such read
locks clear before it can commit, so A’s commit() would then have failed in turn. The last line shows the thread
rule: by default sqlite3 refuses to let one thread use a connection that another thread created, so open a separate
connection in every thread.
Two habits follow. Keep write transactions short, because every other writer waits for them. And batch writes into
transactions: SQLite’s FAQ explains that by default SQLite waits until the data of each transaction is safely stored
on the disk, so it runs far more INSERT statements a second than transactions. Inserting thousands of rows with one
commit each pays that wait thousands of times; one transaction around an executemany() pays it once.
SQLite’s own documentation describes when it fits: data that one application owns, files that should be easy to copy, and moderate write loads. When many machines must write to the same data at once, use a client-server database such as PostgreSQL, which the next lesson connects to through psycopg.
Checkpoint
Check: One writer, one thread per connection
Practise: a library database with safe searches
The exercise asks for a BookStore class: add_book() returns the new id, search() matches part of a title in any
case with % and _ taken literally, and checkout() lends a book once, in one transaction. Its sample tests run in
your browser:
Exercise · Medium · Python
A small library database with safe searches and loans
Complete the class BookStore in book_store.py. Keep the names and parameters: the tests use them. BookStore(path) opens the SQLite database at path (":memory:" by default) and creates its two tables if they do not exist yet.
add_book(title, author)stores a book and returns its new id.search(title_part)returns a list of(id, title)tuples for the books whose title containstitle_part, ignoring upper and lower case, sorted by title. Characters such as',;,%and_intitle_partare matched as themselves: searching for100%finds titles that contain100%, not every title that contains100.checkout(book_id, member)lends a book. It returnsTruewhen the loan was recorded andFalsewhen the book is already on loan, since a book has at most one active loan. It raisesValueErrorfor an id that does not exist. Check and record the loan inside one transaction.close()closes the connection.
Pass every value through placeholders; never put a value into the SQL text.
Starter code · book_store.py
import sqlite3
class BookStore:
"""Books and loans in one SQLite database."""
def __init__(self, path=":memory:"):
self.con = sqlite3.connect(path)
with self.con:
self.con.execute("CREATE TABLE IF NOT EXISTS books (id INTEGER PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL)")
self.con.execute("CREATE TABLE IF NOT EXISTS loans (book_id INTEGER PRIMARY KEY REFERENCES books(id), member TEXT NOT NULL)")
def add_book(self, title, author):
"""Store a book and return its id."""
raise NotImplementedError("write add_book()")
def search(self, title_part):
"""[(id, title), ...] of books whose title contains title_part, any case, sorted by title."""
raise NotImplementedError("write search()")
def checkout(self, book_id, member):
"""True if the loan was recorded, False if the book is already on loan; ValueError for an unknown id."""
raise NotImplementedError("write checkout()")
def close(self):
self.con.close() The sample tests · test_book_store.py
import tempfile
from pathlib import Path
from book_store import BookStore
TITLES = [
("The Guide", "R. K. Narayan"),
("guide to Sorting", "A. Author"),
("Rock 'n' Roll; a history", "B. Writer"),
("100% Python", "C. Coder"),
("1000 Puzzles", "D. Maker"),
]
def filled():
store = BookStore()
ids = [store.add_book(t, a) for t, a in TITLES]
return store, ids
def test_add_returns_new_ids():
"""each new book gets a new id"""
store, ids = filled()
assert len(set(ids)) == len(TITLES)
def test_search_any_case_sorted():
"""titles containing the text, ignoring case, sorted by title"""
store, ids = filled()
titles = [title for _, title in store.search("GUIDE")]
assert titles == ["The Guide", "guide to Sorting"]
def test_search_quotes_and_semicolons():
"""quotes and semicolons are just characters, and the tables survive"""
store, ids = filled()
assert store.search("Rock 'n' Roll;") == [(ids[2], "Rock 'n' Roll; a history")]
assert len(store.search("")) == len(TITLES)
def test_search_wildcards_are_literal():
"""% and _ match only themselves"""
store, ids = filled()
assert store.search("100%") == [(ids[3], "100% Python")]
assert store.search("_") == []
def test_checkout_once():
"""a book can be lent once; a second loan is refused"""
store, ids = filled()
assert store.checkout(ids[0], "asha") is True
assert store.checkout(ids[0], "ravi") is False
assert store.checkout(ids[1], "ravi") is True
def test_checkout_unknown_book():
"""an id that does not exist raises ValueError"""
store, ids = filled()
try:
store.checkout(999, "asha")
except ValueError:
return
assert False, "an unknown book was lent"
def test_data_is_saved():
"""books and loans are still there after closing and reopening the database"""
path = Path(tempfile.mkdtemp()) / "library.db"
store = BookStore(path)
book = store.add_book("Malgudi Days", "R. K. Narayan")
store.checkout(book, "meera")
store.close()
again = BookStore(path)
assert again.search("malgudi") == [(book, "Malgudi Days")]
assert again.checkout(book, "asha") is False
again.close() A hint
LIKE treats % and _ as wildcards, so put a backslash in front of each (and in front of any backslash) in title_part, then write title LIKE ? ESCAPE '\' and pass f"%{escaped}%" as the parameter. SQLite's LIKE already ignores the case of ASCII letters. For checkout(), a with self.con: block commits the loan or rolls it back, and cursor.lastrowid gives the id of the row an INSERT created.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The practice below returns to the library, the wallets and the two writers of shop.db. It opens with the part your
checkpoint answers left weakest, and each pick comes with its reason:
Practice that adapts to you
Practise this lesson
16 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Why should you pass values to sqlite3 as parameters instead of building the SQL string with an f-string?
Because a formatted value becomes part of the SQL text, and a parameter never does. With an f-string, a quote inside
the value ends a string literal early, which at best causes a syntax error and at worst lets crafted input change
what the query does, the flaw known as SQL injection. With a placeholder, ? or :name, the driver sends the
statement and the values separately: SQLite compiles the statement first and binds the values to it as data, so no
character in a value can alter the SQL. Parameters also handle types correctly, such as bytes and None, and let the
database reuse a compiled statement. Placeholders work only for values, so table and column names that depend on
input must be checked against an allowlist instead.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
An import script reads 200,000 rows from a CSV file and inserts them into an SQLite file with one
execute()and onecommit()per row. It takes over ten minutes. Why is it slow, how do you make it fast, and how do you keep the import all-or-nothing? -
Mid to senior
A small web app shares one global sqlite3 connection between its request threads. First it fails with a ProgrammingError about objects created in another thread; after someone passes
check_same_thread=False, users see "database is locked" under load. Explain both errors and design the data access.
Key takeaways
sqlite3.connect()opens a database file or":memory:";execute(),executemany(),fetchone()andfetchall()do the work,commit()saves, andsqlite3.Row,rowcountandlastrowidreport what came back.- SQLite has no date type, so keep dates as ISO 8601 text, which sorts in date order, and remember that a column’s
declared type is only a preference unless the table is
STRICT. - Pass every value through
?or:nameplaceholders and never format values into SQL; names of tables and columns need an allowlist instead. with con:commits on success and rolls back on an exception, but does not close the connection: callclose()or usecontextlib.closing().- SQLite lets one connection write at a time, so a second writer waits up to its timeout and then gets “database is locked”; keep write transactions short, batch many writes into one, and give each thread its own connection.
- SQLite fits data one application owns; many concurrent writers on several machines call for a database server.
Terms in this lesson
- placeholder (SQL)
- A marker such as ? or :name where a value goes in an SQL statement. The driver sends the values apart from the SQL text, and the database binds them to the compiled statement as data.
- SQL injection
- A flaw where text from outside is put into the text of an SQL statement, so that crafted input changes what the statement does, for example to match every row. Placeholders prevent it, because values travel apart from the SQL.
- transaction
- A group of SQL statements that a database applies together or not at all: commit() makes every change permanent, and rollback() or an error undoes all of them. Other connections see the changes only after the commit.
- type affinity
- SQLite's preference for a type in a column declared INTEGER, REAL, TEXT or NUMERIC: a value that converts without loss is stored as that type, and anything else is kept as it is. A STRICT table refuses values it cannot convert instead.
References
- sqlite3: DB-API 2.0 interface for SQLite databases (Python Software Foundation)
- sqlite3: how to use placeholders to bind values in SQL queries (Python Software Foundation)
- sqlite3: how to use the connection context manager (Python Software Foundation)
- What's New In Python 3.14: sqlite3 (Python Software Foundation)
- SQL As Understood By SQLite (SQLite)
- Datatypes In SQLite (SQLite)
- STRICT Tables (SQLite)
- File Locking And Concurrency In SQLite Version 3 (SQLite)
- SQLite Frequently Asked Questions: INSERT is really slow (SQLite)
- Appropriate Uses For SQLite (SQLite)
- Pyodide: WebAssembly constraints (threading) (Pyodide project)
- SQL Injection Prevention Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress