name: tokio-postgres description: Use ONLY when working with database code (tokio-postgres, rust-postgres, Neon Postgres connections, queries, migrations, connection pools, or DB schema). Covers connection setup, query patterns, type mapping, and Neon-specific TLS config for this project.

tokio-postgres (rust-postgres)

Async PostgreSQL client for Rust. This project uses tokio-postgres with Neon (serverless Postgres).

Dependency

Already in Cargo.toml or should be added:

[dependencies.tokio-postgres]
version = "0.7"
features = ["with-chrono-0_4", "with-uuid-1"]

Neon requires TLS. Use postgres-native-tls (or postgres-openssl):

[dependencies]
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1"] }
postgres-native-tls = "0.5"
native-tls = "0.2"

Neon Connection Pattern

Neon uses a standard Postgres wire protocol over TLS. Connection params come from the .env file using libpq-style env vars (PGHOST, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE), not a DATABASE_URL string.

Current .env values:

  • PGHOST=ep-holy-mouse-alwcchgn-pooler.c-3.eu-central-1.aws.neon.tech
  • PGDATABASE=neondb
  • PGUSER=neondb_owner
  • PGSSLMODE=requireTLS is mandatory

Building the connection

use postgres_native_tls::MakeTlsConnector;
use native_tls::TlsConnector;
use tokio_postgres::Config;

async fn connect() -> Result<tokio_postgres::Client, Box<dyn std::error::Error>> {
    // Build config from libpq env vars (PGHOST, PGUSER, etc.)
    let config = tokio_postgres::Config::from_env()?;
    let tls = MakeTlsConnector::new(TlsConnector::new()?);
    let (client, connection) = config.connect(tls).await?;
    tokio::spawn(async move {
        if let Err(e) = connection.await {
            eprintln!("connection error: {}", e);
        }
    });
    Ok(client)
}

Key points:

  • Always spawn the connection task — the Connection future must be polled for queries to execute.
  • Neon requires TLSNoTls will not work. Always use MakeTlsConnector.
  • Use Config::from_env() — reads PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE, PGSSLMODE from .env automatically. Do not hardcode a connection string.
  • The .env host uses the pooler endpoint (-pooler suffix), which enables Neon's connection pooling.

Query Patterns

Simple queries (no params, no prepared statements)

let rows = client.simple_query("SELECT 1").await?;

Parameterized queries (preferred — prevents SQL injection)

let rows = client
    .query("SELECT id, title FROM articles WHERE audience_id = $1", &[&audience_id])
    .await?;
for row in &rows {
    let id: uuid::Uuid = row.get(0);
    let title: &str = row.get(1);
}

Query one row

let row = client
    .query_opt("SELECT id, name FROM audiences WHERE slug = $1", &[&slug])
    .await?;
if let Some(row) = row {
    let id: uuid::Uuid = row.get(0);
    let name: &str = row.get(1);
}

Execute (INSERT/UPDATE/DELETE — no rows returned)

client
    .execute(
        "INSERT INTO articles (id, title, audience_id, content) VALUES ($1, $2, $3, $4)",
        &[&id, &title, &audience_id, &content],
    )
    .await?;

Prepared statements (reuse for repeated queries)

let stmt = client.prepare("SELECT id, title FROM articles WHERE id = $1").await?;
let rows = client.query(&stmt, &[&article_id]).await?;

Transactions

let tx = client.transaction().await?;
tx.execute("INSERT INTO articles (id, title) VALUES ($1, $2)", &[&id, &title]).await?;
tx.execute("INSERT INTO article_versions (article_id, content) VALUES ($1, $2)", &[&id, &content]).await?;
tx.commit().await?;
// Or roll back:
// tx.rollback().await?;

The Transaction type implements GenericClient, so you can pass &tx anywhere that accepts &dyn GenericClient.

Type Mapping (with features enabled)

Rust type (feature)Postgres typeFeature flag
&str / StringTEXT, VARCHARdefault
i32INT4default
i64INT8default
f64FLOAT8default
boolBOOLdefault
uuid::UuidUUIDwith-uuid-1
chrono::NaiveDateTimeTIMESTAMPwith-chrono-0_4
chrono::DateTime<Utc>TIMESTAMPTZwith-chrono-0_4
chrono::NaiveDateDATEwith-chrono-0_4
serde_json::ValueJSON/JSONBwith-serde_json-1
&[u8] / Vec<u8>BYTEAdefault

Nullable columns: use Option<T> (e.g., Option<&str>, Option<uuid::Uuid>).

Common Pitfalls

  • Specifying parameter types: When a parameter type is ambiguous, cast in SQL: $1::UUID, $1::TEXT, etc.
  • Connection must be spawned: If you don't tokio::spawn the Connection, queries will hang forever.
  • Owned vs borrowed: query() borrows params. For String vs &str, query accepts &String via Deref. Use &str slices when possible.
  • Neon cold starts: Neon serverless has cold-start latency on first connect. Consider connection pooling for production.
  • No connection pooling builtin: tokio-postgres does not include a pool. For pooling, use deadpool-postgres or bb8-postgres in production.

Project-Specific Notes

  • DB URL is read from DATABASE_URL env var (Neon connection string).
  • Migrations use the migs crate — see AGENTS.md.
  • This project uses uuid::Uuid (with v4 feature) and chrono for timestamps, so enable with-uuid-1 and with-chrono-0_4 feature flags.
  • Always use parameterized queries — never interpolate values into SQL strings.