Imagine a membership page that lets someone search for a member by ID. The application receives the ID and asks a database for the matching name. That request to the database is a query, written here in SQL, short for Structured Query Language.
The application needs to keep two things separate: the SQL instructions it chose and the value a person submitted. If it builds the instructions by joining them with user input, that input can change how the database reads the query. This is the cause of SQL injection.
A bound parameter supplies a value separately from the SQL instructions. The database uses that value in the place marked by a placeholder. We will compare these two approaches using a fictional members table: ID 3 belongs to Ada, and ID 8 belongs to Lane.
Before you begin
- Recommended first
- Notes: scope, command, result, time, HTTP methods, headers, status codes, cookies, How a web app is put together, Same-origin policy, SQL enough to read a query, Reading a file as text and as bytes
- Estimated time
- 16 min reading, 12 min activity. Setup and optional practice may take longer.
- Where you work
- Read the code examples and recorded trace in your browser. No database, account, Python installation, or running web application is needed. Use only the fictional members table and examples supplied here.
- Without a lab
- The page includes the table source, demonstration script, and trace needed for the activity. You can follow the changing values on paper without running the script. Answer checking and saved progress require JavaScript and browser storage.
After this lesson you can:
- Identify where user input is joined into SQL statement text.
- Read a placeholder and the value supplied separately to it.
- Explain why binding values addresses this injection risk while hiding errors does not.
01Example
Read these as two ways to construct a lookup for ID 3. In the first, the submitted value becomes part of the statement text. In the second, the question mark marks a value to supply separately. This example shows the construction of a query; it does not show a returned database row.
Value submitted: '3'
Joined into one statement:
SELECT name FROM members WHERE id = 3
Statement with a placeholder:
SELECT name FROM members WHERE id = ?
Value supplied separately:
('3',)02The statement and the value
In SELECT name FROM members WHERE id = 3, SELECT names the information to return, FROM names the table, and WHERE gives the condition used to choose rows. The intended task is to return the name from the row whose id matches the submitted value.
String concatenation means joining pieces of text. In the first construction, the application joins the SQL text and the submitted value into one string. The database then reads that whole string as SQL. Characters supplied by a user can therefore become part of the instructions.
In the second construction, the statement ends with id = ?. The question mark is a placeholder for a value. The application gives the database the statement and the value separately, a process called binding. Changing the supplied ID changes the value being compared, while the statement stays the same.
The tuple ('3',) is Python's way to group the one value supplied to this placeholder. The comma makes it a one-item tuple. The quotation marks show that 3 is a Python string; they are not extra SQL text to paste around the placeholder.
The illustration below uses a name search instead of an ID search. Ada fits into the joined text, but the apostrophe in O'Brien closes its SQL string too early. A legitimate name can expose the same construction problem that an attacker could exploit. The parameterized version keeps that whole name as one value.
Explain how user input can change a SQL query and how bound parameters keep values separate from instructions.Model. A teaching model drawn by hand. It shows the idea, not real tool output.
Read the full explanation below.
SQL construction model · no database is contacted or query executed
Supplied input: Worked value '3' · value 3
Joined statement text
SELECT name FROM members WHERE id = 3The input is pasted into the SQL text. This drawing does not execute it.
Separate parameter tuple
Not separately bound in joined modeJoining has no separate parameter tuple.
Joining lets input characters enter the SQL statement text.
Bound values separate data from SQL structure. This model does not show execution, authorization, a database result or complete application security.
The joined construction places 3 inside the statement text; constructing it does not execute SQL.
Follow the explanation
- The joined construction places 3 inside the statement text; constructing it does not execute SQL.
- The statement remains SELECT name FROM members WHERE id = ? and the separate tuple is exactly ('3',).
Complete supplied constructions
Worked value '3'
Joined text:
SELECT name FROM members WHERE id = 3Fixed parameterized statement:
SELECT name FROM members WHERE id = ?Separate Python tuple:
('3',)The statement and display tuple are supplied independently. No SQL is evaluated; this finite diagram demonstrates construction only.
03Why ordinary input can hide the problem
Both versions can look reasonable when the input is a digit such as 3 or 8. A successful lookup with those values does not show that the application handles other input safely. The important question is how the statement is built.
SQL injection happens when submitted text can change the SQL instructions. Depending on the query and the database account's permissions, that can expose or change data the application did not intend to allow. An error caused by a quote is a clue to investigate; an error by itself does not establish an attack or its impact.
For the value in this example, use the database library's placeholder support and pass the value separately. A form label such as numbers only cannot enforce this on the server. Checking the value's expected type can help the application, and binding keeps it out of the SQL syntax.
04Read the Python call
Python's sqlite3 module provides tools for working with SQLite databases. In its examples, cur represents a database cursor: the object used here to run a statement. Its execute method receives the SQL statement as the first argument and the values to bind as the second.
The line cur.execute("SELECT name FROM members WHERE id = ?", (value,)) has one placeholder and a tuple containing one value. The question mark stays in the SQL string. Python passes value through that method instead of joining it onto the string.
The code for the checks also reads form['id'], the submitted ID, and uses .strip() to remove whitespace from its ends. It shows a joined statement stored in q alongside the execute call. Creating q builds a string; it does not run that string. fetchone reads one returned row, and print displays it.
This approach binds data values such as an ID or a name. A placeholder is not a substitute for an entire SQL instruction or a table name. This lesson keeps the table, column, and comparison fixed.
05Trace the activity value
The activity changes the submitted ID from 3 to 8. Use the files below to trace where that new value appears. query-demo.py prints the query constructions; it does not connect to a database or return a member's name.
The recorded trace contains the script's printed lines. Its separate table note describes a different database read. Keep those two operations separate as you work. You can complete the activity entirely from the printed text.
CREATE TABLE members (id INTEGER, name TEXT);
INSERT INTO members (id, name) VALUES (3, 'Ada');
INSERT INTO members (id, name) VALUES (8, 'Lane');
"""Print two ways of constructing a lookup for a fictional member ID.
This demonstration only prints text. It does not open a database,
execute SQL, or return a member's name. Read the printed statement
and the separate tuple to see where the submitted value appears.
"""
def show(label, value):
pasted = "SELECT name FROM members WHERE id = " + value
print(label)
print("pasted text: " + pasted)
print("bound statement: SELECT name FROM members WHERE id = ?")
print("bound tuple: " + repr((value,)))
def main():
show("worked", "3")
show("activity", "8")
if __name__ == "__main__":
main()
Query construction trace
Recorded output from query-demo.py using Python 3.12.3.
The script prints query text and tuples; it does not open a database.
Script output
worked
pasted text: SELECT name FROM members WHERE id = 3
bound statement: SELECT name FROM members WHERE id = ?
bound tuple: ('3',)
activity
pasted text: SELECT name FROM members WHERE id = 8
bound statement: SELECT name FROM members WHERE id = ?
bound tuple: ('8',)
Reading notes
The joined statement contains the submitted digit inside its SQL text.
The statement with a placeholder stays the same for both values.
The separate tuple changes from ('3',) to ('8',).
Separate table lookup
An existing SQLite 3.45.1 in-memory read of members.sql returned:
3|Ada
8|Lane
Those rows are not output from query-demo.py.
- Find the two rows in members.sql. Identify the id and name columns, then note which name belongs to activity ID 8.
- In query-demo.py, find show("activity", "8"). The second argument is the value used for this activity. Follow it into the function's value variable.
- Find the line that builds pasted using +. In the activity trace, record the complete statement it produces. The submitted digit appears inside the SQL statement text.
- Compare that with the bound statement and bound tuple. The statement still ends in id = ?, while ('8',) carries the activity value separately.
- Check your explanation: the joined statement changed from id = 3 to id = 8. The statement with a placeholder stayed the same; only its separate value changed. The table associates ID 8 with Lane, but the demonstration script prints query text rather than that name.
- Finish by explaining which construction treats the submitted ID as data. No script run or database setup is required for this activity.
06Why hiding errors does not fix the query
An application may replace a detailed database error with a generic message for visitors. This changes what the visitor sees. It does not change the statement the application sent to the database.
If user input is still joined into SQL, the construction problem remains. For the lookup value taught here, the fix is to use a statement with a placeholder and bind the value. Error handling and parameter binding solve different problems.
07Optional challenge
Allow about ten minutes. Explain to another learner why removing the apostrophe from O'Brien is a poor replacement for binding the name. Your explanation should describe both the legitimate input that would change and the way the query should receive that input.
Check your reasoning: the name contains an ordinary punctuation mark. A bound parameter allows it to remain part of the name while keeping the SQL statement fixed.
08What to remember
Follow user input to the point where a query is constructed. Joining that input into SQL allows data to become instructions. Binding a value through a placeholder preserves the separation.
Use that same distinction when reviewing a proposed fix: does it change how the query receives input, or only how the page displays a result?
09Cards for the checks
The checks below use these cards. They are original fictional text, safe to read anywhere.
Code for check 2Owned fixture. An original file made for this course. Follow the activity's instructions in your own lab.
value = form['id'].strip()
q = "SELECT name FROM members WHERE id = " + value
cur.execute("SELECT name FROM members WHERE id = ?", (value,))
print(cur.fetchone())
10Fresh cases for the checks
Use these fictional cases for the checks below. They have different inputs from the worked examples. Read them here or make notes; no installation or external service is needed.
SQL source A — fictional source only; the prefix variable holds fixed application textOwned fixture. An original file made for this course. Follow the activity's instructions in your own lab.
1 | value = form['id'].strip()
2 | prefix = "SELECT name FROM members WHERE id = "
3 | statement = prefix + value
4 | cur.execute(statement)
5 | print(cur.fetchone())
As in the lesson, + joins strings. These lines have not been run.
SQL source B — independent ID lookup to repair using the lesson's sqlite3 callOwned fixture. An original file made for this course. Follow the activity's instructions in your own lab.
value = form['id'].strip()
cur.execute("SELECT name FROM members WHERE id = " + value)
Keep the table, selected column, and comparison unchanged.
Only replace the execute call. No database result is supplied.
SQL review C — independent change to visitor-facing errorsOwned fixture. An original file made for this course. Follow the activity's instructions in your own lab.
Before: submitted ID text is joined into SQL; visitors see detailed database errors.
After: the same submitted ID text is joined into SQL; visitors see only 'Lookup unavailable'.
The statement construction, execution call, and database permissions are unchanged.
No request trace, returned rows, or evidence of an attack is supplied.
SQL source D — name lookup prediction; no database was runOwned fixture. An original file made for this course. Follow the activity's instructions in your own lab.
statement = "SELECT name FROM members WHERE name = ?"
value = form['name']
cur.execute(statement, (value,))
Change the submitted name from Mira to D'Arcy, preserving the apostrophe.
The statement and the code stay unchanged. No table contents are provided.
11Optional explanation: follow the value
Spend up to five minutes explaining a submitted value's route through one joined lookup and one bound lookup. Do not run a database or invent an attack outcome.
Self-check: distinguish reading input, constructing a statement, executing a statement, and observing a result. Explain how the placeholder and separate tuple preserve a value, why hiding an error changes a different mechanism, and why source alone does not establish returned rows or impact. This explanation is self-reviewed, not automatically graded.
12Common mistakes
If you see ('3',) in your activity notes, return to the activity call in the script. It supplies 8. The value 3 belongs to the worked example.
If you expect query-demo.py to print Lane, read its print calls. They display the constructed statement and tuple. The members table shows which name matches the ID, but this script does not perform that lookup.
If you put quotes around the question mark in the SQL string, compare it with the execute example. The placeholder stands on its own; Python receives the string value in the separate tuple.
If a generic error page looks like a complete fix, trace the input again. Check whether the query still joins submitted text into the SQL statement.
References
- Python 3.12: Binding values with placeholders
Explains the sqlite3 API's question-mark placeholders and passing values separately to execute. Includes the warning against building queries with Python string operations.
- OWASP: SQL injection prevention
See Defense Option 1 for parameterized queries, and the discussion of query parts that cannot be supplied as bound values.
- PortSwigger: SQL injection
Optional further reading about how injection changes database queries and how parameterized queries prevent it. Complete this page's supplied activity before exploring further practice.