How to Connect to PostgreSQL from Rust

9 min readPostgreSQL

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

LibraryModelWhen to use itVersion (as of July 2026)
tokio-postgresAsync driver, pure RustYou want SQL and full control, async0.7.18
postgresSync wrapper around tokio-postgresScripts, CLIs, no async runtime0.19.14
sqlxAsync toolkit, pure RustCompile-time checked SQL, no ORM0.9.0
DieselSync ORM + query builderSchema-first apps, type-safe DSL2.3.11

Two architectural facts worth knowing before you pick:

  • tokio-postgres and sqlx are pure Rust -- they implement the PostgreSQL wire protocol directly and do not link against libpq. Diesel's PostgreSQL backend binds to libpq via pq-sys, so it inherits a C build dependency.
  • tokio-postgres is the foundation crate: postgres is a blocking wrapper around it, and the popular poolers (deadpool-postgres, bb8-postgres) manage tokio-postgres clients.

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 via native-tls
  • tokio-postgres-rustls (0.14.0) -- pure-Rust rustls
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_URL in the environment (or a .env file), or use offline mode: cargo sqlx prepare writes 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 the Migrate trait 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

ErrorCauseFix
Queries hang forever, no errorConnection future never spawnedtokio::spawn(connection) after connect
error communicating with the serverConnection task panicked or was droppedCheck the spawned task's error log; keep its JoinHandle alive
Cannot drop a runtime in a context where blocking is not allowedSync postgres client used inside async codeUse tokio-postgres in async contexts
Panic: error retrieving column 0: cannot convertrow.get::<i32> on an INT8 column, etc.Match Rust types to PostgreSQL types exactly (i64 for BIGINT); use try_get
password authentication failed for userWrong credentials or pg_hba.conf method mismatchVerify user/password; check server auth config
sqlx: set DATABASE_URL to use query macrosCompile-time macro can't reach a databaseExport DATABASE_URL or commit cargo sqlx prepare output
Diesel build: linking with cc failed / libpq not foundMissing libpq headersInstall libpq-dev or use the pq-src bundled feature
Pool timeout under loadPool exhausted or max_connections hitCheck 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 postgres crate; 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.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.