How to Use Prepared Statements in Rust

Use external Rust libraries like sqlx or diesel to execute parameterized queries that prevent SQL injection.

The string concatenation trap

You are building a user lookup. You grab an ID from an HTTP request, stick it into a SQL string, and run it. It works perfectly in development. Then a user signs up with the username Robert'); DROP TABLE users;--. Your database executes the drop command. You realize that treating SQL like a regular string is a direct path to data loss.

Rust does not stop you from building strings. The language trusts you to pick the right tool for the job. The right tool here is a prepared statement. Prepared statements separate the query structure from the data. You send the template to the database first. The database compiles it into an execution plan. Then you send the parameters. The database plugs the values into the pre-compiled plan without ever parsing them as code.

Think of it like a restaurant order ticket. The kitchen has a fixed template: [ITEM] for table [NUMBER]. The waiter fills in the blanks. The kitchen never reads spaghetti as a cooking instruction. It just knows it is the item. SQL drivers in Rust automate this separation. You never manually concatenate. You bind variables to placeholders.

Stop treating SQL as a string format. Treat it as a typed template.

How binding actually works

Rust has no standard library for databases. You pick a driver crate. Each driver handles prepared statements slightly differently, but the core pattern is identical. You write a query with placeholders, call a bind method, and execute. The driver handles the preparation step automatically. You rarely see a prepare() call in Rust code because the binding chain does it for you.

The sqlx crate is a popular choice because it verifies your queries at compile time. You write the SQL as a string literal, and sqlx checks it against your actual database schema while you code.

use sqlx::{SqlitePool, Row};

/// Fetches a user's display name by their database ID.
pub async fn get_user_name(pool: &SqlitePool, user_id: i32) -> Result<Option<String>, sqlx::Error> {
    // The ? placeholder tells the driver to expect a bound parameter.
    // sqlx::query_scalar tells the driver we expect exactly one column back.
    let name = sqlx::query_scalar::<_, String>("SELECT name FROM users WHERE id = ?")
        // .bind() attaches the Rust value to the placeholder.
        // The driver handles type conversion and escaping automatically.
        .bind(user_id)
        // .fetch_optional returns None if zero rows match, avoiding a panic.
        // This keeps the function safe when the user does not exist.
        .fetch_optional(pool)
        .await?;
    Ok(name)
}

The placeholder syntax changes depending on your database. SQLite and MySQL usually accept ?. PostgreSQL uses $1, $2, and so on. sqlx normalizes this behind the scenes when you use the .bind() chain. The driver tracks the order of your .bind() calls and maps them to the placeholders left to right.

Never guess the placeholder syntax. Check your driver documentation before you write the query.

What happens under the hood

When you call .bind() and then .fetch(), three distinct steps occur. First, the driver sends the raw SQL template to the database server. The server parses the syntax, checks permissions, and builds an execution plan. This plan knows exactly where data will go. Second, the driver serializes your Rust types into a database-friendly binary format. Third, the driver sends the serialized parameters to the server. The server plugs them into the execution plan and runs it.

This separation is why SQL injection fails. The database never re-parses the parameters as SQL. A string containing DROP TABLE is treated as literal text. It matches against a column value, not against the query structure.

Rust adds a second layer of protection through its type system. The .bind() method expects a specific type. If you try to bind a String to a column that expects an i64, the compiler rejects the code. You get an E0277 trait bound error or an E0308 mismatched types error before the program even starts. This catches a whole class of runtime bugs that other languages only discover when a query fails in production.

Convention aside: the Rust database community strongly prefers explicit type mapping over implicit casting. If your database stores a timestamp as TEXT, map it to a String in Rust and parse it later. Do not force the driver to guess. Explicit mapping keeps your code predictable.

Let the type system verify your data before it touches the network.

Realistic usage with type mapping

Production code rarely fetches a single column. You usually map rows into structs, handle missing records gracefully, and manage connection pools. Here is how that looks with sqlx and the FromRow derive macro.

use sqlx::{FromRow, SqlitePool};

#[derive(FromRow, Debug)]
/// Represents a user record pulled directly from the database.
struct User {
    id: i32,
    name: String,
    email: String,
}

