Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Book of Babar

Typed Postgres for Rust, built directly on Tokio and the PostgreSQL wire protocol.

The Babar brand sheet — wordmark, palette, and the herd at work

babar gives you a small set of Postgres-shaped building blocks:

  • Config describes how to connect.
  • Session owns one connection and a background driver task.
  • query! and command! define typed SQL from authored schema facts.
  • Query::raw, Query::raw_with, Command::raw, and Command::raw_with stay available when you need an explicit fallback.
cargo add babar

One typed-SQL path

The primary application story is:

  1. author schema facts with schema!
  2. build statements with schema-scoped query! / command!
  3. pass Rust values that already match the SQL shape
use babar::query::{Command, Query};
use babar::{Config, Session};

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct DemoUser {
    id: i32,
    name: String,
}

babar::schema! {
    mod app_schema {
        table demo_users {
            id: primary_key(int4),
            name: text,
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("secret")
            .application_name("hello-babar"),
    )
    .await?;

    let create: Command<()> =
        Command::raw("CREATE TEMP TABLE demo_users (id int4 PRIMARY KEY, name text NOT NULL)");
    session.execute(&create, ()).await?;

    let insert: Command<DemoUser> =
        app_schema::command!(INSERT INTO demo_users (id, name) VALUES ($id, $name));
    session
        .execute(
            &insert,
            DemoUser {
                id: 1,
                name: "Ada".to_string(),
            },
        )
        .await?;

    let select: Query<(), DemoUser> = app_schema::query!(
        SELECT demo_users.id, demo_users.name
        FROM demo_users
        ORDER BY demo_users.id
    );

    let rows: Vec<DemoUser> = session.query(&select, ()).await?;
    for row in &rows {
        println!("{}	{}", row.id, row.name);
    }

    session.close().await?;
    Ok(())
}

That example shows the intended split:

  • schema-aware macros for application SQL
  • explicit raw fallbacks for bootstrap or unsupported statements
  • structs as the normal shape for application-facing rows and parameters, with one shared struct when the input and row field sets are the same

Choose your path

  • Product docs path — the direct route for readers who want to connect, query, and ship with babar.
  • Rust learning track — an optional guided path for readers who want to learn Rust concepts through babar examples.

Rust learning track (optional)

The Rust learning track is a separate top-level section in the book navigation. Use it if you want a guided Rust-first tour through babar. Skip it if you already know Rust or just want the fastest route into the product docs. Start at Learn Rust with babar when you want that companion path.

How the docs are organized

  • Get Started teaches the first successful round-trip.
  • The Book walks feature-by-feature through everyday usage.
  • Explanation covers architecture, design, and trade-offs.
  • Reference is the catalog for codecs, errors, features, and configuration.

Where to go next

Learn Rust with babar

This optional track is for readers who want to learn Rust by reading real babar code and docs examples instead of detouring through toy applications. The opening sequence is aimed at readers who know how to program already and now need a trustworthy way to read Rust in context.

Opening guided sequence

These first five chapters are meant to be read in order:

  1. Read a babar program
  2. Syntax and control flow in context
  3. Types, structs, and Result
  4. Ownership and borrowing around queries
  5. Async/await and the driver task mental model

Together they answer the first questions most new Rust readers hit when they open babar for the first time:

  • What parts of this file are data shapes versus I/O?
  • How do the statement types describe the database boundary?
  • Why do &, clone(), Option<T>, .await, and ? appear so often?
  • What async mental model is enough to keep reading without studying runtime internals yet?

Follow-on chapters

The remaining chapters deepen that foundation without turning this docs site into a general Rust textbook:

  1. Error handling and service boundaries
  2. Traits, generics, and codecs
  3. Structs, impl, and Rust-flavored OOP
  4. Iterators, closures, and functional style

How the guided pages are framed

Each chapter in this section follows the same pattern:

  1. babar anchor — start from a real babar example, docs page, or code path.
  2. Rust-first explanation — explain the Rust concept directly in the context of that anchor.
  3. Python comparison (optional) — include a clearly labeled comparison only when it closes a real gap for Python-fluent readers.
  4. Checkpoint / reflection — end with a small self-check so you can tell whether the Rust idea is starting to stick.

Start here, then branch outward

If you want the shortest path through the opening sequence, read the five chapters above and keep these source anchors open in a second tab:

After that, jump outward only when you need more depth:

1. Read a babar program

The first goal is not to understand every Rust rule. It is to look at a small babar example and say which parts are data shapes, which parts do networked work, and which details can wait until a second pass.

babar anchor

Keep these open while reading:

The examples are small enough to read end to end, but real enough to show the shapes that keep appearing across the rest of the docs.

Start by sorting the file into four kinds of lines

When you open the first-query example, do not read it top to bottom as one undifferentiated block. Sort the lines into four jobs.

1. Data-shape lines

These lines describe Rust values that will cross the database boundary:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct User {
    id: i32,
    name: String,
}
}

At first read, the most important fact is simple: User is the value shape sent into SQL and read back out. You do not need to understand every derive right away. It is enough to notice that this struct makes the database boundary explicit.

2. Schema and statement lines

These lines define reusable SQL-facing values:

#![allow(unused)]
fn main() {
babar::schema! {
    mod app_schema {
        table demo_users {
            id: primary_key(int4),
            name: text,
        }
    }
}

let insert: Command<User> =
    app_schema::command!(INSERT INTO demo_users (id, name) VALUES ($id, $name));

let users: Query<(), User> = app_schema::query!(
    SELECT demo_users.id, demo_users.name
    FROM demo_users
    ORDER BY demo_users.id
);
}

You can read this as: authored schema facts live in one place, then typed statement values are built from them.

  • Command<User> means “run SQL with a User value; no rows come back.”
  • Query<(), User> means “run SQL with no input parameters; each row decodes into User.”

That is already enough to follow the high-level flow.

3. Networked work

These are the lines that actually talk to Postgres and therefore return futures or results:

#![allow(unused)]
fn main() {
let session: Session = Session::connect(cfg).await?;
session.execute(&create, ()).await?;
session.execute(&insert, User { id: 1, name: "Ada".to_string() }).await?;
let rows: Vec<User> = session.query(&users, ()).await?;
session.close().await?;
}

A good first-pass rule is: lines with .await are the I/O boundary unless you learn otherwise.

4. Local Rust control flow

Everything else is mostly local orchestration:

#![allow(unused)]
fn main() {
for row in &rows {
    println!("id={} name={}", row.id, row.name);
}
}

That loop is not a database concept. It is ordinary Rust code handling values that already came back from the query.

A useful first-pass reading strategy

For the opening sequence, read each babar example in this order:

  1. Find the structs and tuple types.
  2. Find the Query<_, _> and Command<_> values.
  3. Find the .await calls.
  4. Only then read the helper details around them.

That order keeps you from getting stuck on syntax that matters less than the overall shape.

What you can safely defer on day one

A new Rust reader often tries to solve too many puzzles at once. In these opening babar examples, you can safely defer all of this until later:

  • the exact macro-expansion details of schema!, query!, and command!
  • the full meaning of every derive attribute
  • the internals of Tokio and the driver task

You still need to notice that those features exist. You just do not need to master them before you can read the happy path.

Python comparison (optional)

If you come from Python, the quickest bridge is this: the file still has recognizable program structure — imports, data shapes, function boundaries, setup, I/O, and output — but Rust makes more of that structure explicit in types and function signatures.

Use that comparison to get oriented, then come back to the Rust names. The point of this track is to become fluent in the Rust-shaped version of the program, not to translate every line back into Python.

Checkpoint

If you can answer these four prompts from the first-query example, you are reading the file the right way:

  • Which structs describe values crossing the SQL boundary?
  • Which statement returns rows, and which one does not?
  • Which lines must wait on network progress?
  • Which block only formats already-decoded Rust values?

Reflection prompts

  • In the first babar example, which lines define data shapes and which lines perform networked work?
  • Where does the example return or propagate a Result, and what does that tell you about failure boundaries?
  • Which two details can you safely defer until later so you can keep reading the example end to end?

2. Syntax and control flow in context

This chapter introduces Rust syntax only in the forms that show up immediately in babar: bindings, blocks, method chains, match, if let, for, and the small tuples or structs passed into queries.

babar anchor

Use these anchors together:

The goal is not to memorize the whole language. It is to make the common babar surface readable.

let means “name a value”

In the quickstart example, most bindings look like this:

#![allow(unused)]
fn main() {
let host = std::env::var("PGHOST").unwrap_or_else(|_| "localhost".into());
let cfg = Config::new(&host, port, &user, &database)
    .password(password)
    .application_name("babar-quickstart");
}

Two early habits matter here:

  • let creates a binding.
  • bindings are immutable unless you write let mut.

That default fits many babar examples because connection config, statement values, and decoded rows are usually created once and then read, not repeatedly mutated.

Method chains read top to bottom

Config::new(...).password(...).application_name(...) is ordinary Rust method chaining. Read it as “start with a Config, then refine it step by step.”

That pattern appears throughout babar because builder-style setup is clearer than hiding connection details inside one opaque string.

Blocks use braces, and many control-flow forms are expressions

Rust uses braces for blocks, but the deeper idea is that blocks often produce values. You can see that in the quickstart connection handling:

#![allow(unused)]
fn main() {
let session = match Session::connect(cfg).await {
    Ok(s) => s,
    Err(e) => {
        eprintln!("connect failed: {e}");
        return ExitCode::from(1);
    }
};
}

match is not just branching syntax. It is an expression whose branches must line up to produce a sensible overall result.

In this case:

  • the Ok(s) branch yields the Session
  • the Err(e) branch logs and returns early from main

That is why the whole match can sit on the right-hand side of let session = ....

if let handles one pattern without writing a full match

A few lines later, quickstart uses a narrower form:

#![allow(unused)]
fn main() {
if let Err(e) = run(&session).await {
    eprintln!("workflow failed: {e}");
    let _ = session.close().await;
    return ExitCode::from(1);
}
}

Read if let as “if this value matches one pattern I care about, run this block.”

Here the code only needs custom handling for the error case. The success case does nothing special, so a full match would be noisier.

for loops usually iterate by borrowing first

The query-processing loop in quickstart is:

#![allow(unused)]
fn main() {
for row in &rows {
    let n = session.execute(&insert, row.clone()).await?;
    println!("inserted {n} row(s) for id={}", row.0);
}
}

The syntactic detail to notice is &rows.

That means “iterate over borrowed references to each element” instead of moving the array itself. The ownership reason for row.clone() comes in the next chapter; for now, the syntax takeaway is that Rust makes the borrowed-versus-moved choice visible.

Tuples can be compact, but field names disappear

Quickstart uses a tuple type for the insert command:

#![allow(unused)]
fn main() {
let insert: Command<(i32, String, core::primitive::bool, Option<String>)> = ...;
}

Tuple syntax is compact and useful for local examples. But tuple fields are positional (row.0, row.1, and so on), which is why longer-lived docs examples often prefer named structs such as NewUser or UserSummary.

A tiny syntax map for the opening docs

When you see these forms in babar, read them like this:

  • fn name(...) -> T — a function returning T
  • async fn name(...) -> T — a function whose body contains async work
  • Type::name(...) — an associated function or constructor-style call
  • value.method(...) — a method call on a value
  • Enum::Variant(...) or Ok(...) / Err(...) — an enum variant being constructed or matched
  • ? — if this result is an error, return early from the current function

That last item matters enough to get its own chapter later. For now, just recognize it as control flow.

Python comparison (optional)

The closest Python bridge is that the control-flow ideas are familiar — branch, loop, early return, configure an object through calls — but Rust puts more meaning into expressions and types than indentation alone can carry.

Use the bridge to orient yourself. Then keep naming the Rust construct you are seeing: binding, method chain, match, borrowed iteration, tuple field.

Checkpoint

Try reading this line by line without translating it into English prose first:

#![allow(unused)]
fn main() {
let rows: Vec<UserSummary> = session
    .query(&active_users, ActiveUsers { active: true })
    .await?;
}

You should be able to say:

  • what binding is being created
  • which method is being called
  • which argument is a named struct literal
  • where async waiting happens
  • where early-return-on-error can happen

Reflection prompts

  • Which control-flow forms in the quickstart example are there to handle success versus failure?
  • Why is an immutable let binding usually the default in these babar examples?
  • What information is carried by a chained builder call that would often be hidden in Python keyword arguments or dynamic configuration objects?

3. Types, structs, and Result

Now that the syntax is less intimidating, the next step is learning to name the value shapes. In babar, that mostly means understanding structs, tuples, Option<T>, Query<A, B>, Command<A>, and Result<T, E>.

babar anchor

This chapter stays close to:

Query<A, B> and Command<A> describe the boundary

The most important babar types are worth reading literally:

  • Command<A> — send an A into SQL; no result rows come back
  • Query<A, B> — send an A into SQL; each returned row decodes into B

From the selecting chapter:

#![allow(unused)]
fn main() {
let active_users: Query<ActiveUsers, UserSummary> = app_schema::query!(
    SELECT users.id, users.name, users.active
    FROM users
    WHERE users.active = $active
    ORDER BY users.id
);
}

You can read this as a contract:

  • call the query with an ActiveUsers value
  • get back Vec<UserSummary>

That is the main reason the docs keep showing the type names. They are not decoration; they describe the database round-trip.

Named structs are the clearest default

The getting-started guide uses one named struct for both the insert and row shape because the field sets are identical:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct User {
    id: i32,
    name: String,
}
}

This is usually the best first choice in docs because field names carry meaning at the call site.

#![allow(unused)]
fn main() {
session.execute(
    &insert,
    User {
        id: 1,
        name: "Ada".to_string(),
    },
).await?;
}

A reader can see immediately what each value means.

Tuples are fine when the shape is small and local

Quickstart shows the same idea with tuples:

#![allow(unused)]
fn main() {
let insert: Command<(i32, String, core::primitive::bool, Option<String>)> = ...;
}

That is still a real type, just a positional one.

Use the docs’ examples as a rough guide:

  • prefer structs when the fields have business meaning you will keep talking about
  • prefer tuples when the shape is small, local, and obvious from nearby SQL

That is why the book often starts with structs, even when a tuple would compile.

Option<T> means “this may be absent”

In the selecting chapter, a nullable column becomes an optional Rust field:

#![allow(unused)]
fn main() {
struct UserNote {
    id: i32,
    note: Option<String>,
}
}

Option<String> does not mean “string with a special empty value.” It means one of two cases is present in the type:

  • Some(String)
  • None

That is a perfect fit for SQL nullability because the possibility of absence stays visible in the Rust type.

Result<T, E> means success or failure is part of the API

The first-query guide uses this signature:

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {

Read it in pieces:

  • main is async
  • if it succeeds, it returns ()
  • if it fails, it returns a babar::Error through the alias babar::Result<()>

() is Rust’s unit type: “there is no interesting success payload here.” That makes sense for a function whose job is to perform side effects such as connect, execute SQL, print rows, and shut down cleanly.

The first useful reading of ?

You do not need the full error-handling chapter yet to read this:

#![allow(unused)]
fn main() {
let rows: Vec<UserRow> = session.query(&users, ()).await?;
}

At the opening-sequence level, ? means:

  1. wait for the query result
  2. if it is an error, stop this function and return that error upward
  3. if it is a success, unwrap the success value and keep going

That reading is enough to follow almost every early babar example.

Why derive(babar::Codec) keeps appearing

For this chapter, you only need the short version: the derive tells babar how to encode or decode that Rust shape at the database boundary.

You do not need to know the trait machinery yet. Just notice that structs used with Query and Command usually carry the derive because they are part of the SQL contract.

Python comparison (optional)

A Rust struct can feel superficially like a Python dataclass, but the important difference is that the field types, optionality, cloning behavior, and trait derivations are part of the core contract, not light metadata layered on afterward.

That is why the docs keep treating these definitions as central design choices.

Checkpoint

Before moving on, make sure you can classify each of these without hesitation:

  • NewUser — named struct used as a parameter shape
  • UserRow — named struct used as a row shape
  • Option<String> — a field that may be absent
  • Query<ActiveUsers, UserSummary> — typed read statement
  • babar::Result<()> — fallible return type with no success payload beyond “it worked”

Reflection prompts

  • In the current babar examples, where would a named struct help more than a tuple, and why?
  • What does Option<String> communicate about a database column that plain String does not?
  • When you see babar::Result<()>, what work completed successfully if the function returns Ok(())?

4. Ownership and borrowing around queries

Ownership is the first Rust topic that feels genuinely different for many readers. The good news is that you do not need the whole theory to read babar examples well. You mainly need to notice who owns a value, who only borrows it, and why a clone sometimes appears right before async work.

babar anchor

Keep these anchors nearby:

Start with the most important distinction: owner versus borrower

In quickstart, main creates a Session and then passes borrowed access into run:

#![allow(unused)]
fn main() {
let session = match Session::connect(cfg).await {
    Ok(s) => s,
    Err(e) => {
        eprintln!("connect failed: {e}");
        return ExitCode::from(1);
    }
};

if let Err(e) = run(&session).await {
    eprintln!("workflow failed: {e}");
    let _ = session.close().await;
    return ExitCode::from(1);
}
}

main owns the session binding. run(&session) only borrows it.

That is a common babar pattern:

  • one part of the program owns the connection handle
  • helper functions borrow &Session so they can use it without taking it away

Borrowing is why so many method calls take &...

Inside run, the code calls methods like this:

#![allow(unused)]
fn main() {
let n = session.execute(&insert, row.clone()).await?;
let active_rows = session.query(&select, (true,)).await?;
}

Two kinds of borrowing are visible:

  • session is &Session, so the function uses the handle without owning it
  • &insert and &select borrow the statement values instead of consuming them

That makes the statement values reusable. You can execute the same Command multiple times because the call borrows the command definition and consumes only the argument value.

Why row.clone() appears in quickstart

This is the first ownership line that often looks mysterious:

#![allow(unused)]
fn main() {
for row in &rows {
    let n = session.execute(&insert, row.clone()).await?;
    println!("inserted {n} row(s) for id={}", row.0);
}
}

The array rows owns each tuple. The loop iterates over &rows, so row is only a borrowed reference to one tuple.

But execute needs an owned argument value of type:

#![allow(unused)]
fn main() {
(i32, String, bool, Option<String>)
}

Because the tuple contains owned data such as String, the borrowed row cannot just be moved out of the array. row.clone() creates a fresh owned tuple to send into the command.

That is not a random Rust ritual. It is the code making ownership explicit.

Borrow when reusing, move when handing work off

A practical reading rule for early babar code is:

  • borrowed values (&session, &insert, &rows) are being reused or inspected
  • moved values (NewUser { ... }, (true,), or row.clone()) are being handed to a call that takes ownership of the argument

That rule is not the full language, but it is a very strong start.

The Session handle owns less than you might think

Chapter 1: Connecting explains an important subtlety: the Session value you hold is a handle, while the actual socket lives in a background driver task.

For this chapter, the practical takeaway is:

  • your binding owns the handle value
  • borrowed &Session references let other functions use that handle
  • the deeper connection machinery is still controlled in one place

That design is why babar can let many tasks share one session handle without pretending the underlying socket has many owners.

What to ask whenever a value crosses .await

You do not need lifetime jargon yet. Ask this instead:

  1. Is this call borrowing the value or taking ownership of it?
  2. If the call needs ownership, am I done using the original value?
  3. If I still need it later, should I borrow it or clone it first?

Those three questions explain a lot of the surface shape of babar examples.

Python comparison (optional)

A Python reader already knows that names point at objects. Rust is not unique because it has references. Rust feels different because the rules about aliasing, mutation, and handoff across function boundaries are made visible in the code instead of being mostly runtime conventions.

That extra visibility is exactly why the examples show &Session, &insert, and row.clone() instead of silently doing whatever seems convenient.

Checkpoint

From quickstart, classify each of these as mostly borrow, move, or clone to create a new owned value:

  • run(&session)
  • session.query(&select, (true,))
  • session.execute(&insert, row.clone())
  • for row in &rows

If you can do that, you are already reading ownership well enough for the opening docs.

Reflection prompts

  • In a babar example, which value owns the database connection handle and which functions only borrow access to it?
  • Why might code clone a String-carrying value before sending it into database work instead of reusing the original binding directly?
  • What ownership question should you ask any time a value crosses an .await boundary?

5. Async/await and the driver task mental model

This chapter is an explanation-first stop in the learning track. Its job is not to teach every Tokio API. Its job is to make babar’s async code readable: when you see Session::connect(cfg).await?, pool.acquire().await?, or conn.query(&select, args).await?, you should know what kind of waiting is happening and why the connection still stays in a valid state.

babar anchor

Start with these product docs:

Those pages show the same shape at three scales:

  1. one Session connecting to Postgres
  2. one background task owning that connection
  3. one service using a Pool so many requests can await database work safely

The shortest useful async model

In Rust, an async fn does not run immediately. Calling it creates a future: a value that describes work which can make progress later. The work actually advances only when an async runtime such as Tokio polls that future.

That is why the docs keep pairing async functions with .await:

#![allow(unused)]
fn main() {
let session = Session::connect(cfg).await?;
let pool = Pool::new(cfg, PoolConfig::new().max_size(8)).await?;
let conn = pool.acquire().await?;
let rows = conn.query(&select, (id,)).await?;
}

Each .await marks a point where the current function may pause because it needs outside progress:

  • Postgres must answer the startup handshake
  • the pool must hand out a live connection
  • the server must execute the SQL and send rows back

The important mental model is simple: async is how Rust represents waiting for I/O without blocking the whole thread.

What #[tokio::main] is doing for the examples

The examples in 1. Connecting, 11. Building a web service, and Postgres API from scratch all use a Tokio entry point:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // ...
}

