How to use SQLx in Rust

Add SQLx with runtime features, create a connection pool, and run async queries to interact with your database in Rust.

When the database changes, your code should break

You rename a column in your database. You push the code to production. The application starts. The first user clicks a button. The server crashes with a database error. In Python or JavaScript, this is a normal Tuesday. You catch the error in logs, fix it, and redeploy. In Rust, the compiler should have stopped you before you could push.

SQLx makes that happen. It checks your SQL queries against the actual database schema at compile time. If the query is wrong, the build fails. No runtime surprises. No crashing servers. The database error becomes a compiler error, and compiler errors are free to fix.

How SQLx bridges Rust and SQL

SQLx is an asynchronous SQL toolkit. The name stands for "SQL eXtended," but the history doesn't matter. What matters is the mechanism. Most database drivers let you send a string to the database and hope for the best. SQLx connects to your database during the build process, introspects the schema, and generates Rust types that match your tables.

Think of SQLx as a strict contract checker. The database signs a contract saying, "I have a table called users with an id and an email." SQLx reads that contract. When you write a query, SQLx checks it against the contract. If you ask for a column that doesn't exist, SQLx screams. If you try to map a text column to an integer, SQLx screams. The code doesn't compile until the contract is honored.

This requires a running database during development. SQLx connects to the database specified in your DATABASE_URL environment variable. It runs introspection queries to learn the schema. It caches the results. If the schema changes, the next build catches the mismatch immediately.

Setting up the crate

Rust crates use feature flags to keep compile times fast. SQLx supports many databases. You don't want to compile code for MySQL if you only use Postgres. You enable features in Cargo.toml.

