Module 9: SQL and Persistent Data
Parameters, Transactions, and Versioned Migrations
Why every value goes in as a parameter, how a transaction keeps a multi-step write all-or-nothing, and how a schema changes without losing what is already stored.
Lesson 30 of 46 in the recommended order · About 30 min (estimate)
On this page
Outcome
By the end of this lesson you can pass every value into a query as a bound parameter, group related writes so they succeed or fail together, and change a table's shape without losing the rows already in it.
Why it matters
Three things separate a database you can trust from one you cannot.
The first is how values get into queries. Building SQL by joining strings means any text that reaches the query can change what the query is, not just what it matches. That is the vulnerability behind a large share of real data breaches, and the fix is one character.
The second is what happens when a multi-step write fails halfway. Without a transaction, the first two statements are permanent and the third is not, leaving a state your program never intended and cannot recognise.
The third is change. Every schema is wrong eventually. A stored dataset needs a way to gain a column without losing its rows, and without depending on anyone remembering to run the right statement by hand.
Concept
Parameters. Put a ? where the value belongs and pass the values separately:
connection.execute(
"SELECT notice_id FROM notices WHERE agency_code = ? AND amount >= ?",
("GSA", 100000),
)
The database receives the statement and the values along separate paths. A value can never be read as SQL, because it is not on the path SQL arrives by. That is why parameter binding is a guarantee rather than an improvement over careful escaping.
Two practical details. Positional parameters need a sequence of values; a one-item tuple is written (value,), including the trailing comma. And parameters bind values, never table or column names; if something in your program chooses a column name at run time, that value must be checked against an allowlist you wrote, because there is no placeholder for it.
Transactions. A transaction groups statements so they all take effect or none do. In Python's sqlite3, connection.commit() makes the current transaction permanent and connection.rollback() discards it. Using the connection as a context manager commits an open transaction on success and rolls it back if an exception escapes. Entering with connection: does not itself begin a transaction. With the connection settings used here, an INSERT starts one implicitly; start one explicitly with BEGIN before a migration whose first statement changes the schema:
with connection:
connection.execute("INSERT INTO notices VALUES (?, ?)", ("A-9", "GSA"))
connection.execute("INSERT INTO shortlist VALUES (?)", ("A-9",))
If the second statement raises, the first is undone. Without that, a notice exists with no shortlist entry, and nothing in the data says why.
Migrations. A migration is one numbered file containing the statements that move a schema from one version to the next: 0001_init.sql, 0002_add_set_aside.sql. The rules that make them work:
- Numbered and applied in order, once each. Record which have run, in the database itself.
- Forward-only. To undo something, write a new migration; editing an applied one means two databases with the same version number and different shapes.
- Safe on an empty database and on a populated one. Both cases get tested.
- Additive where possible. Adding a nullable column is cheap and cannot lose data; dropping or renaming one needs a plan for the code that still reads it.
ALTER TABLE notices ADD COLUMN set_aside TEXT is the common case. Existing rows get NULL for the new column, which is exactly the honest value: those notices were stored before the field was recorded, and pretending otherwise would invent data.
This is not theoretical for this project. The learning platform you are reading runs on Cloudflare D1, which is SQLite-compatible, and its schema is managed by exactly this pattern: numbered files in a migrations/ directory, applied in order, tested against both an empty and a populated database.
Read the code
import sqlite3
connection = sqlite3.connect(":memory:")
connection.executescript(
"""
CREATE TABLE schema_version (version INTEGER NOT NULL);
INSERT INTO schema_version VALUES (1);
CREATE TABLE notices (notice_id TEXT PRIMARY KEY, agency_code TEXT NOT NULL);
INSERT INTO notices VALUES ('A-1', 'GSA');
"""
)
# Migration 0002: record the set-aside category.
with connection:
connection.execute("BEGIN")
connection.execute("ALTER TABLE notices ADD COLUMN set_aside TEXT")
connection.execute("UPDATE schema_version SET version = 2")
connection.execute(
"INSERT INTO notices (notice_id, agency_code, set_aside) VALUES (?, ?, ?)",
("A-2", "DLA", "Total Small Business"),
)
connection.commit()
print(connection.execute("SELECT version FROM schema_version").fetchone()[0])
print(connection.execute("SELECT notice_id, set_aside FROM notices ORDER BY notice_id").fetchall())
print(connection.execute("SELECT COUNT(*) FROM notices WHERE set_aside IS NULL").fetchone()[0])
The migration explicitly begins a transaction before ALTER TABLE, so the schema change and version bump are one unit committed on normal exit from the with block. Without BEGIN, merely entering this block would not start a transaction for the schema change with these connection settings. A crash between them would otherwise leave a database whose recorded version disagrees with its actual shape, which is the worst state a migration system can be in.
The pre-existing row keeps its data and gains a NULL for the new column. After the migration commits, a separate insert supplies the new row through bound parameters and is committed explicitly. Both commits affect this in-memory database; closing its connection still discards the whole database.
The last query counts rows whose set-aside is NULL. In this fixture that is the pre-existing row, but a later insert can also supply or default to NULL; the count measures missing values, not row age.
Inspect statement binding and migration outcomes
Keep migration writes together; keep bound values separate
Two separate transactions: First commit migration 0002. Then insert A-2 with bound values and commit that insert separately.
The bracket applies to the migration only. A later insert failure would not undo an already committed migration.
Migration 0002 · explicit transaction
BEGINALTER TABLE notices ADD COLUMN set_aside TEXTUPDATE schema_version SET version = 2
Normal block exit → commit
Both changes commit: version 2 and the new column. Existing A-1 survives with set_aside = NULL.
Exception escapes → rollback
An exception after either migration write, before successful block exit, rolls back both schema and version to version 1. The added column is absent; A-1 remains.
Insert lane 1 · SQL statement
Fixed structure- INSERT INTO notices (notice_id, agency_code, set_aside) VALUES (?, ?, ?)
Placeholders- Three ? positions receive three values.
The statement determines the table, columns and operation. Values cannot replace identifiers.
Insert lane 2 · parameter tuple
Separate values- ("A-2", "DLA", "Total Small Business")
Binding order- A-2 → notice_id; DLA → agency_code; Total Small Business → set_aside
execute receives the statement and this tuple separately. A following commit completes this insert transaction.
Worked success · after both commits
schema_version- 2
A-1 set_aside- NULL, observed as Python None
A-2 set_aside- "Total Small Business"
Null-value count- 1
The count measures missing set_aside values. Null alone does not establish row age or business meaning.
Failure check · fresh version-1 fixture
Injected failure- Raise after ALTER TABLE, before UPDATE.
Expected schema- No set_aside column.
Expected version and rows- Version 1; original A-1 preserved.
Check both schema and version. Checking only the version would miss the original half-applied migration defect.
Predict the output
Predict the three printed lines.
Check your prediction
2
[('A-1', None), ('A-2', 'Total Small Business')]
1
A-1 predates the column, so its value is None, which is Python's representation of SQL NULL. In this fixture one row is missing that value. A null does not itself mean "no set-aside" or prove when the row was created.
Modify the code
Change the ALTER TABLE line to ALTER TABLE notices ADD COLUMN set_aside TEXT NOT NULL, and predict what happens.
What changes, and why
The migration raises:
sqlite3.OperationalError: Cannot add a NOT NULL column with default value NULL
The exception leaves the explicitly started transaction, so it is rolled back. The version-update statement is never reached. The database stays on version 1 with its original shape, which is exactly the behaviour you want from a failed migration: no half-applied state.
The database is refusing an impossible instruction. The existing row has no value for the new column, and the column may not be NULL, so there is no value it could take. The three ways forward are to allow NULL, to supply a DEFAULT the existing rows can take, or to add the column nullable, backfill it in a second migration, and only then tighten the constraint. The third is the standard approach on a table with real data in it.
Debug the bug
An assistant was asked for a search by agency code. It produced this and said the input is escaped.
def find_by_agency(connection, agency_code):
sql = "SELECT notice_id FROM notices WHERE agency_code = '" + agency_code + "'"
return connection.execute(sql).fetchall()
print(find_by_agency(connection, "GSA"))
print(find_by_agency(connection, "GSA' OR '1'='1"))
What's actually wrong
Nothing is escaped, and the second call returns every row in the table.
Follow the string. With the second input, sql becomes:
SELECT notice_id FROM notices WHERE agency_code = 'GSA' OR '1'='1'
The input closed the quoted value early and appended a condition that is always true. The database is not being tricked; it is faithfully running the statement it was handed, and the statement is no longer the one the programmer wrote.
Returning every row is the mild version. Because the whole rest of the statement is under the caller's control, the same hole permits reading other tables through a UNION, and, wherever a driver allows several statements in one call, appending a DROP TABLE. Treat any concatenated SQL as full control of the database by whoever supplies the value.
The fix is one line, and it is not "escape the quotes":
def find_by_agency(connection, agency_code):
return connection.execute(
"SELECT notice_id FROM notices WHERE agency_code = ?", (agency_code,)
).fetchall()
Now the second call returns an empty list, because no agency is literally named GSA' OR '1'='1. Finding nothing is the correct answer to a search for a value that does not exist.
Hand-written escaping is not an acceptable substitute. It has to be perfect for every input, every encoding, and every database version, and parameter binding is already perfect by construction.
Try it yourself
The query below is built by concatenation and is handed an input designed to end the statement early. Rewrite it with parameter binding, then confirm both that the input matches nothing and that the table is intact.
Loading this exercise…
Practical challenge (optional)
Optional: write migration 0003 that adds a nullable posted_date column and updates the schema version. Start BEGIN inside a with connection: block before ALTER TABLE, on a connection with no pending transaction, and confirm the pre-existing rows survive with NULL in the new column. Then deliberately put a failing statement after ALTER TABLE inside the same transaction, run it on a fresh copy of the version-2 database, and confirm both that the new column is absent and that the schema version did not advance. Proving to yourself that a failed migration leaves no half-applied state is worth far more than reading that it should.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Why is parameter binding a guarantee rather than a careful habit?
- What cannot be passed as a parameter, and what should you do instead?
- What does a
with connection:block do when a statement inside it raises? - Why must a migration never be edited after it has been applied?
Answers
- The statement and the values travel to the database by separate paths, so a value can never be interpreted as SQL. There is no input for which it fails.
- Table and column names. A name chosen at run time must be checked against an allowlist written into your code, because there is no placeholder for it.
- It rolls back the open transaction if an exception escapes; entering the block does not itself start one. Explicitly begin the migration before its schema-changing statement so that the schema and version update roll back together.
- Databases that already ran it would keep the old shape while reporting the same version number as databases that ran the edited version. Changes go in a new numbered migration.
Sign in to track your progress on this exercise.
Summary and next step
Every value binds as a parameter, names are allowlisted rather than interpolated, related writes belong in one transaction, and migrations are numbered, forward-only, and tested on both empty and populated databases. The assistant now has durable storage it can be trusted with. Module 10 turns the whole thing into a command you can run twice without fear.