That attribute creates the runtime that polls futures for you. Without it, the compiler would accept neither the async main nor the .await calls inside it.

You do not need runtime internals to read babar docs. You do need one rule: if the code is waiting on network work, it will usually be async.

Why Session::connect returns a handle instead of exposing the socket

1. Connecting says that Session::connect gives you a Session, but the real socket ownership moves into a background Tokio task. That design becomes clearer once you separate handle from owner:

  • Session is the handle your code clones, passes around, and calls methods on
  • the driver task is the owner that reads and writes the Postgres socket

From the outside, a Session call feels like:

  1. package a request
  2. send it to the driver task
  3. await the reply

From the inside, the driver task keeps one serial conversation with Postgres. That matters because one connection cannot safely interleave multiple request/response exchanges at random.

Why the driver task exists

The deeper explanation in The background driver task gives two main reasons, and both are practical.

1. Cancellation stays safe

If a future waiting on a database call gets dropped, babar does not abandon the Postgres protocol halfway through a message. The driver task finishes the in-flight exchange on the socket, then moves to the next clean boundary.

That is what the docs mean by cancellation-safe. The future you hold is not the socket itself. It is a request waiting for the driver task’s answer.

2. One connection still supports concurrent callers

Multiple Tokio tasks can all call into the same Session. They are not all writing to the socket directly. They send commands through the driver’s channel, and the driver processes them in arrival order.

That is a useful distinction:

  • concurrent callers: many tasks may submit work
  • serial wire protocol: one connection still speaks to Postgres in order

So Session gives you safe sharing of one connection handle, while Pool gives you true parallelism across multiple connections.

Reading the application flow in the web-service example

The web-service chapter shows the async story at application level:

#![allow(unused)]
fn main() {
async fn create_widget(
    State(state): State<AppState>,
    Json(payload): Json<CreateWidget>,
) -> Result<(StatusCode, Json<Widget>), (StatusCode, String)> {
    let conn = state.pool.acquire().await.map_err(pool_http)?;
    conn.execute(&insert, (payload.id, payload.name.clone()))
        .await
        .map_err(db_http)?;
    Ok((StatusCode::CREATED, Json(Widget { id: payload.id, name: payload.name })))
}
}

Read it in this order:

  1. the handler is async because both HTTP work and database work may wait
  2. pool.acquire().await may pause until a connection is available
  3. conn.execute(...).await may pause until Postgres finishes the command
  4. while this handler is waiting, Tokio can run other tasks

The point is not “async syntax looks modern”. The point is that one service can keep handling network-bound work without dedicating one blocked OS thread to each waiting request.

Session versus Pool in one sentence each

  • Use Session when you want one connection and want to understand the driver-task model directly.
  • Use Pool when your application may have many overlapping requests and should borrow a connection per operation or per handler.

The learning progression across the docs is deliberate:

Python comparison (optional)

If you know Python’s async def, the surface shape will look familiar. The Rust-first difference is that Rust futures are ordinary values with strict ownership rules. They do nothing until a runtime polls them, and the compiler still checks which values may cross an .await point safely.

Checkpoint

Before moving on, make sure you can answer these without looking back:

  • Which lines in the connecting and web-service examples are waiting on network progress rather than doing plain CPU work?
  • Why is dropping an awaited database future not the same as abandoning the socket protocol halfway through?
  • When would you reach for a Pool instead of sharing one Session?

6. Error handling and service boundaries

This chapter is the companion to the async chapter. Once you can read the waiting points, the next skill is reading the failure paths. In babar, that usually means understanding three moves:

  1. propagate an error with ?
  2. inspect an error with match
  3. translate a low-level error into an application-facing one at the boundary

babar anchor

Keep these pages open while reading:

Together they show the whole path from connection setup to HTTP response.

Start with the function signature

Rust makes failure visible in the type signature. In the babar docs you will see shapes such as:

async fn main() -> babar::Result<()> { /* ... */ }
async fn initialize(pool: &Pool) -> babar::Result<()> { /* ... */ }
fn db_http(err: babar::Error) -> (StatusCode, String) { /* ... */ }

Read them literally:

  • babar::Result<()> means “this function may fail with babar::Error
  • Result<T, (StatusCode, String)> means “this handler either returns success data or an HTTP-facing error payload”
  • () means “successful completion matters, but there is no extra success value”

The signature already tells you where responsibility for the error currently lives.

What ? means in babar code

In both 1. Connecting and 9. Error handling, the ? operator appears on async database calls:

#![allow(unused)]
fn main() {
let session = Session::connect(cfg).await?;
session.execute(&create, ()).await?;
let pool = Pool::new(cfg, PoolConfig::new().max_size(8)).await?;
}

? is short for a very specific decision:

  • if the call succeeded, keep going with the success value
  • if the call failed, return that error from the current function immediately

So when you read await?, split it into two questions:

  1. what outside work are we waiting for?
  2. if that work fails, who is responsible next?

That makes ? much less mysterious. It is not swallowing errors. It is propagating them on purpose.

When to use ? and when to use match

Use ? when the current function is not the place that adds meaning.

Use match when the current function is the place that must classify or translate the error.

That is why the error-handling chapter includes:

#![allow(unused)]
fn main() {
match session.execute(&insert, (2, "ada".into())).await {
    Ok(_) => unreachable!(),
    Err(err) => match classify(&err) {
        Failure::Duplicate => println!("duplicate name; skipping"),
        Failure::ServerOther { code } => println!("server error {code}"),
        Failure::IoOrClosed => println!("connection died; retry later"),
        Failure::Bug => println!("our bug, not the server's: {err}"),
    },
}
}

The code is not just asking “did this fail?”. It is asking “what kind of failure is this, and what should the application do next?”.

babar::Error is an enum, so classification is explicit

9. Error handling stresses one rule: there is no Error::kind() convenience classifier. You inspect the variant directly.

That is good Rust practice for a learner to notice. It means the library is not hiding the shape of failure from you. A few useful buckets are:

  • Error::Io(_) and Error::Closed { .. } for transport-level trouble
  • Error::Auth(_) and Error::UnsupportedAuth(_) for authentication failures
  • Error::Server { code, .. } for Postgres server errors
  • Error::Config(_) for setup mistakes caught before I/O
  • Error::Codec(_), Error::SchemaMismatch { .. }, and Error::ColumnAlignment { .. } for typed-data mismatches

This is one of the biggest Rust differences from exception-heavy code: the error cases are normal values, and pattern matching is the standard way to reason about them.

Why SQLSTATE is the stable boundary

When Postgres rejects a statement, Error::Server { code, message, .. } carries both a machine-friendly code and a human-friendly message. The docs tell you to match the SQLSTATE instead of the message text:

#![allow(unused)]
fn main() {
Error::Server { code, .. } if code == "23505" => Failure::Duplicate
}

That is more reliable because:

  • SQLSTATE is designed for programmatic handling
  • messages are written for humans and may vary in wording
  • your service logic usually cares about categories such as duplicate key, foreign-key violation, timeout, or retryable transaction failure

Use the Error catalog when you need the wider table of codes; use the book chapter when you need the mental model.

Service boundaries are where translation happens

The web-service chapter shows the next layer up:

#![allow(unused)]
fn main() {
fn db_http(err: babar::Error) -> (StatusCode, String) {
    match err {
        babar::Error::Server { code, .. } if code == "23505" => {
            (StatusCode::CONFLICT, "already exists".into())
        }
        babar::Error::Server { code, .. } if code == "23503" => {
            (StatusCode::UNPROCESSABLE_ENTITY, "foreign key violation".into())
        }
        other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
    }
}
}

This is an important application-flow boundary:

  • below the boundary, code talks in driver/database terms
  • at the boundary, code translates that into HTTP or domain terms
  • above the boundary, callers should not need to understand babar::Error

That translation is where application meaning appears. A duplicate key becomes a conflict. A missing foreign-key target becomes an unprocessable request. A pool timeout may become service unavailable.

Separate operational detail from client-facing meaning

11. Building a web service explicitly warns against returning every raw database error straight to the client. That is a good rule for two reasons:

  1. low-level messages may leak implementation detail
  2. clients usually need stable application meaning, not driver internals

A practical reading rule:

  • logs and diagnostics may include the detailed babar::Error
  • client responses should usually expose a smaller, domain-appropriate shape

Service code often handles two fallible steps in sequence:

#![allow(unused)]
fn main() {
let conn = state.pool.acquire().await.map_err(pool_http)?;
let rows = conn.query(&select, (id,)).await.map_err(db_http)?;
}

That split is worth noticing:

  • PoolError answers “could the application obtain a usable connection?”
  • babar::Error answers “what went wrong while speaking to Postgres?”

Both are database-adjacent, but they are not the same boundary. Good service code usually keeps that distinction clear.

Python comparison (optional)

If you come from Python, Rust error values can feel like exceptions made explicit. The Rust-first lesson is stricter than that: failure paths are part of the function type, propagation is visible in ?, and classification is usually done with match, not by catching a broad exception late.

Checkpoint

Try to answer these from the docs examples:

  • When a babar call ends with await?, which function has agreed to handle the error next?
  • Why is code == "23505" a better service boundary than checking whether an error message contains the word “duplicate”?
  • In the Axum example, which failures should stay in logs, and which should be translated into stable HTTP meanings?

7. Traits, generics, and codecs

Once you move past the first session.query(&query, args).await?, babar starts showing you more of Rust’s real shape: generic types, trait-based capabilities, and codecs that connect Rust values to Postgres values. This chapter explains those ideas from the babar side first, then names the Rust concepts underneath.

babar anchor

Start with the standard typed query shape from 2. Selecting:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct ActiveUsers {
    active: bool,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserSummary {
    id: i32,
    name: String,
    active: bool,
}

let active_users: Query<ActiveUsers, UserSummary> = app_schema::query!(
    SELECT users.id, users.name, users.active
    FROM users
    WHERE users.active = $active
    ORDER BY users.id
);

let rows: Vec<UserSummary> = session
    .query(&active_users, ActiveUsers { active: true })
    .await?;
}

That single type, Query<ActiveUsers, UserSummary>, already tells you a lot:

  • ActiveUsers is the parameter shape the query accepts
  • UserSummary is the row shape the query produces
  • session.query turns one into the other by way of Postgres

Rust uses generics here because babar wants one query API that works for many parameter and row shapes without erasing the types.

Generics: one query type, many data shapes

In babar, a query is not “just a SQL string.” It is a value whose type records the Rust shapes on both sides of the round-trip.

From the reader’s point of view:

  • Command<Params> means “a command that accepts this parameter shape”
  • Query<Params, Row> means “a query that accepts this parameter shape and decodes rows into this row shape”

The quickstart example shows the same pattern with tuples instead of structs:

#![allow(unused)]
fn main() {
let insert: Command<(i32, String, bool, Option<String>)> =
    quickstart_schema::command!(
        INSERT INTO quickstart (id, name, active, note)
        VALUES ($id, $name, $active, $note)
    );

let select: Query<(bool,), (i32, String, bool, Option<String>)> =
    quickstart_schema::query!(
        SELECT quickstart.id, quickstart.name, quickstart.active, quickstart.note
        FROM quickstart
        WHERE quickstart.active = $active
        ORDER BY quickstart.id
    );
}

That is still the same idea:

  • the command is generic over one parameter type
  • the query is generic over a parameter type and a row type
  • tuples and structs are both valid choices if their codec support exists

Use structs when names make the code easier to read. Use tuples when the shape is small and positional. babar supports both because the generic API only cares about the capability to encode or decode the shape.

Traits: capability contracts, not inheritance trees

Rust traits answer a specific question: what can this type do?

For babar, the key capabilities are encoding and decoding database values. The custom codec chapter shows that directly:

#![allow(unused)]
fn main() {
impl Encoder<Uuid> for UuidCodec {
    fn encode(&self, value: &Uuid, params: &mut Vec<Option<Vec<u8>>>) -> babar::Result<()> {
        params.push(Some(value.as_bytes().to_vec()));
        Ok(())
    }
}

impl Decoder<Uuid> for UuidCodec {
    fn decode(&self, columns: &[Option<bytes::Bytes>]) -> babar::Result<Uuid> {
        /* ... */
        unimplemented!()
    }
}
}

Read those impls as:

  • UuidCodec knows how to encode a Uuid into Postgres parameters
  • UuidCodec knows how to decode a Uuid from Postgres columns

That is the Rust trait mental model that matters here. UuidCodec is not becoming a subtype of anything. It is declaring that it satisfies a capability contract.

This is why traits fit babar so naturally:

  • a query needs something that can encode parameters
  • a query needs something that can decode rows
  • the concrete Rust value can vary, as long as the required trait contract exists

Why #[derive(babar::Codec)] matters

Most application code should not implement Encoder and Decoder by hand. It should derive babar::Codec on normal Rust structs:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct DemoUser {
    id: i32,
    name: String,
}
}

That derive is important because it makes an ordinary Rust type usable at the SQL boundary:

  • as Command<DemoUser> input
  • as Query<_, DemoUser> output
  • as part of larger typed query/command contracts

In other words, a codec is not a side feature bolted on after the fact. It is the mechanism that lets babar keep SQL parameters and rows fully typed.

Derive first, manual codecs second

Use this rule of thumb in babar code:

  1. Derive babar::Codec for normal row-shaped or parameter-shaped structs
  2. Use tuples for tiny positional shapes when names would add noise
  3. Write Encoder / Decoder impls manually only when you need a type that babar does not already know how to map

That is the progression you see across the docs:

  • 2. Selecting uses derived structs for application rows
  • 10. Custom codecs shows the lower-level trait implementation when the built-in mapping is not enough

Where generics stay helpful in service code

The web-service docs keep using the same generic idea even when the surrounding code gets more realistic:

#![allow(unused)]
fn main() {
let insert: Command<(i32, String)> =
    service_schema::command!(INSERT INTO widgets (id, name) VALUES ($id, $name));

let select: Query<(i32,), (i32, String)> = service_schema::query!(
    SELECT widgets.id, widgets.name
    FROM widgets
    WHERE widgets.id = $widget_id
);
}

The handler code does not need to re-explain SQL decoding each time, because the generic type already says what shape goes in and what shape comes out.

That is the real win: the generic API is compact at the call site, but it keeps the data contract visible.

Python comparison (explicitly optional)

If you are coming from Python, keep the comparison narrow:

  • Traits are not base classes. They are closer to explicit capability contracts than to inheritance.
  • Generics are not duck typing. Query<Params, Row> states the expected shapes up front instead of waiting until runtime.
  • Derived codecs are not hidden serializers. In babar, they are part of the type-level contract between Rust and Postgres.

Useful bridge sentence: Python often asks “does this object behave correctly at runtime?” Rust often asks “have we made the required behavior explicit in the type system?”

Checkpoint

If this chapter clicked, you should be able to explain each of these without running code:

  1. In Query<Params, Row>, Params describes the bound input shape and Row describes the decoded output shape.
  2. #[derive(babar::Codec)] makes a normal struct usable at the SQL boundary.
  3. Encoder<A> and Decoder<A> are trait-based capability contracts, not an inheritance hierarchy.

Reflection prompts

  • When would a named struct be clearer than a tuple for a Command<Params> or Query<Params, Row>?
  • Why is “this type can be encoded/decoded” a better mental model than “this type belongs to a class hierarchy” for babar?
  • If you had to support a new Postgres type tomorrow, would you reach for #[derive(babar::Codec)] first or a manual trait impl first, and why?

8. Structs, impl, and Rust-flavored OOP

Rust has object-oriented pieces, but it does not push you toward “everything is a class.” In babar service code, the most useful OOP-flavored ideas are:

  • structs that package related state
  • impl blocks that attach focused behavior to a concrete type
  • composition of small pieces instead of inheritance trees

That is enough to read real service code without importing a Java or Python class model into Rust.

babar anchor

The API tutorial and Axum example both use the same pattern:

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState {
    pool: Pool,
}

struct Settings {
    api_addr: SocketAddr,
    pg_host: String,
    pg_port: u16,
    pg_user: String,
    pg_password: String,
    pg_database: String,
}

impl Settings {
    fn from_env() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        /* ... */
        unimplemented!()
    }

    fn database_config(&self) -> Config {
        /* ... */
        unimplemented!()
    }
}
}

This is a good Rust OOP anchor because it separates three concerns cleanly:

  • AppState stores long-lived shared application state
  • Settings stores configuration data
  • impl Settings defines behavior that is specifically about Settings

Structs package state; they do not hide it

In Rust, a struct is usually the answer to “which values should travel together?”

For the web-service path, that means:

  • AppState { pool } because every handler needs access to the pool
  • request/response structs such as CreateWidget and Widget because they define the shape of JSON at the HTTP boundary
  • Settings because the connection and server configuration belong together

That is object-oriented in the sense that data has named structure. But it is not class-heavy: the fields stay visible, ownership is still explicit, and behavior can live either in an impl block or in free functions.

What belongs in an impl block?

Use an impl block when the behavior is naturally about one type’s job.

Settings::from_env() is a good example:

  • it constructs a Settings
  • it keeps environment parsing logic near the type it creates
  • it gives callers a clear entry point: “ask Settings to build itself”

Settings::database_config(&self) is also a good method:

  • it reads the fields already owned by Settings
  • it produces another value derived from those fields
  • the behavior is coherent even outside the rest of the app

That is the practical Rust rule: put methods where the type gives the behavior a clear home.

What should stay a free function?

Many operations in babar examples are clearer as free functions:

