How to Connect to PostgreSQL from Rust
Rust has no single default PostgreSQL driver the way Ruby has pg or Go has pgx. There are three serious, actively maintained options with genuinely different philosophies: tokio-postgres (a low-level async driver), sqlx (an async toolkit with compile-time query checking), and Diesel (a synchronous ORM with a type-safe query builder). This guide shows working code for each, then covers what actually causes production issues: the connection-task split, pooling, TLS, and build dependencies.
The options
| Library | Model | When to use it | Version (as of July 2026) |
|---|---|---|---|
tokio-postgres | Async driver, pure Rust | You want SQL and full control, async | 0.7.18 |
postgres | Sync wrapper around tokio-postgres | Scripts, CLIs, no async runtime | 0.19.14 |
sqlx | Async toolkit, pure Rust | Compile-time checked SQL, no ORM | 0.9.0 |
| Diesel | Sync ORM + query builder | Schema-first apps, type-safe DSL | 2.3.11 |
Two architectural facts worth knowing before you pick:
tokio-postgresandsqlxare pure Rust -- they implement the PostgreSQL wire protocol directly and do not link against libpq. Diesel's PostgreSQL backend binds to libpq viapq-sys, so it inherits a C build dependency.tokio-postgresis the foundation crate:postgresis a blocking wrapper around it, and the popular poolers (deadpool-postgres,bb8-postgres) managetokio-postgresclients.
tokio-postgres: the low-level async driver
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-postgres = "0.7"A minimal connection:
use tokio_postgres::NoTls;
#[tokio::main]
async fn main() -> Result<(), tokio_postgres::Error> {
let (client, connection) = tokio_postgres::connect(
"host=localhost user=app password=secret dbname=mydb",
NoTls,
)
.await?;
// The connection object performs the actual communication,
// so it must be spawned onto the runtime.
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {e}");
}
});
let row = client
.query_one("SELECT $1::TEXT || ' ' || version()", &[&"connected:"])
.await?;
let msg: &str = row.get(0);
println!("{msg}");
Ok(())
}The connection-task gotcha
tokio_postgres::connect returns a pair: a Client (what you run queries on) and a Connection (the future that actually talks to the socket). If you forget to tokio::spawn the connection, every query on the client hangs forever -- no error, no timeout, just a stuck await. This is the single most common first-hour mistake with this crate. The client and connection communicate through a channel; the connection future has to be polled for anything to move.
Parameterized queries
Placeholders are PostgreSQL-native $1, $2, ... (not ?), and parameters are passed as a slice of trait-object references:
let rows = client
.query(
"SELECT id, name FROM users WHERE region = $1 AND created_at > $2",
&[&"emea", &cutoff],
)
.await?;
for row in rows {
let id: i64 = row.get("id");
let name: &str = row.get("name");
println!("{id}: {name}");
}row.get panics on a type mismatch or unknown column; use row.try_get in code paths where you'd rather have a Result. Type mapping is strict -- a PostgreSQL INT8 must be read as i64, not i32, and NUMERIC needs a crate feature (with-rust_decimal-1 or similar) or it has no Rust representation at all.
Statements run through query/execute are prepared and cached per connection automatically.
postgres: the same driver, blocking
For CLIs and scripts that don't need an async runtime, the postgres crate wraps tokio-postgres and hides the connection task:
use postgres::{Client, NoTls};
fn main() -> Result<(), postgres::Error> {
let mut client = Client::connect("host=localhost user=app dbname=mydb", NoTls)?;
for row in client.query("SELECT id, name FROM users LIMIT 5", &[])? {
let id: i64 = row.get(0);
let name: &str = row.get(1);
println!("{id}: {name}");
}
Ok(())
}Same $1 placeholders, same type rules, no spawn required. It embeds a single-threaded tokio runtime internally, so don't call it from inside an async context -- you'll get a nested-runtime panic.
Pooling: bring your own
tokio-postgres has no built-in pool. For anything beyond a one-off task you want one, because each new connection costs a TCP + TLS + auth round-trip and a PostgreSQL backend process. The two standard choices:
deadpool-postgres (0.14.1) -- the most common pick:
use deadpool_postgres::{Config, Runtime};
use tokio_postgres::NoTls;
let mut cfg = Config::new();
cfg.host = Some("localhost".into());
cfg.user = Some("app".into());
cfg.password = Some("secret".into());
cfg.dbname = Some("mydb".into());
let pool = cfg.create_pool(Some(Runtime::Tokio1), NoTls)?;
let client = pool.get().await?;
let row = client.query_one("SELECT count(*) FROM users", &[]).await?;bb8 (0.9.1, with bb8-postgres 0.9.0) -- same idea, Futures-based API modeled on r2d2.
Size the pool against the server, not the application's appetite: PostgreSQL's default max_connections is 100, and every replica of your service multiplies the pool. pool_size × instances < max_connections (minus a few for admin sessions) is the rule. If you front PostgreSQL with PgBouncer in transaction mode, be aware that automatically prepared statements break -- the same trap as in every other language; use session mode or configure the driver to avoid named prepared statements.
TLS
NoTls in the examples above is exactly what it says. tokio-postgres/postgres don't include a TLS implementation; you plug one in:
postgres-native-tls(0.5.3) -- uses the OS TLS stack vianative-tlstokio-postgres-rustls(0.14.0) -- pure-Rustrustls
use postgres_native_tls::MakeTlsConnector;
use native_tls::TlsConnector;
let connector = TlsConnector::builder().build()?;
let tls = MakeTlsConnector::new(connector);
let (client, connection) = tokio_postgres::connect(
"host=db.example.com user=app dbname=mydb sslmode=require",
tls,
).await?;sslmode in the connection string supports disable, prefer, and require. Note that require in libpq terms does not verify the server certificate chain or hostname by itself -- certificate verification behavior comes from the TLS connector you build. For production over untrusted networks, configure the connector with your CA bundle rather than disabling verification.
sqlx: compile-time checked queries
sqlx is a different proposition: still plain SQL, but the query! macros check your SQL against a real database at compile time -- column names, types, nullability.
Features are additive and you must pick a runtime + TLS combination:
[dependencies]
sqlx = { version = "0.9", features = [ "runtime-tokio", "tls-rustls-ring-webpki", "postgres" ] }
tokio = { version = "1", features = ["full"] }use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(10)
.connect("postgres://app:secret@localhost/mydb")
.await?;
// Checked at compile time against the DATABASE_URL schema:
let user = sqlx::query!("SELECT id, name FROM users WHERE id = $1", 42_i64)
.fetch_one(&pool)
.await?;
println!("{}: {}", user.id, user.name);
Ok(())
}Things that surprise people:
- The macros need a database at compile time. Set
DATABASE_URLin the environment (or a.envfile), or use offline mode:cargo sqlx preparewrites query metadata into the repo so CI can build without a live database. - Pooling is built in (
PgPool), unlike tokio-postgres. One less dependency. - Runtime
query()(no bang) exists too -- no compile-time checking, for dynamic SQL. - Since 0.9.0, workspace-level configuration lives in an optional
sqlx.toml(multi-database setups, migration table renaming). If you're reading pre-0.9 tutorials, feature names and theMigratetrait have changed.
Diesel: the ORM
Diesel (2.3.11) is synchronous, schema-first, and catches most query errors at compile time through its DSL rather than by talking to the database:
[dependencies]
diesel = { version = "2.3", features = ["postgres"] }use diesel::prelude::*;
fn main() {
let mut conn = PgConnection::establish("postgres://app:secret@localhost/mydb")
.expect("failed to connect");
let names: Vec<String> = users::table
.filter(users::region.eq("emea"))
.select(users::name)
.load(&mut conn)
.expect("query failed");
}The build dependency is the thing to know in advance: Diesel's PostgreSQL backend links against libpq (pq-sys), so you need libpq-dev (Debian/Ubuntu), libpq (Homebrew), or libpq-devel (RHEL/Fedora) installed -- the same headers the Ruby pg gem needs. If you'd rather not manage a system dependency, the pq-src feature builds and statically links libpq from source.
For async, diesel-async (0.9.2) replaces the libpq connection with a pure-Rust one on top of tokio-postgres while keeping the same query DSL. Pooling for sync Diesel goes through r2d2 (bundled behind Diesel's r2d2 feature).
If you want a full-featured async ORM in the ActiveRecord style instead of Diesel's compile-time DSL, SeaORM (2.0.0) builds on sqlx -- same pure-Rust driver underneath.
Common errors
| Error | Cause | Fix |
|---|---|---|
| Queries hang forever, no error | Connection future never spawned | tokio::spawn(connection) after connect |
error communicating with the server | Connection task panicked or was dropped | Check the spawned task's error log; keep its JoinHandle alive |
Cannot drop a runtime in a context where blocking is not allowed | Sync postgres client used inside async code | Use tokio-postgres in async contexts |
Panic: error retrieving column 0: cannot convert | row.get::<i32> on an INT8 column, etc. | Match Rust types to PostgreSQL types exactly (i64 for BIGINT); use try_get |
password authentication failed for user | Wrong credentials or pg_hba.conf method mismatch | Verify user/password; check server auth config |
sqlx: set DATABASE_URL to use query macros | Compile-time macro can't reach a database | Export DATABASE_URL or commit cargo sqlx prepare output |
Diesel build: linking with cc failed / libpq not found | Missing libpq headers | Install libpq-dev or use the pq-src bundled feature |
| Pool timeout under load | Pool exhausted or max_connections hit | Check pool sizing math; look for connections held across long awaits |
Which one to pick
- You want SQL, async, and minimal abstraction:
tokio-postgres+deadpool-postgres. - You want your SQL verified before deploy:
sqlx-- the compile-time checking genuinely catches schema drift, at the cost of a database (or prepared metadata) at build time. - You want a type-safe query builder and migrations, sync is fine: Diesel.
- A script or CLI: the
postgrescrate; skip the runtime entirely.
Once you're connected, you still need to explore the data. Mako connects to PostgreSQL with AI-powered autocomplete -- useful for prototyping the SQL you're about to embed in a query! macro. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.