← Back to Blog

Talking to Postgres With Just std

April 2026 databasestdlib

The Postgres wire protocol has a reputation. It's allegedly complicated. In practice, the subset you need for SELECT, INSERT, UPDATE, DELETE with bind parameters is small enough to fit in one file.

See examples/data/postgres_driver.ltl. The whole thing depends only on std::net::tcp, std::bin, and std::collections.

◉ The API

let mut conn = postgres::connect("db.internal", 5432, "app", "prod", pw)?

conn
    |> postgres::query(
        "SELECT id, name FROM users WHERE active = $1 AND created > $2",
        [Param::Bool(true), Param::Int(since_ts)])?
    |> iter()
    |> map(|row| row["name"] as String)
    |> collect()

Parameters bind by position, never by string interpolation. The driver speaks the Extended Query protocol — Parse, Bind, Execute, Sync — so your query plan is cached server-side even on the first run.

◉ Why is this short?

Three reasons:

Pattern matching kills the boilerplate

Reading message types from the wire is a match on a tag byte. No state machine, no parser combinators:

match read_message(stream)? {
    Msg::DataRow(fields)      => ...,
    Msg::CommandComplete(_)   => ...,
    Msg::ReadyForQuery(_)     => break,
    Msg::ErrorResponse(info)  => return Err(info.format()),
    other                     => continue,
}

Parameters are a sum type, not a trait

Param is a closed enum: Null | Bool | Int | Float | Text | Bytea. The OID table is a 6-line match. You don't need generics or trait objects.

Streaming is just an iterator

Rows implements Iterator<Row>. next() reads one message at a time. No buffering an entire result set in memory. No async adapter layer — if you want concurrency, spawn the call.

◉ What's not in the 200 lines

A production driver would add:

- TLS (via std::tls::wrap_stream) - SCRAM-SHA-256 auth (~80 more lines) - Connection pooling (trivial — std::sync::Pool<Conn>) - COPY protocol for bulk loads - Prepared-statement caching

None of those change the shape of the core. They layer on top.

◉ Why write it instead of binding libpq?

Because the FFI boundary is where pipeline-native languages lose their identity. Every result goes through C structs, every parameter through char *, every async op through a callback. You end up writing imperative glue inside a functional language.

A pure-Lateralus driver stays a pipeline all the way down. connect returns Result. query returns Result<Rows>. Rows is an iterator. You |> through it the way you |> through everything else.

◉ Try it

Clone lateralus-examples, spin up a local Postgres, point the example at it, run lateralus run data/postgres_driver.ltl. 200 lines, zero deps, first query runs.

Try it in the playground

Every snippet on this page runs in your browser. No install, no signup.

▶ Open Playground Star on GitHub