#![allow(unused)]
fn main() {
async fn create_widget(
    State(state): State<AppState>,
    Json(payload): Json<CreateWidget>,
) -> Result<(StatusCode, Json<Widget>), (StatusCode, String)> {
    /* ... */
}

async fn get_widget(
    State(state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<Json<Widget>, (StatusCode, String)> {
    /* ... */
}
}

Why not make these methods on AppState?

  • Axum wants handler functions with a specific extractor-driven shape
  • the handler is about an HTTP route, not about AppState alone
  • keeping it as a free function makes the boundary explicit: inputs come from the router and the request, not from a hidden receiver object

This is one of Rust’s most important OOP lessons: methods are useful, but they are not mandatory. If a free function is clearer, prefer the free function.

Composition over inheritance in babar-style code

The babar docs lean on composition constantly:

  • AppState contains a Pool
  • handlers acquire a connection from that pool
  • query values and command values are composed into the handler logic
  • JSON types, SQL types, and configuration types stay separate

Nothing here needs a base DatabaseService class or a WidgetController hierarchy. The pieces are combined because they work together, not because they inherit from one another.

This is especially visible in the route setup:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/healthz", get(healthz))
    .route("/widgets", get(list_widgets).post(create_widget))
    .route("/widgets/:id", get(get_widget))
    .with_state(AppState { pool });
}

Router, AppState, handlers, and Pool each do one job. The application is built by wiring them together.

Traits are part of Rust’s OOP story too

Rust’s object-oriented features are not limited to structs and methods. Traits also matter because they let behavior stay abstract without forcing inheritance.

In this track, you already saw that with codecs:

  • a type can implement the traits needed for database encoding/decoding
  • the type does not need to inherit from a common database-row base class

So when people say Rust has “object-oriented features,” the useful version is:

  • data in structs
  • behavior in impl blocks
  • shared capabilities in traits
  • composition as the normal way to build larger systems

Python comparison (explicitly optional)

If you are coming from Python, the trap is to look for a class every time you see related data and behavior.

Rust-first correction:

  • a struct is often just a named data shape
  • an impl block is for behavior that truly belongs to that shape
  • many route handlers and helper operations stay as free functions
  • traits cover shared behavior more often than inheritance does

So the closest bridge is not “Rust classes.” It is “Rust lets you use some object-oriented organization tools, but it keeps them narrower and more explicit.”

Checkpoint

You are on solid ground if you can identify these three choices in the service examples:

  1. AppState is a struct because the pool needs to move through the router as one named unit.
  2. impl Settings exists because configuration-loading behavior belongs to the Settings type.
  3. create_widget and get_widget stay as free functions because they are route handlers, not methods that need a hidden receiver.

Reflection prompts

  • In the service examples, which data shapes are true domain objects, and which are just transport or configuration structs?
  • If you turned every handler into a method on one giant application type, what would become less clear?
  • Where does composition already do the job that inheritance might have done in another language?

9. Iterators, closures, and functional style

By the time you reach babar service code, Rust often stops looking like statement-by-statement imperative code and starts looking like a pipeline:

  • get rows
  • transform rows
  • collect a result

This chapter explains that style without pretending every loop should become an iterator chain. In Rust, the best question is not “can I make this look more functional?” but “which form makes ownership and intent clearest here?”

babar anchor

The clearest anchor is list_widgets from the Axum example:

#![allow(unused)]
fn main() {
let rows = conn
    .query(&select, (params.name, params.limit, params.offset))
    .await
    .map_err(db_error)?;

let widgets = rows
    .into_iter()
    .map(|(id, name)| Widget { id, name })
    .collect();
}

That tiny pipeline contains three core Rust ideas:

  1. into_iter() consumes the vector of rows
  2. map(...) transforms each row
  3. collect() gathers the transformed items into a new collection

Iterators turn “a collection” into “a sequence of items”

When session.query returns Vec<Row>, you often have a choice:

  • loop over &rows
  • turn rows into an iterator and build something new

The difference matters because the iterator method you pick says something about ownership.

Borrowing iteration

The quickstart example uses a plain borrowed loop:

#![allow(unused)]
fn main() {
for (id, name, active, note) in &active_rows {
    let note = note.as_deref().unwrap_or("(none)");
    println!("  id={id} name={name} active={active} note={note}");
}
}

&active_rows means:

  • keep the vector
  • borrow each row for reading
  • do not consume the collection

This is perfect when you only need to inspect or print the data.

Consuming iteration

The service example instead does this:

#![allow(unused)]
fn main() {
let widgets = rows
    .into_iter()
    .map(|(id, name)| Widget { id, name })
    .collect::<Vec<_>>();
}

into_iter() means:

  • take ownership of rows
  • move each row out of the vector
  • build a new Vec<Widget>

That fits because the old row vector is no longer needed after the mapping step.

Closures are small functions that can capture surrounding values

The map call above uses a closure:

#![allow(unused)]
fn main() {
|(id, name)| Widget { id, name }
}

You can read that as “for each row, make a Widget.”

A closure is often the shortest way to express a local transformation. In Rust, the important extra question is: what does the closure capture from its surroundings?

In the Widget mapping example, the closure only uses its input tuple, so there is no interesting capture. But closures can capture local values, and when they do, Rust cares whether they borrow or move those values.

That matters in service code because ownership rules do not disappear just because the code looks functional.

Functional style is common in row mapping

Here are the most common patterns you will see around babar:

map for shape changes

Turn SQL rows into API structs:

#![allow(unused)]
fn main() {
let widgets = rows
    .into_iter()
    .map(|(id, name)| Widget { id, name })
    .collect::<Vec<_>>();
}

next for one-row lookups

The book shows a compact one-row pattern:

#![allow(unused)]
fn main() {
let user = session
    .query(&user_by_id, UserById { id: 7 })
    .await?
    .into_iter()
    .next();
}

That says: “run the query, turn the vector into an iterator, and take the first item if one exists.”

The web-service example spells the same idea a little more explicitly:

#![allow(unused)]
fn main() {
let Some((id, name)) = rows.into_iter().next() else {
    return Err((StatusCode::NOT_FOUND, format!("widget {id} not found")));
};
}

collect for concrete output

Iterator adapters stay lazy until you ask for a concrete result. collect() is the point where you decide what collection you actually want, often Vec<_>.

That is why the list_widgets handler reads naturally as:

  1. fetch rows
  2. transform rows
  3. collect response objects

When a plain for loop is better

Rust does not treat iterator chains as automatically more advanced or more correct. Use a for loop when it is clearer.

The quickstart example is a good model:

#![allow(unused)]
fn main() {
for row in &rows {
    let n = session.execute(&insert, row.clone()).await?;
    println!("inserted {n} row(s) for id={}", row.0);
}
}

That loop is the right choice because each iteration:

  • performs an async database call
  • has a side effect
  • benefits from being read step by step

An iterator chain would hide the control flow more than it would help.

A practical reading rule

When you hit functional-looking Rust in babar, ask two questions:

  1. Is this pipeline borrowing items or consuming them?
  2. Is this pipeline clearer than the equivalent for loop for this job?

That rule gets you further than memorizing every iterator adapter up front.

Python comparison (explicitly optional)

If you know Python, some of this may resemble comprehensions, map, or generator pipelines. The important Rust-first differences are:

  • into_iter() vs iter() makes ownership visible
  • collect() makes the new collection boundary explicit
  • closure capture rules matter because values can move, not just be referenced

So the bridge is useful, but incomplete. Rust iterator code is still shaped by ownership and move semantics in ways Python does not surface.

Checkpoint

You should now be able to read these patterns in babar without treating them as magic:

  1. rows.into_iter().map(...).collect() means consume rows, transform them, and build a new collection.
  2. rows.into_iter().next() means consume the vector and take the first item if one exists.
  3. for row in &rows means borrow the collection for inspection or stepwise work without consuming it.

Reflection prompts

  • In the list_widgets pipeline, what value gets moved, and what new value gets built?
  • Why is a closure-based map a good fit for row-to-JSON transformation but a worse fit for the quickstart insert loop?
  • When you see into_iter() in Rust, what ownership question should you ask immediately?

Prerequisites

Before you connect, you need a Postgres to connect to. The cheapest debugger you’ll get on this whole journey is a Postgres that prints every byte it does back at you, so let’s run one of those.

A Postgres that talks back

Open a terminal, paste this, and leave it running. It’s a throwaway container — --rm means it disappears when you Ctrl-C, so nothing leaks past your tutorial session.

docker run --rm -it \
  --name babar-pg \
  -p 5432:5432 \
  -e POSTGRES_PASSWORD=postgres \
  postgres:17 \
  -c log_statement=all \
  -c log_min_duration_statement=0 \
  -c log_connections=on \
  -c log_disconnections=on

What each flag is doing for you:

  • --rm -it — foreground, throwaway, Ctrl-C to stop. No daemon, no cleanup chores later.
  • -p 5432:5432 — Postgres’ default port, exposed on localhost.
  • -e POSTGRES_PASSWORD=postgres — sets the password for the default postgres superuser. The postgres:17 image already creates that role and a database of the same name on first boot, so we just need to give it a password.
  • -c log_statement=all — every SQL statement gets logged.
  • -c log_min_duration_statement=0 — every statement also gets a duration logged, no threshold.
  • -c log_connections=on / -c log_disconnections=on — connection lifecycle in the same stream.

The connection string for everything that follows is:

postgres://postgres:postgres@localhost:5432/postgres

…which in Config form is:

#![allow(unused)]
fn main() {
use babar::Config;

let cfg = Config::new("localhost", 5432, "postgres", "postgres")  // type: Config
    .password("postgres")
    .application_name("first-query");
}

Why foreground?

Because the second window — the one tailing those logs — is where you’ll see exactly what babar sent on the wire. Prepared-statement names, parameter values, every BEGIN and COMMIT. When something surprises you in chapter 3 or chapter 7, your first move is to glance at that window. It is faster than any println you will ever write.

Stop it

Ctrl-C in the Postgres window. --rm cleans up the container; the data goes with it. That’s the point — every tutorial run is a fresh database.

Next

Your first query

This guide gets one complete round-trip working: connect to Postgres, create a small table, insert a row, and read typed rows back with babar’s primary SQL surface.

If you want a Rust-first walkthrough of the same example before diving deeper into the product docs, take the optional companion detour through Read a babar program and then come right back here.

Setup

Add babar and Tokio to your Cargo.toml, then put this in src/main.rs.

use babar::query::{Command, Query};
use babar::{Config, Session};

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct User {
    id: i32,
    name: String,
}

babar::schema! {
    mod app_schema {
        table demo_users {
            id: primary_key(int4),
            name: text,
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let cfg = Config::new("localhost", 5432, "postgres", "postgres")
        .password("postgres")
        .application_name("first-query");

    let session: Session = Session::connect(cfg).await?;

    let create: Command<()> =
        Command::raw("CREATE TEMP TABLE demo_users (id int4 PRIMARY KEY, name text NOT NULL)");
    session.execute(&create, ()).await?;

    let insert: Command<User> =
        app_schema::command!(INSERT INTO demo_users (id, name) VALUES ($id, $name));
    session
        .execute(
            &insert,
            User {
                id: 1,
                name: "Ada".to_string(),
            },
        )
        .await?;

    let users: Query<(), User> = app_schema::query!(
        SELECT demo_users.id, demo_users.name
        FROM demo_users
        ORDER BY demo_users.id
    );

    let rows: Vec<User> = session.query(&users, ()).await?;
    for row in &rows {
        println!("id={} name={}", row.id, row.name);
    }

    session.close().await?;
    Ok(())
}

Run it with a Postgres reachable on localhost:5432:

cargo run
# id=1 name=Ada

What to notice

Config is explicit

Config::new(host, port, user, database) takes the required connection fields by position. Optional settings are chained on after that: .password(...), .application_name(...), .connect_timeout(...), and more.

Session is the connection handle

Session::connect(cfg) opens one Postgres connection and starts babar’s background driver task for it. Public calls on Session are cancellation-safe: dropping the waiting future does not leave the wire protocol half-consumed.

schema! gives application SQL a typed home

babar::schema! { ... } records schema facts in Rust and generates schema-scoped wrappers like app_schema::query!(...) and app_schema::command!(...).

That is the main path for application SQL:

  • write schema facts once
  • use named placeholders like $id
  • let the macro infer the parameter and row shapes

When the same named field set is used on both sides of the round-trip, one Rust struct is enough. Add params = Type and row = Type only when you need to pin those shapes explicitly; Chapter 2 shows that form.

query! and command! define runtime values

The important types are still Query<A, B> and Command<A>:

  • A is the Rust value you bind when the statement runs
  • B is the per-row Rust value returned by a query

In the example above:

  • Command<User> inserts a User
  • Query<(), User> returns Vec<User>

There is no intermediate Row object and no .get::<T, _>() step after the query finishes. By the time session.query(...).await? returns, the bytes are already decoded into User values.

Raw SQL is explicit

The setup CREATE TEMP TABLE uses Command::raw(...) because DDL sits outside babar’s schema-aware typed-SQL subset.

When raw SQL still needs explicit parameters or row decoders, use the _with constructors:

  • Command::raw_with(sql, encoder)
  • Query::raw(sql, decoder)
  • Query::raw_with(sql, encoder, decoder)

That keeps the two paths easy to read:

  • schema-aware macros for normal application SQL
  • raw builders for bootstrap and advanced cases

Optional compile-time verification is available

If BABAR_DATABASE_URL or DATABASE_URL is set at macro expansion time, schema-aware SELECT queries can be checked against a live Postgres server. That verification confirms authored schema facts, placeholders, and projected columns for supported query! calls.

Without that environment variable, the same code still expands into the same runtime Query / Command values.

Next

  • Continue with Chapter 1: Connecting for more on connection settings, shutdown, and what the background driver task owns.
  • If you want a Rust-reading companion for this example, pause at Read a babar program.

1. Connecting

In this chapter we’ll use Config, Session::connect, and the background driver task that keeps every call you make cancellation-safe.

If the async model or the Session/driver-task split still feels new, the optional companion chapter Async/await and the driver task mental model gives the shortest Rust-first framing, then points back here.

Setup

use babar::{Config, Session};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let cfg = Config::new("localhost", 5432, "postgres", "postgres")
        .password("postgres")
        .application_name("ch01-connecting")
        .connect_timeout(std::time::Duration::from_secs(5));

    let session: Session = Session::connect(cfg).await?;        // type: Session
    println!(
        "server_version = {}",
        session.params().get("server_version").unwrap_or("?"),
    );
    session.close().await?;
    Ok(())
}

Config is a struct, not a string

Config::new(host, port, user, database) takes the four required fields by position. Optional fields are added by chained methods — .password(...), .application_name(...), .connect_timeout(...), TLS settings, and so on. Because Config is a plain struct you can build it from any source you like (env vars, a config file, a clap::Parser); babar deliberately doesn’t ship a DSN parser or a Config::from_env(). Connection details should be visible and explicit in code.

What Session::connect actually does

Session::connect(cfg) opens one TCP connection to Postgres, negotiates TLS if you asked for it, runs the SCRAM-SHA-256 handshake, exchanges startup parameters, and hands you back a Session. From that moment on, the Session is a thin handle: the real socket ownership lives in a background Tokio task that the Session spawns.

That background task is the reason every public call on Session is cancellation-safe. If you tokio::select! away from a query midway through, the protocol stays in a consistent state — the driver task finishes reading the in-flight messages even if you don’t await the result. The shape of the model is sketched in What makes babar babar; we dive into the details in explanation/driver-task.md.

Reading server parameters

#![allow(unused)]
fn main() {
let v = session.params().get("server_version").unwrap_or("?");
let tz = session.params().get("TimeZone").unwrap_or("?");
println!("server_version={v}, TimeZone={tz}");
}

session.params() returns the ParameterStatus map Postgres sent during startup. It’s read-only and updated by the server when it issues a ParameterStatus message.

Closing politely

session.close().await sends a Terminate and waits for the driver task to drain. If you drop the Session without calling close, the background task is still cancelled cleanly — but close lets you observe a final Result if the server objected to anything.

Recovering when the server is unreachable

Session::connect returns babar::Result<Session>. The error is the same babar::Error enum reviewed in Chapter 9; for connection failures you’ll typically see Error::Io(_) (DNS, TCP, TLS) or Error::Server { code, .. } (auth rejected, database missing). Inspect the variant directly — there’s no Error::kind() classifier.

Next

2. Selecting

This chapter shows the standard read path in babar: authored schema facts, schema-scoped query!, a typed parameter value, and typed rows returned from session.query.

Setup

use babar::query::{Command, Query};
use babar::{Config, Session};

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct ActiveUsers {
    active: bool,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserRecord {
    id: i32,
    name: String,
    active: bool,
}

babar::schema! {
    mod app_schema {
        table users {
            id: primary_key(int4),
            name: text,
            active: bool,
            note: nullable(text),
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch02-selecting"),
    )
    .await?;

    let create: Command<()> = Command::raw(
        "CREATE TEMP TABLE users (
            id int4 PRIMARY KEY,
            name text NOT NULL,
            active bool NOT NULL,
            note text
         )",
    );
    session.execute(&create, ()).await?;

    let insert: Command<UserRecord> =
        app_schema::command!(INSERT INTO users (id, name, active) VALUES ($id, $name, $active));
    session
        .execute(
            &insert,
            UserRecord {
                id: 1,
                name: "alice".to_string(),
                active: true,
            },
        )
        .await?;
    session
        .execute(
            &insert,
            UserRecord {
                id: 2,
                name: "bert".to_string(),
                active: false,
            },
        )
        .await?;

    let active_users: Query<ActiveUsers, UserRecord> = app_schema::query!(
        SELECT users.id, users.name, users.active
        FROM users
        WHERE users.active = $active
        ORDER BY users.id
    );

    let rows: Vec<UserRecord> = session
        .query(&active_users, ActiveUsers { active: true })
        .await?;
    for row in &rows {
        println!("{}	{}	{}", row.id, row.name, row.active);
    }

    session.close().await?;
    Ok(())
}

The shape of a query

Every Query<A, B> has two public-facing type parameters:

  • A — the bound parameter value
  • B — the decoded row value returned for each result row

That type is the contract for the round-trip. In the example above, Query<ActiveUsers, UserRecord> means:

  • call session.query(&query, ActiveUsers { ... })
  • get back Vec<UserRecord>

query! is the main way to build that value. With authored schema facts, the macro can infer both shapes directly from the SQL you wrote.

Schema-scoped wrappers are the reusable pattern

A schema! module gives application SQL a stable namespace and lets you keep the schema facts close to the code that depends on them.

#![allow(unused)]
fn main() {
use babar::query::Query;

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserById {
    id: i32,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserSummary {
    id: i32,
    name: String,
}

babar::schema! {
    mod app_schema {
        table public.users {
            id: primary_key(int4),
            name: text,
            active: bool,
        }
    }
}

let user_by_id: Query<UserById, UserSummary> = app_schema::query!(
    SELECT users.id, users.name
    FROM users
    WHERE users.id = $id AND users.active = true
);
}

For one-off examples or tests, inline schema works too:

#![allow(unused)]
fn main() {
use babar::query::Query;

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserById {
    id: i32,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserSummary {
    id: i32,
    name: String,
}

let user_by_id: Query<UserById, UserSummary> = babar::query!(
    schema = {
        table public.users {
            id: primary_key(int4),
            name: text,
            active: bool,
        },
    },
    SELECT users.id, users.name
    FROM users
    WHERE users.id = $id AND users.active = true
);
}

Pinning or exposing struct shapes

Schema-aware macros can also spell the intended struct contract directly at the macro site:

#![allow(unused)]
fn main() {
use babar::query::Query;

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserLookup {
    active: bool,
    id: i32,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserCard {
    id: i32,
    display_name: String,
}

let user_card: Query<UserLookup, UserCard> = app_schema::query!(
    params = UserLookup,
    row = UserCard,
    SELECT users.name AS display_name, users.id
    FROM users
    WHERE users.id = $id AND users.active = $active
);
}

Use params = Type and row = Type when the macro site should name the contract directly. Use params = _ and row = _ when you want the surrounding Query<A, B> or Command<A> type annotation to stay the source of truth while still making that choice explicit at the macro site. Omit both when the inferred tuple shapes are acceptable. If both are present, the explicit selection wins.

Current limitation: params = Type and params = _ are not yet supported for typed SQL statements that use optional placeholders ($value?) or toggle groups ((...)?). Those statements must omit the params selection and use the default tuple-shaped parameter contract.

Struct matching stays strict:

  • input structs must contain every required placeholder-backed field
  • extra input or row fields are rejected
  • field types and nullability must match the SQL contract
  • row structs match by final output names, so aliases such as display_name must line up with the Rust field name

Supported subset and explicit fallbacks

Schema-aware typed SQL stays intentionally small:

  • exactly one statement per macro call
  • named placeholders like $id, with repeated names reusing the same slot
  • explicit optional forms only where supported: $value? and (...)?
  • authored Rust schema only — no generated schema modules or offline cache

Supported authored column families include bool, bytea, varchar, text, int2, int4, int8, float4, float8, uuid, date, time, timestamp, timestamptz, json, jsonb, and numeric, plus nullable variants. Feature-gated families such as uuid, time, json, and numeric still require the matching Cargo feature.

When a statement sits outside that subset, use an explicit raw fallback:

  • Query::raw(sql, decoder) for zero-parameter raw queries
  • Query::raw_with(sql, encoder, decoder) for parameterized raw queries
  • Command::raw(sql) and Command::raw_with(sql, encoder) for commands

Nullable columns

Postgres columns are nullable by default. In authored schema, declare that with nullable(...) so the inferred row shape becomes Option<T>.

#![allow(unused)]
fn main() {
use babar::query::Query;

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserNote {
    id: i32,
    note: Option<String>,
}

babar::schema! {
    mod app_schema {
        table public.users {
            id: primary_key(int4),
            note: nullable(text),
        }
    }
}

let notes: Query<(), UserNote> = app_schema::query!(
    SELECT users.id, users.note
    FROM users
    ORDER BY users.id
);
}

With raw SQL, you spell the same choice yourself through the decoder.

Multiple rows

session.query(&query, args) always returns Vec<B> in server order. For a one-row lookup, taking the first element is perfectly normal:

#![allow(unused)]
fn main() {
let user = session
    .query(&user_by_id, UserById { id: 7 })
    .await?
    .into_iter()
    .next();
}

For larger result sets, prepare once and stream rows — see Chapter 4.

When to reach for raw queries

Use raw queries when the SQL shape is correct for Postgres but outside the schema-aware subset. Raw builders still keep typed parameters, typed rows, prepare support, and streaming; they just ask you to provide the codecs explicitly.

Next

Chapter 3: Parameterized commands covers write statements, sql! as a lower-level fragment builder, and the raw-command fallbacks.

3. Parameterized commands

This chapter covers write statements in babar: how Command<A> differs from Query<A, B>, how schema-scoped command! handles the common path, and where sql! plus the raw builders fit when you need a lower-level tool.

Setup

use babar::query::{Command, Query};
use babar::{Config, Session};

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct NewTodo {
    id: i32,
    title: String,
    done: bool,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct TodoId {
    id: i32,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct TodoFilter {
    done: bool,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct TodoRow {
    id: i32,
    title: String,
    done: bool,
}

babar::schema! {
    mod todo_schema {
        table todo {
            id: primary_key(int4),
            title: text,
            done: bool,
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch03-params"),
    )
    .await?;

    let create: Command<()> = Command::raw(
        "CREATE TEMP TABLE todo (
            id int4 PRIMARY KEY,
            title text NOT NULL,
            done bool NOT NULL DEFAULT false
         )",
    );
    session.execute(&create, ()).await?;

    let insert: Command<NewTodo> =
        todo_schema::command!(INSERT INTO todo (id, title, done) VALUES ($id, $title, $done));
    session
        .execute(
            &insert,
            NewTodo {
                id: 1,
                title: "buy milk".into(),
                done: false,
            },
        )
        .await?;

    let mark_done: Command<TodoId> =
        todo_schema::command!(UPDATE todo SET done = true WHERE todo.id = $id);
    let affected: u64 = session.execute(&mark_done, TodoId { id: 1 }).await?;
    println!("updated {affected} row(s)");

    let lookup: Query<TodoFilter, TodoRow> = todo_schema::query!(
        SELECT todo.id, todo.title, todo.done
        FROM todo
        WHERE todo.done = $done
        ORDER BY todo.id
    );
    for row in session.query(&lookup, TodoFilter { done: true }).await? {
        println!("{}	{}	{}", row.id, row.title, row.done);
    }

    session.close().await?;
    Ok(())
}

Command<A> vs Query<A, B>

A Command<A> describes a round-trip that does not return rows. session.execute(&command, args).await? returns a u64 affected-row count.

A Query<A, B> describes a round-trip that returns typed rows. session.query(&query, args).await? returns Vec<B>.

Both keep the same mental model: one Rust value in, one typed database round-trip out. The only difference is whether the server returns rows.

The default path: schema-aware query! / command!

Public query! and command! are the main typed-SQL entrypoints. They accept inline schema for one-off use, but the reusable pattern is a schema! module and its schema-scoped wrappers.

#![allow(unused)]
fn main() {
use babar::query::Command;

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct NewTodo {
    id: i32,
    title: String,
}

let insert: Command<NewTodo> = babar::command!(
    schema = {
        table public.todo {
            id: primary_key(int4),
            title: text,
        },
    },
    INSERT INTO todo (id, title) VALUES ($id, $title)
);
}

Or, with a reusable schema module:

#![allow(unused)]
fn main() {
use babar::query::Query;

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct TodoId {
    id: i32,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct TodoPreview {
    id: i32,
    title: String,
}

babar::schema! {
    mod todo_schema {
        table public.todo {
            id: primary_key(int4),
            title: text,
            done: bool,
        }
    }
}

let preview: Query<TodoId, TodoPreview> = todo_schema::query!(
    SELECT todo.id, todo.title
    FROM todo
    WHERE todo.id = $id AND todo.done = false
);
}

Schema-aware typed SQL stays intentionally narrow:

  • exactly one statement per macro call
  • authored Rust schema only
  • supported writes include INSERT ... VALUES, UPDATE ... WHERE, and DELETE ... WHERE
  • RETURNING remains explicit and row-shaped
  • optional ownership forms stay explicit: $value? and (...)?

Unsupported constructs fall back to raw SQL rather than expanding the macro surface into a general query builder or ORM.

Optional verification during macro expansion

If BABAR_DATABASE_URL or DATABASE_URL is set, supported schema-aware SELECT statements can be checked against a live Postgres server during macro expansion. That validation confirms schema facts, placeholders, and projected columns.

command! still expands into the same runtime Command<A> values and shares the same authored-schema pipeline; it just does not currently participate in that live verification hook.

For a technical walk through of how schema!, query!, and command! lower into runtime values, see The typed-SQL macro pipeline.

sql! is the lower-level fragment builder

sql! is the tool for composing SQL fragments with named placeholders. It is not a runnable statement by itself.

#![allow(unused)]
fn main() {
use babar::codec::{bool, int4, text};
use babar::query::Query;

let titles: Query<(i32, bool), (String,)> = babar::sql!(
    "SELECT title FROM todo WHERE ($predicate) AND done = $done",
    predicate = babar::sql!("id = $id", id = int4),
    done = bool,
)
.query((text,));
}

The shape is always:

fragment -> command/query -> run

Reach for sql! when you need fragment composition or when authored schema is not the right abstraction for the SQL you are assembling.

Raw builders

Use the raw constructors when you want one explicit statement value without the schema-aware macro layer:

  • Command::raw(sql) — zero-parameter raw command
  • Command::raw_with(sql, encoder) — parameterized raw command
  • Query::raw(sql, decoder) — zero-parameter raw query
  • Query::raw_with(sql, encoder, decoder) — parameterized raw query

These builders still use the extended protocol. They remain useful when you want prepare support, typed parameters, typed rows, or streaming, but the statement is outside the schema-aware subset.

simple_query_raw is the lower-level simple-protocol escape hatch for raw SQL strings, especially multi-statement bootstrap or migration-style work.

What the codec traits are doing

The raw builders and sql! operate on codec values. Each codec knows which Postgres OIDs it speaks and how to encode or decode the binary representation for that type.

  • Encoder<A> turns a Rust A into parameter bytes.
  • Decoder<B> turns one row into a Rust B.

Schema-aware macros generate statements that use the same codec machinery; they just let authored schema facts and SQL tokens describe the shapes for you.

Next

Chapter 4: Prepared queries & streaming shows how to prepare a statement once, execute it repeatedly, and stream the results.

4. Prepared queries & streaming

In this chapter we’ll prepare a statement on the server, run it many times without re-parsing, and stream a large result set in batches instead of buffering it all into a Vec.

Setup

use babar::query::{Command, Query};
use babar::{Config, Session};
use futures_util::StreamExt;

babar::schema! {
    mod app_schema {
        table prepared_demo {
            id: primary_key(int4),
            title: text,
        }
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch04-prepared"),
    )
    .await?;

    let create: Command<()> = Command::raw(
        "CREATE TEMP TABLE prepared_demo (id int4 PRIMARY KEY, title text NOT NULL)",
        (),
    );
    session.execute(&create, ()).await?;

    let insert: Command<(i32, String)> =
        app_schema::command!(INSERT INTO prepared_demo (id, title) VALUES ($id, $title));
    let prepared = session.prepare_command(&insert).await?;
    for (id, title) in [(1, "alpha"), (2, "beta"), (3, "gamma"), (4, "delta"), (5, "epsilon")] {
        prepared.execute((id, title.into())).await?;
    }
    prepared.close().await?;

    let scan: Query<(), (i32, String)> = app_schema::query!(
        SELECT prepared_demo.id, prepared_demo.title
        FROM prepared_demo
        ORDER BY prepared_demo.id
    );
    let mut rows = session.stream_with_batch_size(&scan, (), 2).await?;
    while let Some(row) = rows.next().await {
        let (id, title) = row?;
        println!("streamed {id}: {title}");
    }

    session.close().await?;
    Ok(())
}

prepare_command and prepare_query

When you call session.prepare_command(&cmd).await? (or prepare_query for a Query<A, B>), babar sends Parse once and gets back a server-side prepared statement that you can call as many times as you want. Each call avoids the Parse round-trip — the server already has the plan, parameter OIDs, and row description cached.

The prepared handle exposes the same execute(args) / query(args) methods you’d use on Session, just bound to one statement value. When you’re done, call .close().await to release the server-side name.

Streaming with stream_with_batch_size

For result sets that don’t fit comfortably in memory, swap session.query for session.stream_with_batch_size(&q, args, n). It returns a RowStream<B> that pulls rows from the server n at a time using a Postgres portal.

A few things to note:

  • Back-pressure — the driver only fetches the next batch when the consumer pulls
  • Cancellation is safe — dropping the stream closes the portal cleanly
  • Each item is Result<B, Error> — decode errors surface row-by-row

Choosing the statement surface before you prepare or stream

Prepared statements and streaming work with the runnable Command / Query values you hand to the session. That means the same surface ordering still applies:

PatternUse it for
query! / command! + prepare_* / query / stream_*Default path for supported schema-aware typed SQL.
Query::raw / Command::raw + prepare_* / query / stream_*Unsupported extended-protocol SQL where you still want typed params/rows.
simple_query_rawSimple-protocol bootstrap or multi-statement raw SQL; not the path for prepared or streaming typed work.

sql! stays available when you want fragment composition, but it is not a prepared statement on its own. Convert it to a Command or Query first.

Next

Chapter 5: Transactions introduces Session::transaction() and how to compose all of the above inside BEGIN / COMMIT.

5. Transactions

In this chapter we’ll wrap a sequence of statements in BEGIN / COMMIT, recover from a partial failure with a savepoint, and let babar’s closure-based API decide when to commit and when to roll back.

Setup

use babar::codec::{int4, text};
use babar::query::{Command, Query};
use babar::{Config, Error, Savepoint, Session, Transaction};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(                          // type: Session
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch05-tx"),
    )
    .await?;

    let create: Command<()> = Command::raw(
        "CREATE TEMP TABLE tx_demo (id int4 PRIMARY KEY, note text NOT NULL)",
        (),
    );
    session.execute(&create, ()).await?;

    session.transaction(|tx: Transaction<'_>| async move {            // type: Transaction<'_>
        let insert: Command<(i32, String)> = Command::raw(
            "INSERT INTO tx_demo (id, note) VALUES ($1, $2)",
            (int4, text),
        );
        tx.execute(&insert, (1, "outer-before".into())).await?;

        // Savepoint that intentionally rolls back.
        let middle = tx.savepoint(|sp: Savepoint<'_>| async move {
            sp.execute(&insert, (2, "savepoint".into())).await?;
            Err::<(), _>(Error::Config("rolling back inner savepoint".into()))
        }).await;
        assert!(matches!(middle, Err(Error::Config(_))));

        tx.execute(&insert, (3, "outer-after".into())).await?;
        Ok(())
    }).await?;

    let select: Query<(), (i32, String)> = Query::raw(
        "SELECT id, note FROM tx_demo ORDER BY id",
        (),
        (int4, text),
    );
    for (id, note) in session.query(&select, ()).await? {
        println!("{id}: {note}");                                     // committed: 1, 3
    }

    session.close().await?;
    Ok(())
}

session.transaction is closure-shaped

Session::transaction(body) takes an async closure that receives a Transaction<'_>. babar opens the transaction with BEGIN, runs your body, and:

  • if the closure returns Ok(_) — commits.
  • if the closure returns Err(_) — rolls back and surfaces your error.
  • if the closure panics — rolls back and re-raises the panic.

You never write COMMIT or ROLLBACK yourself, and you can’t forget to. The borrow checker won’t let you call methods on the underlying Session while the Transaction is alive — there’s exactly one in-flight request on the connection at a time. (This typestate discipline is one of the four properties that make babar distinctive; see What makes babar babar.)

Savepoints compose the same way

tx.savepoint(body) is the closure-shaped sibling for nested rollback scopes. Same rules: Ok releases the savepoint, Err rolls back to the savepoint and propagates the error. Savepoints can nest.

In the example above, the inner savepoint rolls back, but the outer transaction continues and commits rows 1 and 3. Row 2 is gone — as if the savepoint body had never run.

Returning values from a transaction

The closure’s Ok value is the transaction’s return value:

#![allow(unused)]
fn main() {
let next_id: i32 = session.transaction(|tx| async move {
    let q: Query<(), (i32,)> = Query::raw(
        "SELECT COALESCE(MAX(id), 0) + 1 FROM tx_demo",
        (),
        (int4,),
    );
    Ok(tx.query(&q, ()).await?[0].0)
}).await?;
}

tx carries the same execute / query / prepare_* / stream_with_batch_size methods you’ve used on Session, scoped to the transaction. When the closure returns, babar commits and you get your value.

Errors and isolation

If a statement inside the body fails, the closure typically returns Err, babar rolls back, and the transaction is gone. If you want to observe an error and keep going, wrap that one statement in a savepoint — the inner failure rolls the savepoint back without aborting the outer transaction.

Isolation level isn’t set by babar; if you need SERIALIZABLE or a read-only transaction, run SET TRANSACTION ... as the first statement in the body.

Next

Chapter 6: Pooling introduces Pool, which hands you transaction-capable sessions from a pool of warm connections.

6. Pooling

In this chapter we’ll trade Session::connect for a Pool of warm connections, discuss the knobs that matter, and see how prepared statements live alongside pooled connections.

Setup

use std::time::Duration;

use babar::codec::{int4, text};
use babar::query::{Command, Query};
use babar::{Config, HealthCheck, Pool, PoolConfig};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let connect = Config::new("localhost", 5432, "postgres", "postgres")
        .password("postgres")
        .application_name("ch06-pool");

    let pool: Pool = Pool::new(                                       // type: Pool
        connect,
        PoolConfig::new()
            .min_idle(2)
            .max_size(8)
            .acquire_timeout(Duration::from_secs(2))
            .idle_timeout(Duration::from_secs(30))
            .max_lifetime(Duration::from_secs(300))
            .health_check(HealthCheck::Ping),
    )
    .await?;

    // Each acquire() hands you a connection scoped to the binding.
    let conn = pool.acquire().await?;                                 // type: PoolConnection
    let create: Command<()> = Command::raw(
        "CREATE TEMP TABLE pool_demo (id int4 PRIMARY KEY, note text NOT NULL)",
        (),
    );
    let insert: Command<(i32, String)> = Command::raw(
        "INSERT INTO pool_demo (id, note) VALUES ($1, $2)",
        (int4, text),
    );
    let lookup: Query<(i32,), (String,)> = Query::raw(
        "SELECT note FROM pool_demo WHERE id = $1",
        (int4,),
        (text,),
    );

    conn.execute(&create, ()).await?;
    conn.execute(&insert, (1, "first checkout".into())).await?;

    let prepared = conn.prepare_query(&lookup).await?;
    println!("prepared on server as: {}", prepared.name());
    println!("{:?}", prepared.query((1,)).await?);

    drop(prepared);
    drop(conn);  // returns the connection to the pool

    pool.close().await;
    Ok(())
}

What a pool gives you

Pool::new(config, pool_config) opens up to max_size background connections, keeping at least min_idle warm and ready. pool.acquire() hands you a PoolConnection that behaves like a Sessionexecute, query, prepare_command, prepare_query, stream_with_batch_size, transaction, all of it.

Drop the PoolConnection and the pool reclaims it. Drop the Pool itself and outstanding handles continue working until they’re dropped, at which point the connections are closed.

The knobs that matter

FieldWhat it controls
min_idleMinimum number of warm connections kept open.
max_sizeHard ceiling on simultaneous connections (idle + in-use).
acquire_timeoutHow long pool.acquire() waits before returning PoolError::Timeout.
idle_timeoutHow long an idle connection lingers before being closed.
max_lifetimeHow long any connection (idle or in-use) lives before being recycled.
health_checkTest to apply when checking out: HealthCheck::None, HealthCheck::Ping, or HealthCheck::ResetQuery(sql) (runs an arbitrary SQL string on every checkout via the simple-query protocol).

A typical web service starts with min_idle = 2, max_size = 16, acquire_timeout = 2s, idle_timeout = 30s, max_lifetime = 30min, health_check = HealthCheck::Ping. Tune by watching p99 acquire times and Postgres’ own pg_stat_activity for connection churn.

Pooled prepared statements

Each PoolConnection is a real, distinct Postgres connection. Prepared statements live on the server, attached to that connection. That has two consequences worth holding in your head:

  • A prepared statement you make on conn_a is not visible from conn_b. Re-prepare on each connection (cheap — one round-trip), or use a shared statement cache if you build one on top.
  • When the pool recycles a connection (via max_lifetime or a failed health check), all of that connection’s prepared statements go with it. The next prepare_* call on a fresh connection rebuilds them.

Errors that come from the pool itself

pool.acquire() returns Result<PoolConnection, PoolError>. PoolError::AcquireFailed(babar::Error) wraps the underlying connect error; PoolError::Timeout is its own variant. Translate them into your service’s error type at the boundary — the pool example shows the pattern.

Next

Chapter 7: Bulk loads with COPY adds the binary COPY FROM STDIN path for ingesting many rows at once.

7. Bulk loads with COPY

In this chapter we’ll ingest many rows in a single round-trip with binary COPY FROM STDIN. Current limitations are discussed as well.

Setup

use babar::query::Query;
use babar::{Config, CopyIn, Session};

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct VisitRow {
    id: i32,
    email: String,
    active: bool,
    note: Option<String>,
    visits: i64,
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(                          // type: Session
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch07-copy"),
    )
    .await?;

    session
        .simple_query_raw(
            "CREATE TEMP TABLE bulk_visits (\
                id int4 PRIMARY KEY,\
                email text NOT NULL,\
                active bool NOT NULL,\
                note text,\
                visits int8 NOT NULL\
            )",
        )
        .await?;

    let rows = vec![
        VisitRow { id: 1, email: "ada@example.com".into(),  active: true,  note: Some("first".into()), visits: 7  },
        VisitRow { id: 2, email: "bob@example.com".into(),  active: false, note: None,                 visits: 3  },
        VisitRow { id: 3, email: "cara@example.com".into(), active: true,  note: Some("news".into()),  visits: 12 },
    ];

    let copy: CopyIn<VisitRow> = CopyIn::binary(                      // type: CopyIn<VisitRow>
        "COPY bulk_visits (id, email, active, note, visits) FROM STDIN BINARY",
        VisitRow::CODEC,
    );
    let affected: u64 = session.copy_in(&copy, rows.clone()).await?;  // type: u64
    println!("copied {affected} rows");

    let select: Query<(), VisitRow> = Query::raw(
        "SELECT id, email, active, note, visits FROM bulk_visits ORDER BY id",
        (),
        VisitRow::CODEC,
    );
    for row in session.query(&select, ()).await? {
        println!("{row:?}");
    }

    session.close().await?;
    Ok(())
}

What CopyIn::binary is doing

CopyIn::binary(sql, codec) describes a COPY ... FROM STDIN BINARY statement plus a codec for one row. session.copy_in(&copy, rows) sends Postgres’ binary COPY framing — a header, one length-prefixed binary tuple per row, and a trailer — and returns the rows-affected count once the server acknowledges.

The babar::Codec derive on VisitRow expands to an Encoder<VisitRow> / Decoder<VisitRow> pair, with field order matching the struct. That same VisitRow::CODEC is reusable for a SELECT decoder, as the example shows. One row type, one codec, two directions.

Why “binary” and “STDIN”?

  • Binary beats text for throughput: no string parsing on the server, no escaping rules, exact round-trip for bytea, numeric, timestamps, and so on.
  • STDIN is the direction where babar streams into Postgres. The driver task feeds rows as you produce them, so memory usage stays bounded — you can pass an iterator of millions of rows without buffering them all.

What COPY support does not include yet

babar’s COPY support is deliberately narrow at the moment:

  • COPY ... TO STDOUT (reading rows back via COPY) is not yet implemented — it’s on the roadmap, see explanation/roadmap.md.
  • Text and CSV formats (FORMAT text, FORMAT csv) are deferred. Use BINARY for now.
  • COPY FROM PROGRAM and COPY ... FROM <file> are server-side; they don’t go through the driver and aren’t part of babar’s surface.

Next

Chapter 8: Migrations introduces Migrator, FileSystemMigrationSource, and the migrations table.

8. Migrations

In this chapter we’ll point a Migrator at a directory of paired .up.sql / .down.sql files, ask it for a plan, apply pending migrations, and roll back when we change our minds.

Setup

use std::path::PathBuf;

use babar::migration::FileSystemMigrationSource;
use babar::{Config, Migrator, MigratorOptions, Session};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(                          // type: Session
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch08-migrate"),
    )
    .await?;

    let migrator: Migrator<FileSystemMigrationSource> =               // type: Migrator<FileSystemMigrationSource>
        Migrator::with_options(
            FileSystemMigrationSource::new(PathBuf::from("migrations")),
            MigratorOptions::new(),
        );

    // What's applied? What's pending?
    let applied = migrator.applied_migrations(&session).await?;
    let status = migrator.status(&applied)?;
    println!("{status:?}");

    // What would `up` do?
    let plan = migrator.plan_apply(&applied)?;
    println!("plan: {plan:?}");

    // Apply pending migrations.
    let applied_plan = migrator.apply(&session).await?;
    println!("applied: {applied_plan:?}");

    // Roll back the most recent migration.
    let rolled = migrator.rollback(&session, 1).await?;
    println!("rolled back: {rolled:?}");

    session.close().await?;
    Ok(())
}

File layout

FileSystemMigrationSource expects pairs of files in one directory:

migrations/
├── 0001__create_users.up.sql
├── 0001__create_users.down.sql
├── 0002__add_email_index.up.sql
└── 0002__add_email_index.down.sql

The naming convention is <version>__<name>.{up,down}.sql. Versions sort lexicographically — keep them zero-padded so 10 doesn’t sort before 2. Each .up.sql must have a matching .down.sql; missing or unpaired files surface as a clear Error at Migrator build time, not at apply time.

The migrations table

By default Migrator records applied migrations in public.babar_migrations. The schema and table name are configurable on MigratorOptions (.table(MigrationTable::new(schema, name)?)), and there’s an advisory-lock id (.advisory_lock_id(...)) that serializes concurrent migrators across processes — only one can hold the lock and apply at a time, so a deploy that races itself won’t double-apply.

Plan first, apply second

migrator.plan_apply(&applied)? returns a MigrationPlan describing exactly what it would do — same value apply() would consume — without touching the database. Use it for dry-runs in CI, for printing a migration preview, or for human approval gates.

migrator.apply(&session).await? runs the same plan transactionally, one migration per transaction by default. The transaction mode is configurable per migration via MigrationTransactionMode for the rare DDL that can’t run inside a transaction (CREATE INDEX CONCURRENTLY, for example).

Rolling back

migrator.rollback(&session, n).await? runs the .down.sql of the most recent n applied migrations, in reverse. If you need to undo just one, pass 1. If you need a planned dry-run first, plan_rollback(&applied, n)? is its read-only sibling.

The example CLI is just an example

crates/core/examples/migration_cli.rs is a thin, helpful wrapper around the Migrator API — babar-migrate status, plan, up, down --steps N. It’s an example, not a shipped binary. You can copy it into your project verbatim, adapt it, or ignore it entirely and call the Migrator API from your own deploy script.

Next

Chapter 9: Error handling walks through the babar::Error enum and how to classify failures from apply and everything else by inspecting the variant directly.

9. Error handling

This chapter covers the babar::Error enum, classifying failures by inspecting the variant directly, and pulling out the SQLSTATE codes your retry logic actually wants.

If you want a Rust-first bridge for ?, match, and translating database failures at a service boundary, pair this with the optional companion chapter Error handling and service boundaries.

Setup

use babar::codec::{int4, text};
use babar::query::Command;
use babar::{Config, Error, Session};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let session: Session = Session::connect(                          // type: Session
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch09-errors"),
    )
    .await?;

    let create: Command<()> =
        Command::raw("CREATE TEMP TABLE err_demo (id int4 PRIMARY KEY, name text NOT NULL UNIQUE)");
    session.execute(&create, ()).await?;

    let insert: Command<(i32, String)> = Command::raw_with(
        "INSERT INTO err_demo (id, name) VALUES ($1, $2)",
        (int4, text),
    );
    session.execute(&insert, (1, "ada".into())).await?;

    // Second insert violates the UNIQUE constraint — classify it.
    match session.execute(&insert, (2, "ada".into())).await {
        Ok(_) => unreachable!(),
        Err(err) => match classify(&err) {                            // type: Failure
            Failure::Duplicate => println!("duplicate name; skipping"),
            Failure::ServerOther { code } => println!("server error {code}"),
            Failure::IoOrClosed => println!("connection died; retry later"),
            Failure::Bug => println!("our bug, not the server's: {err}"),
        },
    }

    session.close().await?;
    Ok(())
}

#[derive(Debug)]
enum Failure {
    Duplicate,
    ServerOther { code: String },
    IoOrClosed,
    Bug,
}

fn classify(err: &Error) -> Failure {
    match err {
        Error::Server { code, .. } if code == "23505" => Failure::Duplicate,
        Error::Server { code, .. } => Failure::ServerOther { code: code.clone() },
        Error::Io(_) | Error::Closed { .. } => Failure::IoOrClosed,
        _ => Failure::Bug,
    }
}

The babar::Error enum, in one breath

There is no Error::kind() accessor. Classification is by match on the variant:

VariantWhen you see it
Error::Io(io::Error)Socket-level failure — DNS, TCP reset, TLS handshake.
Error::Closed { sql, origin }Server hung up or the driver task shut down with an in-flight request.
Error::Protocol(String)The server (or driver) sent a wire-protocol message that doesn’t fit the state machine. Always a bug somewhere.
Error::Auth(String)SCRAM rejected, password wrong, role can’t log in.
Error::UnsupportedAuth(String)Server asked for an auth method babar doesn’t speak (e.g. gss, sspi).
Error::Server { code, severity, message, detail, hint, position, sql, origin }ErrorResponse from Postgres. code is SQLSTATE — match on it.
Error::Config(String)Configuration problem caught before any I/O.
Error::Codec(String)An encoder or decoder rejected a value.
Error::ColumnAlignment { expected, actual, sql, origin }Decoder column count ≠ server’s RowDescription.
Error::SchemaMismatch { position, expected_oid, actual_oid, column_name, sql, origin }Decoder OID ≠ server’s column type.
Error::Migration(MigrationError)The migrator’s planning or apply step failed.

That’s eleven. They cover everything. You can build a small classify function once per service, and call it everywhere.

Why SQLSTATE matters more than the message

Error::Server.message is for humans. Error::Server.code (a five-character SQLSTATE) is for code. A few you may see often:

SQLSTATEClassMeaning
23505unique_violationDuplicate key.
23503foreign_key_violationMissing FK target.
23502not_null_violationNULL into a NOT NULL column.
40001serialization_failureSerializable transaction must retry.
40P01deadlock_detectedDeadlock; retry the whole transaction.
57014query_canceledStatement timeout fired.
57P01admin_shutdownServer is going away.

The full list is in reference/errors.md. For a retry budget on serialization failures, match on 40001 and run the transaction body again with backoff.

origin and sql for diagnostics

Several variants carry sql: Option<String> and origin: Option<Origin>. The sql! macro captures its callsite as an Origin, so when an error fires from inside a fragment-built query, the Display impl can point you back to the macro invocation — file, line, column. Surface those in your logs and you’ll spend a lot less time bisecting which INSERT blew up.

Translating to your service’s error type

At the boundary of your application, fold babar::Error into your domain error. The pattern from the Axum example is a good starting shape:

#![allow(unused)]
fn main() {
fn db_error(err: babar::Error) -> (StatusCode, String) {
    match err {
        babar::Error::Server { code, .. } if code == "23505" => {
            (StatusCode::CONFLICT, "already exists".into())
        }
        babar::Error::Auth(_) | babar::Error::UnsupportedAuth(_) => {
            (StatusCode::UNAUTHORIZED, "auth failed".into())
        }
        other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
    }
}
}

Next

10. Custom codecs

In this chapter we’ll go from “I want to read widgets.id as a uuid::Uuid” to a working Encoder<Uuid> / Decoder<Uuid> pair, and see when to reach for #[derive(babar::Codec)] instead of writing the traits by hand.

Setup

#![allow(unused)]
fn main() {
use babar::codec::{Decoder, Encoder};
use babar::types::Type;
use bytes::Bytes;
use uuid::Uuid;

const UUID_OID: u32 = 2950;

struct UuidCodec;

impl Encoder<Uuid> for UuidCodec {                                    // type: impl Encoder<Uuid>
    fn encode(&self, value: &Uuid, params: &mut Vec<Option<Vec<u8>>>) -> babar::Result<()> {
        params.push(Some(value.as_bytes().to_vec()));
        Ok(())
    }

    fn oids(&self) -> &'static [u32] { &[UUID_OID] }
    fn format_codes(&self) -> &'static [i16] { &[1] }                 // binary
}

impl Decoder<Uuid> for UuidCodec {                                    // type: impl Decoder<Uuid>
    fn decode(&self, columns: &[Option<Bytes>]) -> babar::Result<Uuid> {
        let bytes = columns[0]
            .as_ref()
            .ok_or_else(|| babar::Error::Codec("uuid: NULL".into()))?;
        let arr: [u8; 16] = bytes.as_ref().try_into()
            .map_err(|_| babar::Error::Codec("uuid: wrong length".into()))?;
        Ok(Uuid::from_bytes(arr))
    }

    fn n_columns(&self) -> usize { 1 }
    fn oids(&self) -> &'static [u32] { &[UUID_OID] }
    fn format_codes(&self) -> &'static [i16] { &[1] }
}

const UUID: UuidCodec = UuidCodec;
}

What you have to implement

Both traits are generic over a Rust value type A. Encoder<A> turns an &A into one or more parameter byte buffers; Decoder<A> turns N column buffers back into an A.

The Encoder<A> methods (format_codes and types have sensible defaults — implement them only when you need to override):

  • encode(&self, value, params) — push exactly oids().len() entries onto params. Some(bytes) for a value, None for SQL NULL.
  • oids() — the Postgres OIDs of the parameter slots, in order.
  • format_codes()0 for text format, 1 for binary; defaults to text. Use binary for everything you can.
  • types() — richer type metadata; default implementation derives this from oids().

The Decoder<A> methods (format_codes and types again have defaults you can usually skip):

  • decode(&self, columns) — consume the first n_columns() entries of columns and produce an A.
  • n_columns() — how many columns this decoder consumes.
  • oids() — column OIDs, in order. oids().len() == n_columns().
  • format_codes() — same convention as the encoder.

The driver checks the top-level decoder’s n_columns() against the server’s RowDescription for you; that’s how you get Error::ColumnAlignment instead of a panic when shapes don’t line up.

Use it just like a built-in codec

#![allow(unused)]
fn main() {
use babar::query::Query;

let q: Query<(Uuid,), (Uuid, String)> = Query::raw(
    "SELECT id, name FROM widgets WHERE id = $1",
    (UUID,),
    (UUID, babar::codec::text),
);
}

Codec values compose: the tuple (UUID, text) is itself a Decoder<(Uuid, String)>, because Decoder<A> is implemented for tuples whose elements implement Decoder<_>.

When to derive instead

If you have a Postgres composite type or a row-shaped struct, skip the trait impls entirely and use #[derive(babar::Codec)]:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserRow {
    id: i32,
    name: String,
    note: Option<String>,
    #[pg(codec = "varchar")]
    handle: String,
}
}

The derive expands to an Encoder<UserRow> / Decoder<UserRow> pair whose column order matches the struct. #[pg(codec = "...")] lets you override the codec per field — useful when the column type is varchar instead of text, for example. The generated codec is exposed as UserRow::CODEC and works in Command::raw, Query::raw, and CopyIn::binary exactly like any other.

The full example lives in crates/core/examples/derive_codec.rs.

Tips you’ll want before your first round-trip fails

  • Match the OID exactly. If your oids() says int4 (23) but the column is int8 (20), the driver returns Error::SchemaMismatch with both OIDs. Look them up with SELECT oid, typname FROM pg_type WHERE typname = 'uuid'.
  • Binary first, text only as a last resort. The binary representation is exact; the text representation involves Postgres’ IN/OUT functions and locale settings.
  • Handle NULL explicitly. A NULL column arrives as None in columns. If your type can’t be NULL, decode it directly. If it can, expose a nullable(...) wrapper or use Option<A> from your caller.
  • encode errors are user errors, not panics. Return Err(Error::Codec(...)) for unrepresentable values rather than panicking — the driver propagates it cleanly.

Next

Chapter 11: Building a web service wires a pool, custom codecs, and tracing together inside an Axum service.

11. Building a web service

In this chapter you’ll wire babar into an Axum HTTP service: a connection pool in shared state, JSON in / JSON out handlers, and schema-aware typed SQL for the application queries.

Setup

use std::net::SocketAddr;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use babar::query::{Command, Query};
use babar::{Config, Pool, PoolConfig};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
struct AppState {
    pool: Pool,
}

#[derive(Debug, Serialize)]
struct Widget {
    id: i32,
    name: String,
}

#[derive(Debug, Deserialize)]
struct CreateWidget {
    id: i32,
    name: String,
}

babar::schema! {
    mod app_schema {
        table public.widgets {
            id: primary_key(int4),
            name: text,
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    tracing_subscriber::fmt()
        .with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "babar=info".into()))
        .try_init()
        .ok();

    let cfg = Config::new("127.0.0.1", 5432, "postgres", "postgres")
        .password("postgres")
        .application_name("babar-axum-service");
    let pool = Pool::new(cfg, PoolConfig::new().max_size(8)).await?;

    initialize(&pool).await?;

    let app = Router::new()
        .route("/healthz", get(|| async { "ok" }))
        .route("/widgets", post(create_widget))
        .route("/widgets/:id", get(get_widget))
        .with_state(AppState { pool });

    let addr: SocketAddr = "127.0.0.1:3000".parse()?;
    println!("listening on http://{addr}");
    axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
    Ok(())
}

The handler shape

#![allow(unused)]
fn main() {
async fn initialize(pool: &Pool) -> babar::Result<()> {
    let conn = pool.acquire().await.map_err(pool_error)?;
    let create: Command<()> = Command::raw(
        "CREATE TABLE IF NOT EXISTS widgets (id int4 PRIMARY KEY, name text NOT NULL)",
        (),
    );
    conn.execute(&create, ()).await?;
    Ok(())
}

async fn create_widget(
    State(state): State<AppState>,
    Json(payload): Json<CreateWidget>,
) -> Result<(StatusCode, Json<Widget>), (StatusCode, String)> {
    let conn = state.pool.acquire().await.map_err(pool_http)?;
    let insert: Command<(i32, String)> =
        app_schema::command!(INSERT INTO widgets (id, name) VALUES ($id, $name));
    conn.execute(&insert, (payload.id, payload.name.clone()))
        .await
        .map_err(db_http)?;
    Ok((StatusCode::CREATED, Json(Widget { id: payload.id, name: payload.name })))
}

async fn get_widget(
    State(state): State<AppState>,
    Path(id): Path<i32>,
) -> Result<Json<Widget>, (StatusCode, String)> {
    let conn = state.pool.acquire().await.map_err(pool_http)?;
    let select: Query<(i32,), (i32, String)> = app_schema::query!(
        SELECT widgets.id, widgets.name
        FROM widgets
        WHERE widgets.id = $widget_id
    );
    let rows = conn.query(&select, (id,)).await.map_err(db_http)?;
    rows.into_iter()
        .next()
        .map(|(id, name)| Json(Widget { id, name }))
        .ok_or((StatusCode::NOT_FOUND, format!("widget {id} not found")))
}
}

Each handler:

  1. pulls a connection from the pool with pool.acquire()
  2. uses schema-aware query! / command! for application SQL
  3. reserves Command::raw for unsupported setup SQL such as the DDL in initialize
  4. maps babar::Error and babar::PoolError to HTTP responses at the boundary

Drop the connection between handlers — Axum will get a fresh one for the next request. Pass the Pool, not a long-lived connection handle, through your own service types.

Errors at the boundary

#![allow(unused)]
fn main() {
fn pool_http(err: babar::PoolError) -> (StatusCode, String) {
    (StatusCode::SERVICE_UNAVAILABLE, err.to_string())
}

fn db_http(err: babar::Error) -> (StatusCode, String) {
    match err {
        babar::Error::Server { code, .. } if code == "23505" => {
            (StatusCode::CONFLICT, "already exists".into())
        }
        babar::Error::Server { code, .. } if code == "23503" => {
            (StatusCode::UNPROCESSABLE_ENTITY, "foreign key violation".into())
        }
        other => (StatusCode::INTERNAL_SERVER_ERROR, other.to_string()),
    }
}
}

Use the SQLSTATE table from Chapter 9 to expand this map. Resist the temptation to expose Error’s full Display directly — it’s great for logs, but it leaks internals to clients.

Raw vs simple-query in service code

For most handlers, stick to query! / command!. When you need a fallback:

  • use Query::raw / Command::raw for unsupported single statements that should still use the extended protocol and typed params/rows
  • use simple_query_raw only for simple-protocol raw SQL strings, especially multi-statement bootstrap or maintenance work

That same split is why the example uses Command::raw for table setup instead of simple_query_raw: it is still one statement, still fits the extended protocol, and does not need raw result sets.

Where the spans come from

Once tracing_subscriber is initialized (any subscriber will do — fmt, tracing-opentelemetry, etc.), every Session::connect, Session::execute, Session::query, prepared statement, and transaction call records a span:

Span nameFields
db.connectdb.system, db.user, db.name, net.peer.name, net.peer.port
db.preparedb.system, db.statement, db.operation
db.executedb.system, db.statement, db.operation
db.transactiondb.system, db.operation

Field names follow OpenTelemetry semantic conventions, so any exporter that understands OTel naming gets useful signal for free.

What this gets you

The full axum_service example in crates/core/examples/axum_service.rs adds env-var parsing plus a listing endpoint, but it keeps the same shape: pool in state, one acquire per handler, schema-aware typed SQL for application queries, and raw fallbacks only where the typed subset is not the right tool.

Next

Chapter 12: TLS & security covers TlsMode, root certificates, and the SCRAM-SHA-256 channel-binding handshake.

12. TLS & security

In this chapter we’ll turn TLS on, point at a custom root certificate, pick a backend, and understand what SCRAM-SHA-256 channel binding buys us.

Setup

use std::path::PathBuf;

use babar::config::{TlsBackend, TlsMode};
use babar::{Config, Session};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    let cfg = Config::new("db.example.com", 5432, "postgres", "postgres")
        .password("postgres")
        .application_name("ch12-tls")
        .tls_mode(TlsMode::Require)                                    // type: Config (chained)
        .tls_backend(TlsBackend::Rustls)
        .tls_server_name("db.example.com")
        .tls_root_cert_path(PathBuf::from("/etc/ssl/certs/internal-ca.pem"));

    let session: Session = Session::connect(cfg).await?;               // type: Session
    println!(
        "negotiated TLS — server_version = {}",
        session.params().get("server_version").unwrap_or("?"),
    );
    session.close().await?;
    Ok(())
}

Three modes, pick one

TlsMode controls babar’s handshake posture:

TlsModeWhat babar does
DisableNever attempt TLS. Plain TCP.
PreferAsk for TLS; if the server refuses, fall back to plain TCP.
RequireDemand TLS. A server that refuses is a connection failure.

For anything outside localhost, use TlsMode::Require. Prefer is convenient for development against a server you don’t control; it’s also the mode an attacker would love your production deploy to use.

Two backends, pick one

TlsBackend::Rustls is the pure-Rust default; the cargo feature is rustls (and it’s in the default feature set). TlsBackend::NativeTls (cargo feature native-tls) uses the platform’s TLS stack (Schannel on Windows, Secure Transport on macOS, OpenSSL on Linux). Pick Rustls unless you have a specific reason — system roots, FIPS mode, smartcard support — to reach for the platform native-tls stack. See reference/feature-flags.md for the exact flag names.

Custom roots

tls_root_cert_path(path) reads a PEM bundle from disk and adds those certificates to the trusted root set for this connection. This is the right knob for self-signed dev CAs, internal CAs, and “corporate-root-of-trust”-style deployments. Without it, babar uses the backend’s default root store (system roots for NativeTls, webpki-roots for Rustls).

tls_server_name(name) overrides the SNI hostname babar sends in the handshake. Useful when you connect by IP but the certificate has a DNS name; useful when you tunnel through ssh -L. Leave it unset when the connection host already matches the certificate.

SCRAM-SHA-256 and channel binding

babar speaks Postgres’ modern auth handshake, SCRAM-SHA-256, with optional channel binding when TLS is in play. The short version:

  • Your password never crosses the wire — the client and server prove knowledge of the salted hash via challenge/response.
  • With channel binding (SCRAM-SHA-256-PLUS), the proof is bound to the TLS channel, so a man-in-the-middle who terminates TLS can’t reuse the proof against the real server. Postgres advertises SCRAM-SHA-256-PLUS over TLS connections; babar uses it automatically when both sides offer it.

babar also supports MD5 and cleartext-password auth for legacy servers, but if the server selects something babar doesn’t speak — gss, sspi, or any auth code babar hasn’t implemented — you get Error::UnsupportedAuth(_). The fix is almost always to update the server’s pg_hba.conf to use scram-sha-256 rather than weakening the client.

A “what could go wrong?” checklist

  • Error::Io(_) during connect with TLS on — usually a bad root cert, a hostname mismatch, or the server isn’t actually serving TLS on that port.
  • Error::UnsupportedAuth(_) — server’s pg_hba.conf selected an auth method babar doesn’t speak. Switch the role to scram-sha-256.
  • Error::Auth(_) — wrong password, role can’t log in, or password expired.
  • Error::Server { code: "28P01", .. } — invalid password, sent by the server instead of an Auth failure.

Next

Chapter 13: Observability zooms out from TLS to the spans, fields, and logs that make a production-running babar service legible.

13. Observability

In this chapter we’ll see what babar emits via tracing out of the box, attach a subscriber, and pick the fields you want flowing into your aggregator.

Setup

use babar::codec::{int4, text};
use babar::query::Query;
use babar::{Config, Session};

#[tokio::main(flavor = "current_thread")]
async fn main() -> babar::Result<()> {
    tracing_subscriber::fmt()                                          // type: Subscriber
        .with_env_filter(
            std::env::var("RUST_LOG").unwrap_or_else(|_| "babar=info".into()),
        )
        .with_target(false)
        .try_init()
        .ok();

    let session: Session = Session::connect(                           // type: Session
        Config::new("localhost", 5432, "postgres", "postgres")
            .password("postgres")
            .application_name("ch13-observability"),
    )
    .await?;

    let q: Query<(), (i32, String)> = Query::raw(
        "SELECT 1::int4, 'hello'::text",
        (),
        (int4, text),
    );
    let _ = session.query(&q, ()).await?;

    session.close().await?;
    Ok(())
}

What babar emits

There is no babar-specific subscriber to register. Initialize any tracing subscriber and you’ll start seeing spans:

SpanWhere it firesUseful fields
db.connectSession::connectdb.system, db.user, db.name, net.peer.name, net.peer.port
db.prepareprepare_command / prepare_querydb.statement, db.operation
db.executesession.execute, command.executedb.statement, db.operation
db.transactionsession.transaction, tx.savepointdb.operation

Field names follow OpenTelemetry’s database semantic conventions, so exporters (Jaeger, Tempo, Datadog APM, Honeycomb, …) understand them without translation. db.operation is the first SQL keyword (SELECT, INSERT, BEGIN, SAVEPOINT, …) — coarse but cheap to group by.

Picking a subscriber

SubscriberWhen to reach for it
tracing_subscriber::fmtLocal development, structured logs to stdout.
tracing-bunyan-formatterJSON logs your aggregator already understands.
tracing-opentelemetry + an OTLP exporterDistributed tracing alongside the rest of your services.

The Axum example uses tracing_subscriber::fmt with an env filter:

#![allow(unused)]
fn main() {
tracing_subscriber::fmt()
    .with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "babar=info".into()))
    .try_init()
    .ok();
}

