A parameterized query sends the database instruction and its values through distinct API arguments. The database interprets the instruction as SQL and uses each parameter where a value belongs. This boundary works in embedded engines such as SQLite as well as client-server databases; it does not depend on a network exchange or on an application explicitly managing named server statements.
When it breaks
String concatenation removes that boundary. A quote supplied as part of a name can end the intended literal, and following text can introduce a condition or comment. Blocking semicolons does not fix the problem: an attacker may change the meaning of one SELECT without adding a second statement. Similarly, an API that refuses multiple statements still needs proper parameter binding.
Binding prevents a value from becoming SQL grammar, but the chosen operator still interprets the value according to its documented rules. With LIKE, percent matches a sequence of characters and underscore matches one character. A bound search string can therefore match more records than a literal-text search intended. That is a pattern-semantics issue, not evidence that parameter binding failed.
Other mistakes produce incorrect results without injection. A comma-separated string bound to one IN placeholder remains one scalar value; it is not expanded into a list. A bound column name remains a text value rather than a column reference. Type checks, allowed structural choices, and application permissions remain necessary alongside binding.
Check it quickly
Run this complete Python script with the standard sqlite3 module. It creates only an in-memory fixture and closes it after the assertions. The recorded environment was Python 3.14.4 with SQLite 3.53.0. The deliberately vulnerable query is included solely to compare behavior against three invented users:
import sqlite3
with sqlite3.connect(':memory:') as db:
db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)')
db.executemany('INSERT INTO users VALUES (?, ?)', [(1, 'Alice'), (2, 'A_1'), (3, 'Ab1')])
payload = "' OR 1=1 --"
# Deliberately vulnerable comparison, only against this in-memory fixture.
unsafe = "SELECT id FROM users WHERE name='" + payload + "' ORDER BY id"
assert sorted(db.execute(unsafe).fetchall()) == [(1,), (2,), (3,)]
assert db.execute('SELECT id FROM users WHERE name=?', (payload,)).fetchall() == []
print('concatenated: 3 rows; bound: 0 rows')
for value in ("O'Reilly", r'a\b', 'x; DROP TABLE users; --'):
assert db.execute('SELECT ?', (value,)).fetchone() == (value,)
assert db.execute('SELECT count(*) FROM users').fetchone() == (3,)
print('quote, backslash, semicolon: literal round trips')
assert db.execute('SELECT id FROM users WHERE name LIKE ? ORDER BY id', ('A_1',)).fetchall() == [(2,), (3,)]
literal = 'A_1'
escaped = literal.replace('!', '!!').replace('%', '!%').replace('_', '!_')
assert db.execute("SELECT id FROM users WHERE name LIKE ? ESCAPE '!' ORDER BY id", (escaped,)).fetchall() == [(2,)]
assert db.execute('SELECT id FROM users WHERE name=?', (literal,)).fetchall() == [(2,)]
print('bound LIKE: 2 rows; escaped LIKE and equality: 1 row')
assert db.execute('SELECT id FROM users WHERE id IN (?)', ('1,3',)).fetchall() == []
assert db.execute('SELECT id FROM users WHERE id IN (?,?) ORDER BY id', (1, 3)).fetchall() == [(1,), (3,)]
assert db.execute('SELECT ? FROM users LIMIT 1', ('name',)).fetchone() == ('name',)
assert db.execute('SELECT id FROM users ORDER BY id LIMIT ? OFFSET ?', (1, 1)).fetchall() == [(2,)]
print('IN needs separate values; bound column is a literal; LIMIT/OFFSET bind')
db.close()
The concatenated query returns all three identifiers, while the bound version returns no matches for the same input. The injected comment also removes the appended ordering clause, so the assertion sorts the vulnerable result before comparing identifiers. The test does not depend on an unordered query coincidentally returning insertion order.
The next assertions round-trip a quote, a backslash, and a semicolon-containing string exactly. The three-row count remains unchanged. These are actual calls to the SQLite driver, not a string-escaping simulation or a check that a query happens to contain a question mark. Save the SQL text and parameter assignment together when reviewing a real call site.
The bound LIKE pattern A_1 matches both A_1 and Ab1. Escaping the underscore using an explicit exclamation-mark escape reduces the result to the literal name. Escaping the escape character first is necessary when arbitrary search text can contain it. For exact matching, equality is simpler and also returns only the literal name here.
A single bound value containing 1,3 returns no identifiers. Two placeholders with two integers return identifiers one and three. The next SELECT returns the literal text name, demonstrating why a value placeholder cannot choose a projected column. Finally, bound numeric limit and offset values return identifier two. No numeric string interpolation is required for pagination.
Review the complete data path
Choose placeholders according to the actual driver. Python’s SQLite interface accepts question marks and named parameters; Psycopg uses different binding notation. Pass the parameter collection separately rather than applying language-level formatting first. A value loaded from your own database is still a value when reused in another query; storing it once does not make later interpolation safe.
For variable-length lists, construct only the required placeholder structure and bind each element, using the driver’s supported collection facilities where available. Define empty-list behavior and respect parameter-count limits. Dynamic identifiers and ordering keywords need an allowed set of query structures; filtering punctuation out of arbitrary SQL text is not a reliable substitute.
Set appropriate page-size bounds and enforce object authorization even when every value is bound. Parameterization protects the SQL grammar boundary, not the entire business operation. Tests should check ordinary results as well as adversarial strings and operator-specific edge cases, so a secure rewrite does not silently broaden searches, change ordering, or expose another user’s rows.
Sources
- Python sqlite3, driver placeholders and binding API, checked 2026-09-12.
- SQLite expressions, parameters, LIKE escaping, and IN semantics, checked 2026-09-12.
- OWASP SQL injection prevention, parameterized values and allowed structural choices, checked 2026-09-12.