[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
tokio = { version = "1.0", features = ["full"] }

The postgres feature adds Postgres support. The runtime-tokio-rustls feature adds async support for the Tokio runtime with TLS encryption. If you use a different database, swap postgres for sqlite, mysql, or mssql. If you use a different async runtime, swap tokio for async-std.

Convention dictates using runtime-tokio-rustls for most projects. Tokio is the standard async runtime in Rust. Rustls is a pure-Rust TLS implementation. It avoids linking to system OpenSSL libraries, which simplifies deployment. Stick to the convention unless you have a specific reason not to.

Minimal connection and query

You need a connection pool. A pool manages multiple database connections. It reuses connections to avoid the overhead of establishing new TCP handshakes. You create the pool once, then pass it around your application.

use sqlx::postgres::PgPool;
use std::env;

#[tokio::main]
async fn main() {
    // Load connection string from environment variable
    // Never hardcode credentials in source code
    let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    
    // Create a pool of connections
    // The pool manages connection lifecycle automatically
    let pool = PgPool::connect(&db_url)
        .await
        .expect("Failed to connect to database");
    
    // Execute a query and fetch a single scalar value
    // query_scalar returns one column from one row
    let version: String = sqlx::query_scalar("SELECT version()")
        .fetch_one(&pool)
        .await
        .expect("Query failed");
    
    println!("Postgres version: {version}");
}

The DATABASE_URL environment variable is the standard convention. Tools like cargo sqlx and many frameworks expect this variable. Set it in your shell or a .env file. The pool handles the rest. Trust the pool. It manages the connections so you don't have to.

Mapping rows to structs

Queries return rows. Rows are just data. You want Rust structs. SQLx provides the FromRow derive macro. It generates the code to map database columns to struct fields.

use sqlx::FromRow;

#[derive(Debug, FromRow)]
struct User {
    id: i32,
    email: String,
}

async fn get_user(pool: &PgPool, user_id: i32) -> Result<User, sqlx::Error> {
    // query_as! maps the result to the User struct
    // The $1 placeholder prevents SQL injection
    sqlx::query_as!(User, "SELECT id, email FROM users WHERE id = $1", user_id)
        .fetch_one(pool)
        .await
}

The query_as! macro is the standard way to fetch structs. It checks the query at compile time. It verifies that the columns id and email exist. It verifies that the types match. If the database column is username instead of email, the compiler rejects the code. If the database column is BIGINT instead of INT, the compiler rejects the code.

Convention favors query_as! over query! whenever you have a struct. query! returns raw rows. You have to manually extract columns. query_as! is safer and less verbose. Use the macro. It does the heavy lifting.

Handling nullable columns

Rust has no null. Databases have null. SQLx bridges the gap with Option<T>. If a database column can be null, the corresponding struct field must be Option<T>. If the column is not null, the field is T.

#[derive(Debug, FromRow)]
struct User {
    id: i32,
    email: String,
    // The bio column can be null in the database
    // The Rust field must be Option<String>
    bio: Option<String>,
}

If you get this wrong, the compiler rejects you with E0277 (trait bound not satisfied). The error message points to the type mismatch. It says something like "the trait Decode is not implemented for String". This happens because SQLx tries to decode a nullable column into a non-nullable type. Fix the struct to match the schema. The compiler forces your code to match the database reality.

Parameterized queries and safety

Never concatenate strings to build SQL. SQL injection is a real threat. SQLx uses parameterized queries. You write placeholders in the SQL string. You pass the values as arguments. SQLx sends the query and the values separately to the database. The database treats the values as data, not as code.

async fn find_user(pool: &PgPool, name: &str) -> Result<User, sqlx::Error> {
    // $1 is the placeholder for the first parameter
    // The name argument is bound to $1 safely
    sqlx::query_as!(User, "SELECT id, email, bio FROM users WHERE email = $1", name)
        .fetch_one(pool)
        .await
}

The macro checks the number of parameters. It checks the types. If you pass a string where an integer is expected, you get E0308 (mismatched types). The compiler catches the error before the code runs. Parameterized queries are the only way to write safe SQL. Trust the placeholders. They prevent injection attacks automatically.

Offline mode for CI/CD

Compile-time checking requires a database. This creates a problem for Continuous Integration. Your CI server doesn't have a database running. SQLx solves this with offline mode.

You run cargo sqlx prepare on your local machine. The command connects to the database, checks every query, and saves the results in a .sqlx directory. You commit that directory to git. When CI runs, SQLx reads the cached results instead of connecting to a database. The build still checks your queries, but it doesn't need a live server.

This is the standard workflow for Rust projects using SQLx. Commit the .sqlx folder. Treat it as part of your build artifacts. If you change the schema, run cargo sqlx prepare again and commit the updates. The offline cache keeps your builds fast and reliable.

Pitfalls and compiler errors

Feature flags are the most common stumbling block. If you forget to add the postgres feature, the compiler won't find PgPool. You get a "cannot find type" error. Check your Cargo.toml. The dependency tree grows only with what you enable.

Type mismatches are the second common issue. If you try to map a database column to a Rust type that SQLx doesn't know, you get E0277 (trait bound not satisfied). The fix is usually adding the right feature flag or implementing the trait. For custom types, you need to implement Type, Encode, and Decode. For standard types, just match the Rust type to the database type.

Blocking the async runtime is a silent killer. SQLx is async. If you call a blocking function inside an async context, you block the entire thread pool. The application stalls. Use tokio::task::spawn_blocking for heavy CPU work or blocking I/O. Keep the async flow unblocked. The runtime expects non-blocking operations.

Decision matrix

Use SQLx when you want compile-time checked SQL and an async runtime. Use Diesel when you prefer a query builder DSL and don't mind compile-time overhead from macro expansion. Use sea-orm when you want an active-record style ORM that handles migrations and associations automatically. Reach for a raw driver like tokio-postgres when you need maximum performance and are writing your own abstraction layer.

Pick the tool that matches your comfort with SQL. SQLx requires you to write SQL. It checks your SQL. It doesn't hide the SQL. If you like writing SQL, SQLx is the best choice. If you want to avoid SQL, look at an ORM.

Where to go next