That’s enough to see span enter/exit lines for every connect, query, and transaction — handy when something is stalling and you want to know whether it’s the pool, the prepare, or the server.

Setting application_name

Config::new(...).application_name("billing-svc") is the cheapest piece of observability babar offers. Postgres records it in pg_stat_activity.application_name, so your DBA can see which service is holding a long-running query open. Use a stable service-level name; don’t include a hostname or PID — the pool will multiplex many connections from one process.

What about metrics?

babar doesn’t ship metrics directly — there’s no built-in pool_acquire_latency_seconds histogram, for example. You assemble those at the boundary:

  • Pool acquire latency: time pool.acquire().await yourself and feed it into metrics::histogram! (or whichever crate you use).
  • Query latency: derive from the db.execute span duration via tracing-opentelemetry, or wrap your handlers in your service’s metrics layer.
  • Server-side stats (pg_stat_statements, pg_stat_activity): query them yourself with a periodic Query and push to your aggregator. babar gives you the round-trip; the policy is yours.

What you can answer once this is wired up

  • “Which endpoint’s db.execute p99 spiked at 14:32?” — span histograms from your tracing backend.
  • “Was that an in-flight query or a connect-time stall?”db.connect vs db.prepare vs db.execute span breakdown.
  • “Which service held that connection open?” — the application_name you set, surfaced by pg_stat_activity.