/// Retrieves a full user record or returns a clear not-found error.
pub async fn find_user(pool: &SqlitePool, id: i32) -> Result<User, sqlx::Error> {
    // We use a named placeholder for readability, though ? works identically.
    // sqlx matches the bind order to the placeholder order regardless of naming.
    let user = sqlx::query_as::<_, User>("SELECT id, name, email FROM users WHERE id = ?")
        .bind(id)
        // .fetch_one panics if zero rows are returned, which is intentional here.
        // We want the error to bubble up so the caller knows the user is missing.
        .fetch_one(pool)
        .await?;
    Ok(user)
}

The #[derive(FromRow)] macro generates the mapping code. It matches struct field names to SQL column names. If your database returns a column that your struct does not have, the compiler complains. If your struct expects a column that the query omits, the compiler complains. You cannot accidentally drop data without the type system telling you.

Some drivers like diesel take a different approach. They use a fluent query builder instead of raw strings. You chain methods like .filter(id.eq(user_id)). The builder constructs the SQL and the binding plan simultaneously. You never see placeholders at all. The trade-off is flexibility. Raw SQL gives you full control over complex joins and window functions. Query builders give you guaranteed syntax correctness at the cost of verbosity.

Pick the tool that matches your query complexity. Do not force a query builder to do raw SQL's job.

Connection pooling and statement caching

Prepared statements interact closely with connection pooling. A prepared statement lives on a specific database connection. If you prepare a query on connection A, you cannot reuse that prepared handle on connection B. This creates a performance trap if you prepare a new statement for every request.

Database drivers solve this with statement caching. When you call .bind() and .fetch(), the driver checks an internal cache first. If the exact SQL template is already prepared on that connection, it reuses the cached plan. If not, it prepares it, caches it, and then executes. You get the performance benefit of prepared statements without managing the lifecycle manually.

The cache has a size limit. sqlx defaults to a small cache to prevent memory leaks. If you run thousands of unique queries, you will evict old plans constantly. The solution is parameterization. Instead of running SELECT * FROM logs WHERE level = 'INFO' and SELECT * FROM logs WHERE level = 'ERROR' as separate templates, you write SELECT * FROM logs WHERE level = ? and bind the level. One template covers all variations. The cache stays warm. Your queries run faster.

Convention aside: name your database connections in production logs. When a prepared statement fails, the error usually points to a connection ID. Named pools make debugging routing issues significantly easier.

Design your queries for caching. Parameterize everything that varies.

Common pitfalls and compiler signals

The biggest mistake developers make is reaching for string formatting. You will be tempted to write format!("SELECT * FROM users WHERE id = {}", id). The compiler will happily accept this. The database will execute it. And you will leave yourself open to injection. Treat SQL strings as templates, not as dynamic content. Never use format! or concat! for query construction.

Another frequent issue is placeholder syntax confusion. If you switch from SQLite to PostgreSQL, ? stops working. PostgreSQL expects $1. If you keep using ?, the driver returns a syntax error at runtime. sqlx catches this at compile time if you enable its offline feature, but diesel and tokio-postgres rely on runtime checks for syntax. Always check your driver documentation for the correct placeholder format.

Type mismatches are the third trap. You might bind a u32 to a column defined as BIGINT. The compiler might not catch this immediately if the driver uses a generic ToSql trait. You will get an E0277 trait bound error or a runtime type conversion failure. Fix it by casting your Rust type explicitly or adjusting your struct definition to match the database schema exactly.

A subtle runtime error occurs when you forget to bind a placeholder. The driver will either panic or return a parameter count mismatch error. Some drivers silently ignore extra binds, which leads to confusing results. Always match your .bind() count to your placeholder count exactly.

Test your queries against a real database during development. Compile-time checks are excellent, but they cannot verify your business logic.

Choosing your approach

You have several ways to interact with databases in Rust. Each one handles prepared statements differently. Pick the tool that matches your project's needs.

Use sqlx when you want compile-time verification of your SQL queries and type-safe row mapping. It checks your schema against your code before you run it, catching typos and missing columns early. Use diesel when you prefer a fluent query builder over raw SQL strings. It constructs queries through method chains, which eliminates placeholder syntax entirely and guarantees syntactic correctness. Use tokio-postgres when you need maximum performance and direct control over PostgreSQL protocol details. It requires manual placeholder management but gives you the lowest overhead. Use raw string queries with manual binding only when you are writing a database abstraction layer yourself. You will need to handle connection pooling, transaction management, and type mapping from scratch.

Match the driver to your team's comfort level. Do not overcomplicate a simple lookup with a heavy ORM.

Where to go next