Query Parameterization: Bind Parameters, Prepared Statements, and SQL Injection


Query Parameterization: Bind Parameters, Prepared Statements, and SQL Injection

Parameterized queries are simultaneously the most important security practice and a significant performance optimization. This article explains how bind parameters work, how PostgreSQL caches query plans, and why parameterization is non-negotiable.

SQL Injection: The Problem

Without parameterization, user input is concatenated into SQL strings:




# DANGEROUS: Never do this


user_id = request.args.get("id")


cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")





An attacker provides `1; DROP TABLE users;` as the `id` parameter, and the query becomes:




SELECT * FROM users WHERE id = 1; DROP TABLE users;





Parameterization prevents this by separating SQL code from data:




# SAFE: Use parameterized query


cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))





The database receives the SQL structure and the parameter values separately. The parameter value `1; DROP TABLE users;` is treated as a literal string, not executable SQL.

Bind Parameters

PostgreSQL supports two parameter syntaxes depending on the driver:

**psycopg2** (`%s`):




cursor.execute(


"INSERT INTO users (email, name, age) VALUES (%s, %s, %s)",


("alice@example.com", "Alice", 30)


)





**asyncpg** (`$1`, `$2`):




await conn.execute(


"INSERT INTO users (email, name, age) VALUES ($1, $2, $3)",


"alice@example.com", "Alice", 30


)





**JDBC** (`?`):




PreparedStatement stmt = conn.prepareStatement(


"SELECT * FROM users WHERE email = ?"


);


stmt.setString(1, "alice@example.com");


ResultSet rs = stmt.executeQuery();





Prepared Statements

PostgreSQL separates prepared statements into two phases:


* **PREPARE**: The database parses, analyzes, and plans the query.

2\. **EXECUTE**: The database runs the plan with specific parameter values.




-- Explicit prepared statement


PREPARE find_user(INTEGER) AS


SELECT * FROM users WHERE id = $1;




-- Execute with parameter


EXECUTE find_user(42);


EXECUTE find_user(99);




-- Deallocate when done


DEALLOCATE find_user;





Automatic Prepared Statements in Drivers

Most PostgreSQL drivers implement automatic prepared statement caching:




import psycopg2


from psycopg2 import pool




# psycopg2 automatically caches prepared statements per connection


conn = psycopg2.connect("dbname=mydb")


cursor = conn.cursor()




# First call: prepares and executes


cursor.execute("SELECT * FROM users WHERE id = %s", (42,))




# Second call: reuses cached prepared statement


cursor.execute("SELECT * FROM users WHERE id = %s", (99,))





Generic Query Plans

For prepared statements with bind parameters, PostgreSQL generates a **generic plan** after the fifth execution. The generic plan assumes average parameter values rather than specific ones:




PREPARE search_orders(INTEGER, TEXT) AS


SELECT * FROM orders WHERE user_id = $1 AND status = $2;




-- First 5 executions: custom plans


EXECUTE search_orders(42, 'paid');


EXECUTE search_orders(99, 'pending');




-- After 5: switches to generic plan


-- The generic plan might use a hash join where the custom plan used nested loops





Generic plans are more stable but can be suboptimal for skewed data distributions. Use `PLAN_CACHE_MODE` in some drivers or disable generic plans via `SET plan_cache_mode = force_custom_plan`:




-- Force custom plans for this prepared statement


SET plan_cache_mode = force_custom_plan;


PREPARE find_vip(INTEGER) AS


SELECT * FROM orders WHERE user_id = $1 AND total > 1000;





Plan Caching

How prepared statements interact with connection pooling:




Application → Connection Pool → PostgreSQL





If each application request gets a different pooled connection, prepared statements are not reused across requests. Solutions:


* **Application-side caching**: Cache prepared statement text and let the driver prepare on each connection.

2\. **Server-side prepared statements**: Use `PREPARE`/`EXECUTE` explicitly, but manage lifecycle. 3\. **Statement pooling**: PgBouncer with `pool_mode = session` shares the same connection for the same client.

pg_stat_statements Insights




CREATE EXTENSION pg_stat_statements;




SELECT query, calls, total_exec_time, rows,


mean_exec_time, stddev_exec_time


FROM pg_stat_statements


WHERE query LIKE '%users%'


ORDER BY total_exec_time DESC


LIMIT 10;





High `stddev_exec_time` on parameterized queries suggests plan instability: the same query performs very differently depending on parameter values.

Parameterization Beyond SQL Injection

Parameterization also handles type conversion automatically:




# Without parameterization: type conversion issues


# f-string produces: ... WHERE created_at = 2026-05-12 (subtraction!)


cursor.execute(f"SELECT * FROM orders WHERE created_at = '{date_string}'")




# With parameterization: driver handles quoting


cursor.execute("SELECT * FROM orders WHERE created_at = %s", (date_obj,))





Best Practices

Always Parameterize




# Good


cursor.execute("UPDATE users SET email = %s WHERE id = %s", (new_email, uid))




# Bad: f-string or string concatenation


cursor.execute(f"UPDATE users SET email = '{new_email}' WHERE id = {uid}")





Dynamic IN Clauses

For variable-length IN clauses, use `ANY(ARRAY[...])`:




user_ids = [1, 2, 3, 4, 5]


cursor.execute(


"SELECT * FROM users WHERE id = ANY(%s)",


(user_ids,)


)





Or generate positional parameters:




placeholders = ','.join(['%s'] * len(user_ids))


cursor.execute(


f"SELECT * FROM users WHERE id IN ({placeholders})",


user_ids


)





Batch Execution




data = [


("alice@example.com", "Alice"),


("bob@example.com", "Bob"),


("carol@example.com", "Carol"),


]




# Uses server-side prepared statement for all rows


psycopg2.extras.execute_values(


cursor,


"INSERT INTO users (email, name) VALUES %s",


data,


template="(%s, %s)"


)





Stored Procedures




CREATE FUNCTION get_user(p_id INTEGER)


RETURNS users AS $$


SELECT * FROM users WHERE id = p_id;


$$ LANGUAGE sql STABLE;




-- Call with parameter


SELECT * FROM get_user(42);





Common Mistakes


* **Escaping is not parameterization**: `escape_string()` or `quote_ident()` are error-prone. Use bind parameters.

2\. **Dynamic identifiers cannot be parameterized**: Table/column names must be validated separately: ```python # Column name cannot be a bind parameter! cursor.execute("SELECT %s FROM users", ("email",)) # Always validate dynamic identifiers against a whitelist allowed_columns = {'email', 'name', 'age'} if column not in allowed_columns: raise ValueError(f"Invalid column: {column}") ``` 3\. **Stored procedures still need parameterization**: Do not concatenate input inside procedures.

Parameterization is the single most impactful practice for database security and performance. It is supported by every modern database driver and should be used for every query that includes any data from outside the SQL text.