You’re done

That’s the Book. From Connecting to here, you have the entire user-facing surface of babar — and a sense for how to operate it in production.

For the precise types and methods, head to the Reference. For the why — design choices, the background driver task, comparisons with other Rust Postgres drivers — head to the Explanation section.

Codec catalog

Generated rustdoc: https://docs.rs/babar/latest/babar/codec/index.html

See also: Book Chapter 10 — Custom codecs.

Every codec babar ships, grouped by module. OIDs are the Postgres type OIDs the codec advertises in Bind / RowDescription. All codecs use the binary wire format unless noted.

babar::codec (always on)

Postgres typeOIDRust typeCodec valueModule
int2 / smallint21i16int2primitive
int4 / integer23i32int4primitive
int8 / bigint20i64int8primitive
float4 / real700f32float4primitive
float8 / double precision701f64float8primitive
bool16boolboolprimitive
text25Stringtextprimitive
varchar1043Stringvarcharprimitive
bpchar / char(n)1042Stringbpcharprimitive
bytea17Vec<u8>byteaprimitive
any (NULL-aware wrapper)n/aOption<T>nullable(C)nullable
T[]array OIDVec<T>array(C)array (feature array)

Codec constants are lowercase to match Postgres type names — int4, text, bool shadow the Rust primitives inside babar::codec. That’s deliberate; import the constants explicitly (use babar::codec::{int4, text};) and the prim names remain visible elsewhere.

Optional types — feature-gated

Postgres typeOIDRust typeCodec valueModuleFeature
uuid2950uuid::Uuiduuiduuiduuid
date1082time::Datedatetimetime
time1083time::Timetimetimetime
timestamp1114time::PrimitiveDateTimetimestamptimetime
timestamptz1184time::OffsetDateTimetimestamptztimetime
date1082chrono::NaiveDatechrono_datechronochrono
time1083chrono::NaiveTimechrono_timechronochrono
timestamp1114chrono::NaiveDateTimechrono_timestampchronochrono
timestamptz1184chrono::DateTime<Utc>chrono_timestamptzchronochrono
interval1186babar::codec::Intervalintervalintervalinterval
numeric1700rust_decimal::Decimalnumericnumericnumeric
json114serde_json::Value / T: Deserializejson / typed_json::<T>()jsonjson
jsonb3802serde_json::Value / T: Deserializejsonb / typed_json::<T>()jsonjson
inet869std::net::IpAddrinetnetnet
cidr650babar::codec::Cidrcidrnetnet
macaddr829babar::codec::MacAddrmacaddrmacaddrmacaddr
macaddr8774babar::codec::MacAddr8macaddr8macaddrmacaddr
bit(n)1560babar::codec::BitStringbitbitsbits
varbit1562babar::codec::BitStringvarbitbitsbits
hstoreserver-assignedbabar::codec::Hstorehstorehstorehstore
citextserver-assignedStringcitextcitextcitext
tsvector3614babar::codec::TsVectortsvectortext_searchtext-search
tsquery3615babar::codec::TsQuerytsquerytext_searchtext-search
vectorserver-assignedbabar::codec::Vectorvectorpgvectorpgvector
geometry (PostGIS)server-assignedT: geo_types::*geometry::<T>()postgispostgis
geography (PostGIS)server-assignedT: geo_types::*geography::<T>()postgispostgis
range<T>range OIDbabar::codec::Range<T>range(C)rangerange
multirange<T>mr OIDbabar::codec::Multirange<T>multirange(C)multirangemultirange (implies range)

Composing codecs

Most type-system muscle lives in combinators, not new codec modules:

CombinatorWhat it does
nullable(C)Adds NULL → Option<T> handling. Required for any column that can be NULL.
array(C)One-dimensional Postgres arrays as Vec<T>.
range(C)Postgres ranges over T.
multirange(C)Postgres multiranges (Postgres 14+).
(C1, C2, …)A row tuple — Decoder<(A, B, …)> is auto-implemented for tuples of decoders.

For non-'static user types, write your own Encoder<A> / Decoder<A> (Chapter 10) — the codec module’s Encoder<UnitStruct> glue is small.

Next

For the cargo features that gate these codecs, see feature-flags.md. For the error variants codecs return on bad bytes, see errors.md.

Error catalog

Generated rustdoc: https://docs.rs/babar/latest/babar/enum.Error.html

See also: Book Chapter 9 — Error handling.

Variants

Every babar::Error variant is matchable directly in application code.

VariantShapeWhen it fires
IoIo(std::io::Error)TCP, TLS, or socket I/O failure (DNS, refused, reset, EOF).
ClosedClosed { sql: Option<String>, origin: Option<Origin> }The session was closed and the call lost its connection. sql and origin carry the in-flight statement.
ProtocolProtocol(String)The server sent something babar cannot interpret as a valid protocol exchange.
AuthAuth(String)SCRAM rejected, password wrong, role cannot log in, or no password was configured.
UnsupportedAuthUnsupportedAuth(String)The server selected an authentication method babar does not implement.
ServerServer { code, severity, message, detail, hint, position, sql, origin }A PostgreSQL ErrorResponse. code is the five-character SQLSTATE.
ConfigConfig(String)Client-side configuration is invalid.
CodecCodec(String)An encoder or decoder rejected the bytes or the row shape.
ColumnAlignmentColumnAlignment { expected, actual, sql, origin }The decoder expected expected columns but RowDescription advertised actual.
SchemaMismatchSchemaMismatch { position, expected_oid, actual_oid, column_name, sql, origin }The decoder’s declared OID at position differs from the OID PostgreSQL returned.
MigrationMigration(MigrationError)A migration step failed; the inner enum carries the migration-specific cause.

Closed, Server, ColumnAlignment, and SchemaMismatch carry an origin field. When a statement came from sql!, query!, command!, or a schema-scoped wrapper generated by schema!, that origin points back to the call site.

SQLSTATE patterns

The code field on Error::Server is a five-character SQLSTATE. This section is guidance for application code, not an exhaustive registry. The full list lives in the PostgreSQL documentation: https://www.postgresql.org/docs/current/errcodes-appendix.html.

Constraint and concurrency

SQLSTATEClassCommon causeTypical reaction
23505unique_violationDuplicate key on insert/upsert.Map to a 409 in your service; consider INSERT ... ON CONFLICT.
23503foreign_key_violationInserting a row whose parent does not exist.422 / validation error.
23502not_null_violationMissing required column.422 / validation error.
23514check_violationA CHECK constraint rejected the row.422 / validation error.
40001serialization_failureConflicting concurrent transactions at SERIALIZABLE.Retry with backoff.
40P01deadlock_detectedThe deadlock detector aborted your transaction.Retry; investigate the lock order.

Authentication and resource

SQLSTATEClassCommon cause
28P01invalid_passwordWrong password.
28000invalid_authorization_specificationRole cannot log in or pg_hba.conf rejected the connection.
53300too_many_connectionsServer max_connections reached.
57P03cannot_connect_nowServer is starting up or recovering.

Schema

SQLSTATEClassCommon cause
42P01undefined_tableMissing table, often a missing migration.
42703undefined_columnMissing column or schema drift.
42P07duplicate_tableSetup attempted to create an existing table.

Choosing what to retry

A practical starting policy:

Variant / codeRetry?
Error::Io(_)Yes, with backoff. The connection is gone; the pool can reconnect.
Error::Server { code: "40001", .. }Yes — retry the whole transaction.
Error::Server { code: "40P01", .. }Yes — retry the whole transaction.
Error::Server { code: "57P03", .. }Yes, after a delay.
Error::Auth(_) / UnsupportedAuth(_)No. Surface to an operator.
Error::Codec(_) / ColumnAlignment / SchemaMismatchNo. Fix the code or schema expectations.
Other Error::ServerNo by default; classify by SQLSTATE.

Next

For codec shapes that can produce Error::Codec or SchemaMismatch, see codecs.md. For Config and PoolConfig settings that can produce Error::Config, see configuration.md.

Cargo features

Generated rustdoc: https://docs.rs/babar/latest/babar/index.html

See also: Book Chapter 12 — TLS & security and Chapter 10 — Custom codecs.

Every feature flag the babar crate (and its core crate babar-core) exposes. All features are off by default except the ones listed in default = [...].

TLS backends

FeatureWhat it enablesDefault?
rustlsThe pure-Rust TLS backend (TlsBackend::Rustls). Pulls in rustls, tokio-rustls, and rustls-native-certs.yes
native-tlsPlatform TLS via native-tls + tokio-native-tls (Schannel / Secure Transport / OpenSSL). Selectable via TlsBackend::NativeTls.no

Only one TLS backend is needed at runtime; you can enable both if you want to pick at runtime. Config::tls_mode(TlsMode::Disable) opts out of TLS entirely without touching features.

Codec features

Each row turns on a codec module under babar::codec. Disabling unused codec features is the most effective way to keep babar’s compile time and binary size small.

FeatureCodec moduleHeadline typesExtra deps
uuidbabar::codec::uuiduuid::Uuid ↔ Postgres uuiduuid
timebabar::codec::timetime::Date / Time / PrimitiveDateTime / OffsetDateTimetime
chronobabar::codec::chronochrono::NaiveDate / NaiveTime / NaiveDateTime / DateTime<Utc>chrono
numericbabar::codec::numericrust_decimal::Decimal ↔ Postgres numericrust_decimal
jsonbabar::codec::jsonserde_json::Value and typed_json::<T>() for Serialize + Deserializeserde, serde_json
arraybabar::codec::arrayarray(C) combinator for one-dimensional arraysfallible-iterator
rangebabar::codec::rangerange(C) combinator over discrete and continuous ranges
multirangebabar::codec::multirangemultirange(C) (Postgres 14+); implies range
intervalbabar::codec::intervalbabar::codec::Interval
netbabar::codec::netinet, cidr (IpAddr, Cidr)
macaddrbabar::codec::macaddrMacAddr, MacAddr8
bitsbabar::codec::bitsBitString for bit / varbit
hstorebabar::codec::hstoreHstore (BTreeMap<String, Option<String>>)
citextbabar::codec::citextStringcitext extension type
text-searchbabar::codec::text_searchTsVector, TsQuery
pgvectorbabar::codec::pgvectorVector for the pgvector extension
postgisbabar::codec::postgisgeometry::<T>() / geography::<T>() over geo-typesgeo-types

Pick what your application actually uses. A common starting set for an HTTP service:

babar = { version = "...", features = ["rustls", "uuid", "time", "json", "numeric"] }

Default features

Disable defaults if you want to ship with native-tls, or with TLS off entirely:

babar = { version = "...", default-features = false, features = ["native-tls", "uuid"] }

babar-macros

The proc-macro crate (babar-macros, exposed via babar::Codec and babar::sql) currently exposes no cargo features of its own — it’s unconditionally on when you depend on babar.

Next

For the runtime configuration of TLS, see configuration.md. For Postgres types and the codec values they map to, see codecs.md.

Configuration

Generated rustdoc: https://docs.rs/babar/latest/babar/struct.Config.html

See also: Book Chapter 1 — Connecting, Chapter 6 — Pooling, Chapter 12 — TLS & security.

babar::Config

Config holds everything Session::connect needs. Required fields are positional in the constructor; optional fields are chained methods. Build it from any source — env vars, a config file, a clap::Parser. babar deliberately doesn’t ship a DSN parser.

Constructors

MethodRequired arguments
Config::new(host, port, user, dbname)impl Into<String> for host/user/dbname, u16 for port. Resolves host via DNS at connect time.
Config::with_addr(addr, port, user, dbname)addr: IpAddr, port: u16, user/dbname as impl Into<String>. Skips DNS — useful for IP-direct deployments.

Optional fields (chained, value-returning)

MethodTypeDefaultNotes
.password(p)impl Into<String>noneSent to the server only as part of the auth handshake.
.application_name(n)impl Into<String>noneSurfaces in pg_stat_activity.application_name. Cheapest observability win.
.connect_timeout(d)DurationnoneWall-clock cap on Session::connect.
.tls_mode(m)TlsModeDisableDisable / Prefer / Require. Opt in to Prefer or Require explicitly. See ch12.
.require_tls()Sugar for .tls_mode(TlsMode::Require).
.tls_backend(b)TlsBackendRustls (with rustls feature)Rustls or NativeTls.
.tls_server_name(n)impl Into<String>hostOverride SNI / certificate-name match.
.tls_root_cert_path(p)impl Into<PathBuf>system roots / webpki-rootsPEM bundle of additional root CAs.

TLS-mode and backend enums

EnumVariantsRe-exported as
TlsModeDisable, Prefer, Requirebabar::config::TlsMode
TlsBackendRustls, NativeTlsbabar::config::TlsBackend

babar::PoolConfig

PoolConfig is everything Pool::new needs that isn’t a Config.

Constructor

PoolConfig::new() — conservative defaults. All knobs are chained, value-returning methods.

Knobs

MethodTypeDefaultNotes
.min_idle(n)usize0Keep at least n warm connections when traffic permits.
.max_size(n)usize16Hard cap on total connections in the pool.
.acquire_timeout(d)Duration30 secondsHow long pool.acquire() waits before returning PoolError::Timeout.
.idle_timeout(d)Durationunset (no idle timeout)Close idle connections older than this.
.max_lifetime(d)Durationunset (no lifetime cap)Recycle connections after this age regardless of idle state.
.health_check(h)HealthCheckHealthCheck::NonePer-acquire validation policy (off by default).

PoolError

VariantWhen
PoolError::Timeoutacquire_timeout elapsed before a slot freed up.
PoolError::AcquireFailed(babar::Error)The pool tried to open a fresh connection and the underlying Session::connect failed.
PoolError::PoolClosedThe pool itself has been closed.

Picking values

Some tested starting points:

Service shapemax_sizeacquire_timeoutmin_idle
HTTP service, low/medium traffic8165–10s0
HTTP service, high traffic≈ #worker threads × 21–3s≥ 2
Long-running batch / ETL1430s+0

Beyond that, watch:

  • pg_stat_activity for connection count vs server’s max_connections.
  • Pool acquire latency (you wrap it yourself; see Chapter 13).
  • p99 query latency vs pool size — if increasing max_size doesn’t move p99, the pool isn’t the bottleneck.

Next

For the cargo features that gate TLS backends and codec types, see feature-flags.md. For the errors these knobs can produce, see errors.md.

What makes babar babar

See also: Why babar, Design principles, and The typed-SQL macro pipeline.

This page explains where babar sits, what its public API is optimizing for, and which trade-offs stay visible in the design.

Where babar sits

┌─────────────────────────────────────────┐
│ your app                                │
├─────────────────────────────────────────┤
│ babar  (typed Query/Command values,     │
│         codecs, pool, COPY, migrations) │
├─────────────────────────────────────────┤
│ tokio  (TcpStream, tasks, cancellation) │
├─────────────────────────────────────────┤
│ Postgres wire protocol v3               │
└─────────────────────────────────────────┘

babar speaks the PostgreSQL wire protocol directly on top of Tokio. There is no libpq, no other Rust Postgres client under the surface, and no generic multi-database layer between your application and the server.

That keeps the exposed shapes recognizably Postgres-shaped: extended-protocol prepares, binary results, SCRAM authentication, channel binding over TLS, and binary COPY FROM STDIN.

Four design choices that show up everywhere

1. One background driver task owns the socket

#![allow(unused)]
fn main() {
let session: Session = Session::connect(cfg).await?;
}

Session is a handle. The connection itself lives in a Tokio task started by Session::connect. Public methods send requests to that task over channels and wait for the reply.

That design does two things:

  • it keeps public calls cancellation-safe
  • it guarantees there is one writer to the socket, even when Session is cloned and shared across tasks

The background driver task page covers the runtime mechanics in more detail.

2. Types describe the database boundary

Query<A, B> says which value shape goes in and which row shape comes back. Command<A> says which value shape goes in when no rows come back.

#![allow(unused)]
fn main() {
use babar::query::{Command, Query};

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct NewUser {
    id: i32,
    name: String,
    parent_id: Option<i32>,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserLookup {
    id: i32,
}

#[derive(Debug, Clone, PartialEq, babar::Codec)]
struct UserRow {
    name: String,
    parent_id: Option<i32>,
}

babar::schema! {
    mod app_schema {
        table public.users {
            id: primary_key(int4),
            name: text,
            parent_id: nullable(int4),
        }
    }
}

let insert: Command<NewUser> =
    app_schema::command!(INSERT INTO users (id, name, parent_id) VALUES ($id, $name, $parent_id));

let select: Query<UserLookup, UserRow> = app_schema::query!(
    SELECT users.name, users.parent_id
    FROM users
    WHERE users.id = $id
);
}

Those statement values are plain Rust values. The type system prevents mixing up row-returning and rowless operations, and it keeps parameter and row shapes visible at the call site.

3. Schema-aware macros and explicit raw builders are separate tools

The main application path is authored schema plus schema-scoped query! / command!. That keeps ordinary SQL concise and lets babar infer parameter and row shapes from the statement.

The explicit fallback path stays available too:

  • Command::raw / Command::raw_with
  • Query::raw / Query::raw_with
  • sql! for lower-level fragment composition

That split is intentional. babar does not try to hide which statements are in the schema-aware typed-SQL subset and which ones need explicit codecs.

4. Validation happens as early as the API can make it happen

babar prefers to surface mismatches at the statement boundary.

  • At bind time, the parameter shape is already part of the statement type.
  • At prepare time, row decoders are checked against RowDescription so schema drift shows up before row decoding begins.
  • At display time, server-positioned errors include SQL text and origin information so failures point back to the authored statement.
  • At macro expansion time, supported schema-aware SELECT statements can be checked against a live database when BABAR_DATABASE_URL or DATABASE_URL is set.

The result is not “every bug is impossible.” The result is that several classes of query-shape mistakes become impossible or fail earlier than runtime row handling.

What babar is deliberately not

  • Not multi-database. babar is for Postgres.
  • Not synchronous. The runtime model is async Tokio.
  • Not an ORM. SQL stays visible.
  • Not a fluent AST builder. babar keeps SQL text front and center.
  • Not a full migration platform. It ships a focused migration runner instead of a separate migration product surface.

Those boundaries keep the API small and keep the implementation aligned with the Postgres protocol it is built on.

When babar is a good fit

Reach for babar when you want:

  • Postgres-specific behavior without a lowest-common-denominator abstraction
  • typed statement values at the database boundary
  • schema-aware typed SQL for common application queries and commands
  • explicit raw fallbacks for the cases that need them
  • early feedback when statement shape and schema drift apart

If you need multi-database support, a full ORM, or a much broader SQL rewrite surface, another tool will fit better.

Why babar?

See also: Get started, the Book.

babar is a Rust client for Postgres. The project is organized around a simple idea: one clear shape for each database task.

Connect, run a typed query, run a command, stream a result, manage a transaction, hold a pool, ingest with COPY, run migrations — each task has a small surface area, and the codec layer stays explicit when you need it.

Three pillars

Ergonomic by design

Read it once, understand it forever. Queries are typed values, commands are typed values, and codecs are imported by name when you work at the raw layer.

Postgres at heart

babar speaks Postgres directly: extended-protocol prepares, binary results, SCRAM-SHA-256, channel binding over TLS, and binary COPY FROM STDIN for bulk ingest.

Built for the herd

A single background task owns the socket and serializes wire I/O, so public calls stay cancellation-safe. Pools, statement caches, and tracing spans are part of the design.

What “typed query” means here

In babar, a Query<Params, Row> is a runtime value. It carries:

  • SQL text
  • a parameter encoder for Params
  • a row decoder for Row

The schema-aware path uses schema!, query!, and command! to build those runtime values from authored schema facts and SQL. The explicit fallback path uses Query::raw, Query::raw_with, Command::raw, and Command::raw_with when you want to provide codecs yourself.

Design principles

See also: Why babar, What makes babar babar, and The typed-SQL macro pipeline.

These principles explain why the public API looks the way it does.

1. Typed at the boundary

Database operations are represented as typed values:

  • Query<P, R> for row-returning statements
  • Command<P> for statements without rows

That keeps the contract visible in the type itself. A reader can see the bound value shape and the decoded row shape without reconstructing it from runtime logic.

The same rule shapes the macro surface. schema!, query!, and command! produce the same Query<P, R> and Command<P> values you could build by hand with the raw constructors.

2. Async by construction

Each Session is backed by a background task that owns the TcpStream. Public API calls communicate with that task over channels and await the reply.

That runtime structure is what makes babar’s cancellation-safety story work: if a waiting future is dropped, the driver task still completes the in-flight protocol exchange before moving to the next request.

3. Native Postgres protocol, not a translation layer

babar speaks the Postgres v3 wire protocol directly. It does not wrap libpq, it does not shell out to a C client, and it does not flatten Postgres behavior behind a generic SQL abstraction.

That makes Postgres features show up directly in the API surface:

  • binary results
  • extended-protocol prepared statements
  • SCRAM authentication and channel binding
  • binary COPY FROM STDIN
  • row metadata checked against declared decoder OIDs

4. Validate, then run

babar pushes verification toward the earliest useful point.

  • Parameter encoders fix the bind shape before any network I/O.
  • Decoder column counts and OIDs are checked against RowDescription at prepare time.
  • Schema-aware query! can optionally verify supported SELECT statements against a live database during macro expansion.
  • Error values carry SQL text and origin information so failures stay tied to the authored statement.

This principle is why the typed-SQL surface stays narrow. babar would rather make unsupported cases explicit than claim broader coverage with weaker guarantees.

5. Explicit layers beat hidden magic

There is a deliberate separation between:

  • schema-aware macros for ordinary application SQL
  • sql! for lower-level fragment composition
  • raw builders for explicit codec-driven statements
  • simple-protocol raw execution for bootstrap and advanced escape hatches

That separation keeps each layer legible. It also means the docs can teach one primary path without pretending every SQL statement belongs in the same tool.

6. No unsafe

babar keeps unsafe out of the implementation. The macro crate forbids unsafe code, and the rest of the codebase follows the same line.

7. Small dependency surface, small feature surface

The default feature set is intentionally small. Optional codec families and TLS backends are feature-gated so applications only compile the integrations they need.

That reduces compile time, narrows dependency risk, and keeps the default build focused on the core Postgres client surface.

8. Operability is part of the API

Pools, statement caches, and tracing spans are not bolt-ons. Connection, prepare, and execute paths emit spans that fit standard database observability conventions, and application_name flows through to pg_stat_activity.

The point is not to ship an observability product. The point is to expose the seams production services need.

The typed-SQL macro pipeline

See also: What makes babar babar, Design principles, and Chapter 3: Parameterized commands.

This page explains how babar’s schema-aware typed-SQL macros turn authored schema facts and SQL tokens into runtime Query<P, R> and Command<P> values.

It is aimed at readers who want the architecture of the feature, not just the surface syntax.

The pipeline at a glance

schema! facts
    ↓
query! / command! input
    ↓
parse supported SQL subset
    ↓
infer parameter + row shapes
    ↓
generate Fragment + codecs
    ↓
emit Query<P, R> or Command<P>
    ↓
optional live verification for supported SELECT statements

The important point is that babar does not invent a second runtime model for the macro path. The macros lower into the same statement values the rest of the API uses.

1. schema! defines the facts the macros can rely on

schema! is the reusable front door. It records table, column, nullability, and primary-key facts in Rust syntax and emits schema-scoped wrappers.

#![allow(unused)]
fn main() {
babar::schema! {
    mod app_schema {
        table public.users {
            id: primary_key(int4),
            name: text,
            active: bool,
            note: nullable(text),
        }
    }
}
}

Those facts are intentionally authored, explicit, and local to your Rust code. The macro pipeline does not depend on generated schema files or an offline cache.

2. query! and command! parse a narrow SQL subset

The typed-SQL macros accept one statement at a time. Within that statement they look for a constrained set of forms:

  • SELECT projections and predicates for reads
  • INSERT ... VALUES, UPDATE ... WHERE, and DELETE ... WHERE for writes
  • named placeholders such as $id
  • explicit optional forms such as $value? and (...)? in supported positions

The subset stays small on purpose. babar is not trying to accept arbitrary SQL and partially reinterpret it. When a statement is outside the supported forms, the expected move is to use Query::raw, Query::raw_with, Command::raw, or Command::raw_with.

3. Placeholder names become parameter shapes

Named placeholders are collected, deduplicated, and ordered into the generated parameter encoder.

That lets the macro infer a single Rust value shape for the statement. If the SQL uses $id and $name, the generated statement expects a Rust value that can encode those fields in that order. In practice that usually means either:

  • a struct with matching field names
  • a tuple shape that matches the inferred parameter ordering

The docs recommend structs because they make the SQL-to-Rust correspondence obvious at the call site.

4. Projections become row decoders

For query!, the selected columns and schema facts are turned into a decoder for R.

That decoder carries:

  • the expected column count
  • the expected Postgres OIDs in column order
  • the decode logic for the Rust row shape

This matters because the macro result is not “typed text.” It is a Query<P, R> value with a generated decoder, ready for prepare-time validation and runtime row decoding.

5. Lowering targets Fragment, Query, and Command

At runtime, babar executes Fragment<A>, Query<A, B>, and Command<A> values. The macro path lowers into those same types.

Conceptually, the generated code builds:

  • SQL text that Postgres can execute
  • an encoder for the inferred parameter shape
  • a decoder for the inferred row shape, if the statement returns rows
  • origin metadata so errors can point back to the macro call site

That shared runtime model is why schema-aware macros and raw builders can coexist cleanly. They are different authoring surfaces for the same execution layer.

6. Live verification is optional and scoped

If BABAR_DATABASE_URL or DATABASE_URL is set during macro expansion, supported schema-aware SELECT statements can be checked against a live Postgres server.

That verification confirms, for the supported path:

  • schema facts line up with the database
  • placeholders have the expected types
  • projected columns match the inferred row shape

If the environment variable is absent, the macro still emits the same runtime statement value. Verification is an extra check, not a different API mode.

7. Why babar keeps one compiler for the public typed-SQL surface

The public typed-SQL story is easier to teach and easier to reason about when it has one pipeline:

  • schema! provides facts
  • query! and command! consume those facts
  • the result is a Query<P, R> or Command<P>

That keeps the mental model stable across:

  • inline schema examples
  • schema-scoped wrappers
  • optional live verification
  • runtime execution and prepare-time checks

The lower-level tools still matter, but they are intentionally separate layers, not alternate public compilers that need different explanations.

8. When to drop below the macro layer

Use the raw and fragment layers when one of these is true:

  • the statement is outside the schema-aware subset
  • you want explicit codec control
  • you need fragment composition with sql!
  • you are doing bootstrap or infrastructure work where authored schema facts are not the right abstraction

That is not a failure case. It is part of the design: the macro layer handles the common typed-SQL path, and the raw layer remains explicit for everything else.

Reading the pipeline from the outside in

If you are evaluating babar’s architecture, the macro pipeline says three things about the project:

  1. authored schema facts are a first-class input
  2. typed SQL lowers into ordinary runtime values instead of a separate execution system
  3. unsupported statements stay explicit instead of being hidden behind partial emulation

Those choices are what let babar keep a greenfield, Postgres-shaped typed-SQL story without turning the macro surface into an unbounded SQL compiler.

Comparisons

See also: Why babar, Design principles.

Trade-offs, not scorekeeping. These tools solve overlapping problems from different angles. The useful question is which shape fits your team, database scope, and operating model.

The table below compares babar with three common Rust choices: tokio-postgres, sqlx, and diesel.

Dimensionbabartokio-postgressqlxdiesel
Primary shapeTyped Postgres clientAsync Postgres driverAsync SQL toolkitORM / query DSL
Database scopePostgres onlyPostgres onlyMultiple databasesMultiple databases
Query APITyped runtime Query<P, R> / Command<P> valuesRaw SQL strings plus codec traitsRaw SQL, macros, row mapping helpersSchema-aware DSL and derives
SQL checking styleOptional online verification plus prepare-time validationMostly runtimeStrong compile-time emphasisSchema-driven compile-time DSL
Explicit codec modelYes, codecs are imported valuesUsually trait-based (ToSql / FromSql)Mostly inferred / mapped through traits and macrosMostly hidden behind derives / schema mapping
Current maturityNewer, intentionally focused surfaceMost battle-tested async Postgres optionLarge ecosystem and polished toolingMature ORM ecosystem
Strong fitPostgres-specific apps that want explicit typed values and protocol visibilityTeams that want established async Postgres coverage todayTeams that want compile-time SQL workflows or multi-database supportTeams that want an ORM and schema-driven query construction

Reading the trade-offs

babar and tokio-postgres

These two are the closest in scope: both are Postgres-specific async clients. The trade-off is mostly about API shape.

  • Choose babar when you want query and row shape visible in the type signature, explicit codec values, prepare-time schema checks, and richer SQL-origin error rendering.
  • Choose tokio-postgres when you want the most established async Postgres driver in Rust today, broader production history, or a feature babar still defers such as broader COPY, LISTEN / NOTIFY, or cancellation surface.

babar and sqlx

These overlap most for teams that like hand-written SQL but care about types and validation.

  • Choose babar when you want Postgres-specific APIs, explicit runtime codecs, and normal builds that do not depend on compile-time database connectivity.
  • Choose sqlx when compile-time SQL checking is the center of your workflow, you want offline-cache tooling, or you need a single client across multiple databases.

babar and diesel

Here the trade-off is more architectural than incremental.

  • Choose babar when you want SQL to stay SQL and prefer the protocol seam — codecs, prepare, COPY, transactions, pooling — to be the visible API.
  • Choose diesel when you want an ORM, schema-driven query construction, and a workflow built around derives, generated schema, and migration tooling.

Summary

If you want…Reach for
A typed Postgres client with one obvious way to do each thingbabar
The most battle-tested async Postgres driver in Rusttokio-postgres
Compile-time-verified SQL, multi-database supportsqlx
A schema-aware ORM with a strong DSLdiesel
  • Roadmap — what’s deferred (and therefore what tokio-postgres covers today that babar doesn’t).
  • Design principles — the why behind the trade-offs above.

The driver task

See also: Book Chapter 1 — Connecting, Design principles, and the optional Rust-learning companion Async/await and the driver task mental model.

Every Session in babar is backed by a single background task that owns the underlying TcpStream. This page explains what that task is, what it does, and why it exists.

If you want the shortest Rust-first mental model before reading the deeper architecture details here, start with Async/await and the driver task mental model and then return to this page.

Shape of the model

When you call Session::connect, babar:

  1. Opens the TCP connection and runs the startup + auth handshake.
  2. Spawns a background task (tokio::spawn) and gives it the read half and write half of the now-authenticated stream.
  3. Hands you back a Session value that holds an mpsc::Sender<Command> — the channel into the driver task — plus a small amount of cached server state (parameters, backend keys).

Every public call on Sessionquery, execute, prepare_query, prepare_command, transaction, copy_in, close — translates to a Command enum sent over that channel. Each Command carries a oneshot::Sender for its reply. The driver task pulls commands off the inbox, performs the protocol exchange against the server, and replies on the oneshot.

There is exactly one task per connection. The mpsc channel is the single point of serialization for everything that talks to that socket.

Why a task

Postgres’ wire protocol is asynchronous in the responses-arrive-as- they-arrive sense, but it is rigorously serial in the one request/response sequence at a time per connection sense. You cannot interleave two Bind/Execute/Sync cycles on the same socket — the server’s responses are in order and any client that pipelines them must consume the responses in order too.

If the public API directly wrote and read on the socket, every public call would need to lock against every other public call, and Tokio cancellation would tear half-finished protocol exchanges apart. Instead, babar puts the protocol state machine inside the task, and the public API becomes “send a Command, await the reply.” The cost of an extra mpsc hop buys two large benefits: cancellation safety and concurrency on a single connection.

Cancellation safety

If you tokio::select! on session.execute(&cmd, args) and the other branch wins, the future you abandon is just a oneshot::Receiver being dropped. The driver task notices the receiver is gone only after it finishes the in-flight Execute/Sync cycle — it never abandons the protocol mid-message. The next command waiting in the mpsc inbox runs after a clean protocol boundary.

That’s what we mean when we say every public call in babar is cancellation-safe. You don’t need to hold the future to its end.

Concurrency on one connection

You can spawn many tasks all calling into the same Session. They all hit the same mpsc channel; the driver task processes them in arrival order. Throughput is bounded by the connection, not by an arbitrary lock policy. Pipelining multiple short queries against one session is reasonable; if you need true concurrency, that’s what the Pool is for.

What lives on the task

The driver task owns:

  • The TcpStream halves and an oneshot per pending request.
  • The framing buffer (writes to tx_buf, reads chunked frames).
  • Parameter status updates as the server announces them.
  • The internal prepared-statement cache.

It explicitly does not own:

  • User-level types like Query<P, R> — those live in your code.
  • The Pool, which is a layer above sessions.
  • Codec implementations — codecs run on the calling task; the driver task only deals in Vec<Option<Bytes>> columns.

Shutdown

Session::close() sends a Close command, waits for the acknowledgement, and joins the task. Dropping a Session without calling close() causes the mpsc::Sender to be dropped; the driver task notices, sends Terminate, and exits cleanly. There is no detached task that outlives the Session value.

Why not async fn directly on the socket?

Two reasons.

First, cancellation correctness. If Session::execute were a plain async fn writing and reading on the socket, abandoning that future mid-Execute would leave the connection desynchronized — half a message sent, no Sync paired, the server still responding to the last frame. There is no clean way to recover from that without closing the connection. The driver-task model means the future is just a oneshot::Receiver, and abandoning it does not endanger anything.

Second, single-writer guarantees. Postgres’ protocol benefits from write coalescing (a Parse/Bind/Execute/Sync is one writev of small frames). With one task owning the writer, that coalescing is trivial; with many tasks, it requires either locks or a lock-free SPSC ring per worker — and at that point you’ve re-invented the driver task with extra steps.

Roadmap

See also: MILESTONES.md in the repository for the authoritative milestone list.

This page summarizes how babar’s roadmap is organized, what is currently in scope per milestone, and what has been intentionally deferred so the surface area stays honest.

Currently babar is in pre-Alpha – I would not use it unless you want to contribute, find bugs, and improve its rust API. Work for the time being will be focused on stabilizing the developer API and identifying if there is a real desire for babar’s approach in the rust community.

What’s in now

Across the early milestones, babar has shipped:

  • Wire protocol foundation: framing, startup, parameter status, graceful shutdown, the driver task.
  • Authentication: cleartext, MD5, SCRAM-SHA-256, SCRAM-SHA-256-PLUS (channel binding over TLS).
  • The typed core: Session, Query<P, R>, Command<P>, Fragment<A>, the Encoder/Decoder traits, and codec combinators (nullable, tuples, array, range, multirange).
  • The primitive codec set and the optional codec families (reference/codecs.md).
  • Prepared statements with a per-session cache, portal-backed streaming, and prepare_command / prepare_query.
  • Closure-shaped transactions and savepoints.
  • Binary COPY FROM STDIN for bulk ingest.
  • Pool with health checks, idle timeouts, and lifetime caps.
  • A library-first migration engine with advisory locking and checksums.
  • TLS via rustls (default) or native-tls.
  • tracing spans with OpenTelemetry semantic conventions.

What’s deferred

Some features are not currently in babar either due to time or choice. Currently missing features include:

  • A streaming-notifications API based on LISTEN / NOTIFY is on the roadmap but not yet shipped. Use a polling loop or a sidecar service in the meantime
  • DSN parsing / Config::from_env() and other connection APIs for convenience will be added as needs dictate
  • ORM / query DSL is not currently planned, babar is focused on enabling writing SQL
  • Why babar — the high-level pitch.
  • Comparisons — honest trade-offs vs other Rust Postgres clients.

Postgres API from Scratch

This tutorial walks through a small Postgres-backed HTTP API built with:

  • Tokio for async execution
  • Axum for HTTP routing
  • babar for typed Postgres access

It assumes you already know basic Rust syntax, structs, and Result, but have not spent much time with Tokio yet.

If you want a shorter Rust-first async refresher before working through the service code, the optional companion Async/await and the driver task mental model pairs well with this tutorial.

We will start from an empty directory, bootstrap a tiny server, then grow it into a coherent one-resource JSON API for tracking elephant herds and their grazing grounds.

1. Before we write code

What we are building

By the end of this walkthrough you will have:

  • a new Rust binary project
  • an Axum server listening on 127.0.0.1:3000
  • a shared babar::Pool stored in application state
  • startup code that creates a herds table if it does not exist yet
  • a GET /healthz endpoint so you can prove the service is alive
  • a POST /herds endpoint to register a herd
  • a GET /herds endpoint to list herds
  • a GET /herds/:id endpoint to fetch one herd

We will build that in two stages:

  1. get the runtime, router, and database bootstrap in place
  2. add JSON handlers on top of that working foundation

Prerequisites

You need:

  • Rust stable and cargo
  • a running PostgreSQL server
  • a shell where you can set environment variables
  • basic Rust familiarity

Helpful but optional:

  • psql so you can inspect the database manually
  • the companion examples in this repository:
    • crates/core/examples/quickstart.rs
    • crates/core/examples/todo_cli.rs
    • crates/core/examples/axum_service.rs

Why these tools

  • Tokio runs async Rust code and handles network I/O.
  • Axum gives us routing, request extraction, and JSON responses.
  • babar gives us a typed Postgres client and pool that fit naturally into a Tokio application.

The main service path uses a Pool, not a single Session, because a web server may handle many requests at once. Each request can borrow a database connection from the pool when it needs one.

2. Start from an empty directory

Create a new project:

cargo init herd-api --bin
cd herd-api

Add the dependencies we need for the bootstrap and the API:

cargo add axum
cargo add tokio --features macros,rt-multi-thread,net
cargo add babar
cargo add serde --features derive
cargo add serde_json
cargo add tracing
cargo add tracing-subscriber --features fmt,env-filter

Why add serde now even though the first endpoint is plain text? Because the next sections accept and return JSON, so it is simpler to install the full set once.

Configuration: keep it boring and explicit

For a beginner tutorial, environment variables are a good fit:

  • they keep secrets like passwords out of source code
  • they work the same in local dev, CI, and containers
  • they avoid adding a config framework before we need one

Export these values before running the server:

export PGHOST=127.0.0.1
export PGPORT=5432
export PGUSER=postgres
export PGPASSWORD=postgres
export PGDATABASE=postgres
export API_ADDR=127.0.0.1:3000

If your local Postgres uses different values, change them here. PGPASSWORD is the one most likely to differ.

We will also write the Rust code so local defaults exist for the whole local-dev setup. That keeps the first run easy while still making the connection settings obvious.

3. Tokio in one mental model

If you are new to Tokio, this is the shortest useful mental model:

  • an async fn does not run by itself; it returns a value called a future
  • a runtime polls that future and wakes it back up when it can make progress
  • Tokio is the runtime that does that work for us

Why does that matter here?

  • Axum waits for incoming HTTP requests
  • babar waits for Postgres network reads and writes
  • Tokio lets one process manage all of that waiting efficiently

When an async function hits .await, it is basically saying: “I cannot finish this step right now; please come back when the socket is ready.” Tokio can then run other work instead of blocking the whole thread.

That is why the tutorial uses:

#[tokio::main]
async fn main() { /* ... */ }

#[tokio::main] creates a Tokio runtime for the program and lets main be async, so we can:

  • create the Postgres pool with .await
  • run startup SQL with .await
  • start the Axum server with .await

You do not need to know every Tokio API before writing a web service. For this tutorial, the important rule is simpler: if something touches the network, it will usually be async, and Tokio is what makes that async code run.

4. Build the bootstrap server

Replace src/main.rs with this:

use std::net::SocketAddr;

use axum::routing::get;
use axum::Router;
use babar::query::Command;
use babar::{Config, Pool, PoolConfig};

#[derive(Clone)]
struct AppState {
    pool: Pool,
}

struct Settings {
    api_addr: SocketAddr,
    pg_host: String,
    pg_port: u16,
    pg_user: String,
    pg_password: String,
    pg_database: String,
}

impl Settings {
    fn from_env() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        let api_addr = std::env::var("API_ADDR")
            .unwrap_or_else(|_| "127.0.0.1:3000".into())
            .parse()?;

        let pg_host = std::env::var("PGHOST").unwrap_or_else(|_| "127.0.0.1".into());
        let pg_port = std::env::var("PGPORT")
            .ok()
            .and_then(|value| value.parse().ok())
            .unwrap_or(5432);
        let pg_user = std::env::var("PGUSER").unwrap_or_else(|_| "postgres".into());
        let pg_password =
            std::env::var("PGPASSWORD").unwrap_or_else(|_| "postgres".into());
        let pg_database =
            std::env::var("PGDATABASE").unwrap_or_else(|_| "postgres".into());

        Ok(Self {
            api_addr,
            pg_host,
            pg_port,
            pg_user,
            pg_password,
            pg_database,
        })
    }

    fn database_config(&self) -> Config {
        Config::new(
            &self.pg_host,
            self.pg_port,
            &self.pg_user,
            &self.pg_database,
        )
        .password(&self.pg_password)
        .application_name("herd-api")
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    tracing_subscriber::fmt()
        .with_env_filter(
            std::env::var("RUST_LOG")
                .unwrap_or_else(|_| "herd_api=info,babar=info".into()),
        )
        .with_target(false)
        .init();

    let settings = Settings::from_env()?;
    let pool = Pool::new(settings.database_config(), PoolConfig::new().max_size(8)).await?;

    initialize_schema(&pool).await?;

    let app = Router::new()
        .route("/healthz", get(healthz))
        .with_state(AppState { pool });

    tracing::info!("listening on http://{}", settings.api_addr);
    let listener = tokio::net::TcpListener::bind(settings.api_addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn initialize_schema(
    pool: &Pool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let conn = pool.acquire().await?;

    let create_herds: Command<()> = Command::raw(
        "CREATE TABLE IF NOT EXISTS herds (
            id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
            name text NOT NULL,
            grazing_ground text NOT NULL
        )",
    );

    conn.execute(&create_herds, ()).await?;
    Ok(())
}

async fn healthz() -> &'static str {
    "ok"
}

What this code is doing

There are a few important ideas packed into a small file.

Settings::from_env

This function keeps configuration loading in one place. That pays off quickly:

  • main stays readable
  • every environment variable has one obvious home
  • later, if you want stricter validation, you can add it here

#[tokio::main]

This is the Tokio bridge from regular Rust into async Rust. Without it, none of the .await calls in main would compile.

Pool::new(...)

This is the first real babar setup step. A pool gives the application a small set of reusable Postgres connections. In a web service that is almost always a better starting point than passing around one shared connection handle.

In the API section, each request handler will:

  1. borrow the pool from AppState
  2. acquire() a connection
  3. run a typed Command or Query
  4. return the connection to the pool automatically when the request finishes

initialize_schema

This tutorial keeps the schema story deliberately simple at first:

  • on startup, create the one table we need
  • keep the SQL visible
  • avoid introducing migrations before the API itself exists

That is good enough for a beginner walkthrough and a single table. Once the app starts growing, the next step is to move this into babar migrations so schema changes are tracked explicitly instead of living inside main.rs.

Command<()>

Even though this SQL does not take parameters, we still use a babar Command. The () means “this command expects no input values.” In the next section we will keep using typed Command and Query values for herd inserts and herd lookups.

5. Run the bootstrap

Start the server:

cargo run

You should see a log line like:

listening on http://127.0.0.1:3000

In another shell, confirm the server responds:

curl http://127.0.0.1:3000/healthz

Expected response:

ok

If you have psql, you can also confirm that startup initialization created the table:

psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -c '\d herds'

If the server starts and /healthz returns ok, your bootstrap is working.

6. Grow the bootstrap into a herd registry API

Now replace src/main.rs with this fuller version:

use std::net::SocketAddr;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::get;
use axum::{Json, Router};
use babar::codec::{int8, text};
use babar::query::{Command, Query};
use babar::{Config, Pool, PoolConfig};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
struct AppState {
    pool: Pool,
}

type HttpError = (StatusCode, String);

#[derive(Debug, Deserialize)]
struct CreateHerd {
    name: String,
    grazing_ground: String,
}

#[derive(Debug, Serialize)]
struct Herd {
    id: i64,
    name: String,
    grazing_ground: String,
}

struct Settings {
    api_addr: SocketAddr,
    pg_host: String,
    pg_port: u16,
    pg_user: String,
    pg_password: String,
    pg_database: String,
}

impl Settings {
    fn from_env() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        let api_addr = std::env::var("API_ADDR")
            .unwrap_or_else(|_| "127.0.0.1:3000".into())
            .parse()?;

        let pg_host = std::env::var("PGHOST").unwrap_or_else(|_| "127.0.0.1".into());
        let pg_port = std::env::var("PGPORT")
            .ok()
            .and_then(|value| value.parse().ok())
            .unwrap_or(5432);
        let pg_user = std::env::var("PGUSER").unwrap_or_else(|_| "postgres".into());
        let pg_password =
            std::env::var("PGPASSWORD").unwrap_or_else(|_| "postgres".into());
        let pg_database =
            std::env::var("PGDATABASE").unwrap_or_else(|_| "postgres".into());

        Ok(Self {
            api_addr,
            pg_host,
            pg_port,
            pg_user,
            pg_password,
            pg_database,
        })
    }

    fn database_config(&self) -> Config {
        Config::new(
            &self.pg_host,
            self.pg_port,
            &self.pg_user,
            &self.pg_database,
        )
        .password(&self.pg_password)
        .application_name("herd-api")
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    tracing_subscriber::fmt()
        .with_env_filter(
            std::env::var("RUST_LOG")
                .unwrap_or_else(|_| "herd_api=info,babar=info".into()),
        )
        .with_target(false)
        .init();

    let settings = Settings::from_env()?;
    let pool = Pool::new(settings.database_config(), PoolConfig::new().max_size(8)).await?;

    initialize_schema(&pool).await?;

    let app = Router::new()
        .route("/healthz", get(healthz))
        .route("/herds", get(list_herds).post(create_herd))
        .route("/herds/:id", get(get_herd))
        .with_state(AppState { pool });

    tracing::info!("listening on http://{}", settings.api_addr);
    let listener = tokio::net::TcpListener::bind(settings.api_addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn initialize_schema(
    pool: &Pool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let conn = pool.acquire().await?;

    let create_herds: Command<()> = Command::raw(
        "CREATE TABLE IF NOT EXISTS herds (
            id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
            name text NOT NULL,
            grazing_ground text NOT NULL
        )",
    );

    conn.execute(&create_herds, ()).await?;
    Ok(())
}

async fn healthz() -> &'static str {
    "ok"
}

async fn create_herd(
    State(state): State<AppState>,
    Json(payload): Json<CreateHerd>,
) -> Result<(StatusCode, Json<Herd>), HttpError> {
    let conn = state.pool.acquire().await.map_err(pool_error_http)?;

    let insert_herd: Command<(String, String)> = Command::raw_with(
        "INSERT INTO herds (name, grazing_ground) VALUES ($1, $2)",
        (text, text),
    );
    conn.execute(&insert_herd, (payload.name.clone(), payload.grazing_ground.clone()))
        .await
        .map_err(db_error)?;

    let current_herd_id: Query<(), (i64,)> = Query::raw(
        "SELECT currval(pg_get_serial_sequence('herds', 'id'))",
        (int8,),
    );
    let herd_id = conn
        .query(&current_herd_id, ())
        .await
        .map_err(db_error)?
        .into_iter()
        .next()
        .map(|(id,)| id)
        .ok_or_else(|| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                "insert succeeded but no id was returned".to_string(),
            )
        })?;

    let select_herd: Query<(i64,), (i64, String, String)> = Query::raw_with(
        "SELECT id, name, grazing_ground FROM herds WHERE id = $1",
        (int8,),
        (int8, text, text),
    );
    let herd = conn
        .query(&select_herd, (herd_id,))
        .await
        .map_err(db_error)?
        .into_iter()
        .next()
        .map(herd_from_row)
        .ok_or_else(|| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                "inserted herd could not be loaded back".to_string(),
            )
        })?;

    Ok((StatusCode::CREATED, Json(herd)))
}

async fn list_herds(State(state): State<AppState>) -> Result<Json<Vec<Herd>>, HttpError> {
    let conn = state.pool.acquire().await.map_err(pool_error_http)?;

    let list_herds: Query<(), (i64, String, String)> = Query::raw(
        "SELECT id, name, grazing_ground FROM herds ORDER BY id",
        (int8, text, text),
    );
    let herds = conn
        .query(&list_herds, ())
        .await
        .map_err(db_error)?
        .into_iter()
        .map(herd_from_row)
        .collect();

    Ok(Json(herds))
}

async fn get_herd(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<Json<Herd>, HttpError> {
    let conn = state.pool.acquire().await.map_err(pool_error_http)?;

    let get_herd: Query<(i64,), (i64, String, String)> = Query::raw_with(
        "SELECT id, name, grazing_ground FROM herds WHERE id = $1",
        (int8,),
        (int8, text, text),
    );
    let herd = conn
        .query(&get_herd, (id,))
        .await
        .map_err(db_error)?
        .into_iter()
        .next()
        .map(herd_from_row)
        .ok_or_else(|| (StatusCode::NOT_FOUND, format!("herd {id} not found")))?;

    Ok(Json(herd))
}

fn herd_from_row((id, name, grazing_ground): (i64, String, String)) -> Herd {
    Herd { id, name, grazing_ground }
}

#[allow(clippy::needless_pass_by_value)]
fn pool_error_http(err: babar::PoolError) -> HttpError {
    (StatusCode::SERVICE_UNAVAILABLE, err.to_string())
}

#[allow(clippy::needless_pass_by_value)]
fn db_error(err: babar::Error) -> HttpError {
    (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}

This is still a small program, but now it has the three things most API tutorials need:

  • request models for incoming JSON
  • response models for outgoing JSON
  • handlers that turn HTTP input into typed database operations

7. Router, state, and handler mental model

The router is the table of contents for your service:

#![allow(unused)]
fn main() {
let app = Router::new()
    .route("/healthz", get(healthz))
    .route("/herds", get(list_herds).post(create_herd))
    .route("/herds/:id", get(get_herd))
    .with_state(AppState { pool });
}

Read it from top to bottom:

  • GET /healthz calls healthz
  • GET /herds calls list_herds
  • POST /herds calls create_herd
  • GET /herds/:id calls get_herd

AppState is how shared dependencies reach the handlers:

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct AppState {
    pool: Pool,
}
}

Because Pool is stored in state, handlers do not open brand-new database connections themselves. They borrow the shared pool, acquire one connection for the request, and hand it back automatically when the handler returns.

That keeps the handler story simple:

  1. Axum matches the route
  2. Axum extracts the inputs for that route
  3. the handler runs a typed database operation
  4. the handler returns JSON or an HTTP error

8. Request and response models

The two JSON-facing structs are intentionally boring:

#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
struct CreateHerd {
    name: String,
    grazing_ground: String,
}

#[derive(Debug, Serialize)]
struct Herd {
    id: i64,
    name: String,
    grazing_ground: String,
}
}

CreateHerd is the shape we accept from clients. It does not have an id because Postgres creates that for us.

Herd is the shape we send back. It includes the generated id, so clients can fetch the herd again later.

This separation is useful even in a tiny tutorial:

  • request models describe what the client must send
  • response models describe what the server promises to return

9. How Axum extracts input

Axum handlers declare their inputs directly in the function signature.

JSON body extraction

create_herd uses:

#![allow(unused)]
fn main() {
Json(payload): Json<CreateHerd>
}

That means:

  • Axum reads the request body
  • Axum parses it as JSON
  • Axum deserializes it into CreateHerd

If the body is missing required fields or is not valid JSON, Axum returns an error response before your handler logic runs.

Path extraction

get_herd uses:

#![allow(unused)]
fn main() {
Path(id): Path<i64>
}

That means the :id portion of /herds/:id is parsed as an i64. If the client sends /herds/abc, Axum rejects it because abc cannot become an integer.

State extraction

Both handlers use:

#![allow(unused)]
fn main() {
State(state): State<AppState>
}

That is how they reach the shared Pool.

10. Typed Command and Query values

The database layer is small, but it is already doing something important: turning SQL into typed Rust values.

Create uses a typed Command

The insert step is:

#![allow(unused)]
fn main() {
let insert_herd: Command<(String, String)> = Command::raw_with(
    "INSERT INTO herds (name, grazing_ground) VALUES ($1, $2)",
    (text, text),
);
}

Read that type literally:

  • this is a Command
  • it takes a (String, String) parameter tuple
  • those two Rust values are encoded with the text codec

When the handler executes it, the payload values must match that shape:

#![allow(unused)]
fn main() {
conn.execute(&insert_herd, (payload.name.clone(), payload.grazing_ground.clone()))
    .await?;
}

That is the beginner-friendly mental model for Command: write something, but do not expect rows back.

Create then uses a small Query to load the inserted row

Because id is generated by the database, the handler asks Postgres for the id that was just created on this same connection:

#![allow(unused)]
fn main() {
let current_herd_id: Query<(), (i64,)> = Query::raw(
    "SELECT currval(pg_get_serial_sequence('herds', 'id'))",
    (int8,),
);
}

Then it runs another query to fetch the full herd:

#![allow(unused)]
fn main() {
let select_herd: Query<(i64,), (i64, String, String)> = Query::raw_with(
    "SELECT id, name, grazing_ground FROM herds WHERE id = $1",
    (int8,),
    (int8, text, text),
);
}

This is a helpful first example of Query:

  • the first type parameter is the input tuple
  • the second type parameter is the row tuple we expect back

List uses a typed Query

The list endpoint does not need parameters, so its input type is ():

#![allow(unused)]
fn main() {
let list_herds: Query<(), (i64, String, String)> = Query::raw(
    "SELECT id, name, grazing_ground FROM herds ORDER BY id",
    (int8, text, text),
);
}

That says: “no input values, and every row should decode as (i64, String, String).”

Get-by-id uses a typed Query

The single-herd lookup takes one i64 id and expects one decoded row shape:

#![allow(unused)]
fn main() {
let get_herd: Query<(i64,), (i64, String, String)> = Query::raw_with(
    "SELECT id, name, grazing_ground FROM herds WHERE id = $1",
    (int8,),
    (int8, text, text),
);
}

Notice the single-element tuple syntax:

  • (i64,) for the Rust type
  • (int8,) for the codec tuple

The trailing comma matters because Rust distinguishes (i64,) from plain i64.

11. How handlers map database results to HTTP responses

The handlers stay small because each one follows the same shape.

Create

create_herd:

  1. acquires a pooled connection
  2. executes the typed insert command
  3. queries the generated id
  4. queries the inserted row
  5. returns 201 Created plus Json<Herd>

The return type makes that explicit:

#![allow(unused)]
fn main() {
Result<(StatusCode, Json<Herd>), HttpError>
}

List

list_herds runs one query, maps each row tuple into a Herd, collects them into a Vec<Herd>, and returns:

#![allow(unused)]
fn main() {
Result<Json<Vec<Herd>>, HttpError>
}

Get one herd

get_herd runs the lookup query and then checks whether any row came back:

#![allow(unused)]
fn main() {
.into_iter()
.next()
.map(herd_from_row)
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("herd {id} not found")))?;
}

That is the HTTP mapping in one place:

  • row found -> 200 OK with JSON
  • no row found -> 404 Not Found

Database failures map to 500 Internal Server Error, and pool acquisition failures map to 503 Service Unavailable.

12. Try the finished API

Start the server:

cargo run

The example responses below assume a fresh herds table. If you already ran the tutorial once against the same database, the returned id values may be higher and GET /herds may include earlier rows too.

Create a herd:

curl -X POST http://127.0.0.1:3000/herds \
  -H 'content-type: application/json' \
  -d '{"name":"Royal Herd","grazing_ground":"Great Forest Meadow"}'

Expected response:

{"id":1,"name":"Royal Herd","grazing_ground":"Great Forest Meadow"}

List herds:

curl http://127.0.0.1:3000/herds

Expected response:

[{"id":1,"name":"Royal Herd","grazing_ground":"Great Forest Meadow"}]

Fetch one herd:

curl http://127.0.0.1:3000/herds/1

Expected response:

{"id":1,"name":"Royal Herd","grazing_ground":"Great Forest Meadow"}

Ask for a herd that does not exist:

curl http://127.0.0.1:3000/herds/999

Expected response body:

herd 999 not found

13. Add observability before production

A small async service still needs observability. Once a request can cross Axum, Tokio, and Postgres, a plain error string stops being enough. Good logs and traces help you answer three practical questions quickly:

  • did the service start with the settings you expected?
  • which request is running, and how long did it take?
  • did the slow or failing step happen in HTTP handling or in Postgres?

That matters even more in async code, because .await lets Tokio pause one task while other work runs. Observability gives you a breadcrumb trail back through those pauses.

Add request, startup, and handler tracing

We already initialized tracing in main, which is the right place to do it. Set up the subscriber before loading settings, opening the pool, or running startup SQL so those steps emit events too.

Add one more dependency so Axum creates a request span for every HTTP call:

cargo add tower-http --features trace

The changed pieces in the same main.rs look like this:

use std::net::SocketAddr;

use axum::extract::{MatchedPath, Path, State};
use axum::http::{Request, StatusCode};
use axum::routing::get;
use axum::{Json, Router};
use babar::codec::{int8, text};
use babar::query::{Command, Query};
use babar::{Config, Pool, PoolConfig};
use serde::{Deserialize, Serialize};
use tower_http::trace::TraceLayer;
use tracing::{info, instrument};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    tracing_subscriber::fmt()
        .with_env_filter(
            std::env::var("RUST_LOG")
                .unwrap_or_else(|_| "tower_http=info,herd_api=info,babar=info".into()),
        )
        .with_target(false)
        .compact()
        .init();

    let settings = Settings::from_env()?;
    info!(
        api_addr = %settings.api_addr,
        pg_host = %settings.pg_host,
        pg_database = %settings.pg_database,
        "starting herd-api",
    );

    let pool = Pool::new(settings.database_config(), PoolConfig::new().max_size(8)).await?;
    initialize_schema(&pool).await?;
    info!("schema ready");

    let app = Router::new()
        .route("/healthz", get(healthz))
        .route("/herds", get(list_herds).post(create_herd))
        .route("/herds/:id", get(get_herd))
        .with_state(AppState { pool })
        .layer(
            TraceLayer::new_for_http()
                .make_span_with(|request: &Request<_>| {
                    let matched_path = request
                        .extensions()
                        .get::<MatchedPath>()
                        .map(MatchedPath::as_str)
                        .unwrap_or("<unmatched>");

                    tracing::info_span!(
                        "http.request",
                        method = %request.method(),
                        matched_path,
                    )
                })
                .on_response(|response, latency, _span| {
                    info!(
                        status = response.status().as_u16(),
                        latency_ms = latency.as_millis() as u64,
                        "request finished",
                    );
                }),
        );

    info!("listening on http://{}", settings.api_addr);
    let listener = tokio::net::TcpListener::bind(settings.api_addr).await?;
    axum::serve(listener, app).await?;
    Ok(())
}

#[instrument(name = "startup.initialize_schema", skip(pool))]
async fn initialize_schema(
    pool: &Pool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    info!("ensuring herds table exists");
    let conn = pool.acquire().await?;

    let create_herds: Command<()> = Command::raw(
        "CREATE TABLE IF NOT EXISTS herds (
            id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
            name text NOT NULL,
            grazing_ground text NOT NULL
        )",
    );

    conn.execute(&create_herds, ()).await?;
    Ok(())
}

#[instrument(name = "handler.create_herd", skip(state, payload))]
async fn create_herd(
    State(state): State<AppState>,
    Json(payload): Json<CreateHerd>,
) -> Result<(StatusCode, Json<Herd>), HttpError> {
    info!(
        herd.name = %payload.name,
        herd.grazing_ground = %payload.grazing_ground,
        "registering herd",
    );

    let conn = state.pool.acquire().await.map_err(pool_error_http)?;
    // ... insert + select exactly as before ...
    info!(herd.id = herd_id, "herd inserted");
    Ok((StatusCode::CREATED, Json(herd)))
}

#[instrument(name = "handler.list_herds", skip(state))]
async fn list_herds(State(state): State<AppState>) -> Result<Json<Vec<Herd>>, HttpError> {
    let conn = state.pool.acquire().await.map_err(pool_error_http)?;
    // ... query exactly as before ...
    Ok(Json(herds))
}

#[instrument(name = "handler.get_herd", skip(state))]
async fn get_herd(
    State(state): State<AppState>,
    Path(id): Path<i64>,
) -> Result<Json<Herd>, HttpError> {
    let conn = state.pool.acquire().await.map_err(pool_error_http)?;
    // ... query exactly as before ...
    Ok(Json(herd))
}

The important idea is not “log everything.” It is “log the boundaries”:

  • startup: selected API address, Postgres host/database, and whether schema initialization finished
  • incoming requests: method, matched route, status code, and latency
  • handler-level facts: herd ids and herd names when they help explain what happened
  • database work: operation spans from babar plus safe identifiers from your own code

Avoid logging secrets like PGPASSWORD, and be careful about dumping full request bodies once they may contain private data.

What babar gives you for database visibility

Babar already emits tracing spans for its own database work, including db.connect, db.prepare, db.execute, and db.transaction. That means the request span from Axum can contain the lower-level database spans automatically. If POST /herds slows down, you can tell whether the time went into request routing, pool acquisition, or SQL execution instead of guessing.

See the traces locally

Run the service with an explicit log filter:

RUST_LOG=tower_http=info,herd_api=info,babar=info cargo run

Then create a herd from another shell:

curl -X POST http://127.0.0.1:3000/herds \
  -H 'content-type: application/json' \
  -d '{"name":"Royal Herd","grazing_ground":"Great Forest Meadow"}'

You should see output shaped roughly like this:

INFO starting herd-api api_addr=127.0.0.1:3000 pg_host=127.0.0.1 pg_database=postgres
INFO startup.initialize_schema: ensuring herds table exists
INFO schema ready
INFO listening on http://127.0.0.1:3000
INFO http.request{method=POST matched_path=/herds}: handler.create_herd: registering herd herd.name=Royal Herd herd.grazing_ground=Great Forest Meadow
INFO http.request{method=POST matched_path=/herds}: db.execute db.statement="INSERT INTO herds (name, grazing_ground) VALUES ($1, $2)"
INFO http.request{method=POST matched_path=/herds}: request finished status=201 latency_ms=4

The exact formatting depends on your subscriber, but the shape is the useful part: one request span, nested handler activity, and database spans beneath it.

Forward the same telemetry to Dial9 later

For local development, plain text logs to stdout are enough. In a deployed service, keep the same span names and fields, then add an exporter or collector layer that forwards them to your observability backend. If your team uses Dial9, think of it as the place those traces and logs land, not as something that changes how you instrument the herd registry itself.

A good production mental model is:

  1. emit structured tracing events in the service
  2. keep request, handler, and database spans correlated
  3. attach deployment metadata like service name, environment, and version
  4. ship that telemetry to Dial9 through your normal OpenTelemetry or structured log pipeline

That way the same instrumentation helps you both on cargo run and in a real deployment.

14. Where to go next

At this point you have a complete beginner-sized flow:

  • Axum receives HTTP input
  • extractors turn that input into Rust values
  • babar encodes typed parameters into SQL
  • babar decodes typed rows back into Rust values
  • handlers map those values into HTTP responses

When you are ready to harden it, the next practical steps are:

  • move startup schema creation into babar migrations
  • add validation rules for empty herd names or grazing grounds
  • add update and delete endpoints once create/list/get feel comfortable

Companion sources

  • crates/core/examples/quickstart.rs — the smallest typed database flow
  • crates/core/examples/todo_cli.rs — CRUD-shaped babar usage without HTTP
  • crates/core/examples/axum_service.rs — the closest full HTTP + Postgres example in the repository