The Book of Babar
Typed Postgres for Rust, built directly on Tokio and the PostgreSQL wire protocol.

babar gives you a small set of Postgres-shaped building blocks:
Configdescribes how to connect.Sessionowns one connection and a background driver task.query!andcommand!define typed SQL from authored schema facts.Query::raw,Query::raw_with,Command::raw, andCommand::raw_withstay available when you need an explicit fallback.
cargo add babar
One typed-SQL path
The primary application story is:
- author schema facts with
schema! - build statements with schema-scoped
query!/command! - 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
babarexamples.
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
- Prerequisites → — start a local Postgres and make the examples observable.
- Your first query → — connect, create a table, insert a row, and read it back.
- Rust learning track → — take the optional guided route
if you want to learn Rust through
babar. - Selecting rows → — learn the standard query shape with schema-scoped wrappers and row structs.
- What makes babar babar → — see how the driver task, typed boundaries, and Postgres-specific design fit together.
- The typed-SQL macro pipeline → — follow
schema!,query!, andcommand!from authored schema facts to runtime values.
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:
- Read a babar program
- Syntax and control flow in context
- Types, structs, and
Result - Ownership and borrowing around queries
- 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:
- Error handling and service boundaries
- Traits, generics, and codecs
- Structs,
impl, and Rust-flavored OOP - Iterators, closures, and functional style
How the guided pages are framed
Each chapter in this section follows the same pattern:
- babar anchor — start from a real
babarexample, docs page, or code path. - Rust-first explanation — explain the Rust concept directly in the context of that anchor.
- Python comparison (optional) — include a clearly labeled comparison only when it closes a real gap for Python-fluent readers.
- 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:
- Your first query
- 1. Connecting
- 2. Selecting
crates/core/examples/quickstart.rs
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:
- Your first query
crates/core/examples/quickstart.rs
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 aUservalue; no rows come back.”Query<(), User>means “run SQL with no input parameters; each row decodes intoUser.”
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:
- Find the structs and tuple types.
- Find the
Query<_, _>andCommand<_>values. - Find the
.awaitcalls. - 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!, andcommand! - 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
babarexample, 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?
Read next
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:
crates/core/examples/quickstart.rs- 1. Connecting
- 2. Selecting
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:
letcreates 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 theSession - the
Err(e)branch logs and returns early frommain
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 returningTasync fn name(...) -> T— a function whose body contains async workType::name(...)— an associated function or constructor-style callvalue.method(...)— a method call on a valueEnum::Variant(...)orOk(...)/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
letbinding usually the default in thesebabarexamples? - What information is carried by a chained builder call that would often be hidden in Python keyword arguments or dynamic configuration objects?
Read next
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:
- Your first query
- 2. Selecting
crates/core/examples/quickstart.rs
Query<A, B> and Command<A> describe the boundary
The most important babar types are worth reading literally:
Command<A>— send anAinto SQL; no result rows come backQuery<A, B>— send anAinto SQL; each returned row decodes intoB
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
ActiveUsersvalue - 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:
mainis async- if it succeeds, it returns
() - if it fails, it returns a
babar::Errorthrough the aliasbabar::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:
- wait for the query result
- if it is an error, stop this function and return that error upward
- 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 shapeUserRow— named struct used as a row shapeOption<String>— a field that may be absentQuery<ActiveUsers, UserSummary>— typed read statementbabar::Result<()>— fallible return type with no success payload beyond “it worked”
Reflection prompts
- In the current
babarexamples, where would a named struct help more than a tuple, and why? - What does
Option<String>communicate about a database column that plainStringdoes not? - When you see
babar::Result<()>, what work completed successfully if the function returnsOk(())?
Read next
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:
crates/core/examples/quickstart.rs- Your first query
- 1. Connecting
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
&Sessionso 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:
sessionis&Session, so the function uses the handle without owning it&insertand&selectborrow 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,), orrow.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
&Sessionreferences 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:
- Is this call borrowing the value or taking ownership of it?
- If the call needs ownership, am I done using the original value?
- 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
babarexample, 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
.awaitboundary?
Read next
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:
- one
Sessionconnecting to Postgres - one background task owning that connection
- one service using a
Poolso 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:
Sessionis 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:
- package a request
- send it to the driver task
- 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:
- the handler is async because both HTTP work and database work may wait
pool.acquire().awaitmay pause until a connection is availableconn.execute(...).awaitmay pause until Postgres finishes the command- 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
Sessionwhen you want one connection and want to understand the driver-task model directly. - Use
Poolwhen 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:
- 1. Connecting teaches one connection
- The background driver task explains why that connection is modelled as a handle plus task
- 11. Building a web service shows why real services usually step up to a pool
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
Poolinstead of sharing oneSession?
Read next
- Error handling and service boundaries
- 1. Connecting
- 11. Building a web service
- The background driver task
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:
- propagate an error with
? - inspect an error with
match - 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 withbabar::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:
- what outside work are we waiting for?
- 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(_)andError::Closed { .. }for transport-level troubleError::Auth(_)andError::UnsupportedAuth(_)for authentication failuresError::Server { code, .. }for Postgres server errorsError::Config(_)for setup mistakes caught before I/OError::Codec(_),Error::SchemaMismatch { .. }, andError::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:
- low-level messages may leak implementation detail
- 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
Pool errors and database errors are related, but not identical
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:
PoolErroranswers “could the application obtain a usable connection?”babar::Erroranswers “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
babarcall ends withawait?, 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?
Read next
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:
ActiveUsersis the parameter shape the query acceptsUserSummaryis the row shape the query producessession.queryturns 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:
UuidCodecknows how to encode aUuidinto Postgres parametersUuidCodecknows how to decode aUuidfrom 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:
- Derive
babar::Codecfor normal row-shaped or parameter-shaped structs - Use tuples for tiny positional shapes when names would add noise
- Write
Encoder/Decoderimpls manually only when you need a type thatbabardoes not already know how to map
That is the progression you see across the docs:
2. Selectinguses derived structs for application rows10. Custom codecsshows 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:
- In
Query<Params, Row>,Paramsdescribes the bound input shape andRowdescribes the decoded output shape. #[derive(babar::Codec)]makes a normal struct usable at the SQL boundary.Encoder<A>andDecoder<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>orQuery<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?
Read next
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
implblocks 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:
AppStatestores long-lived shared application stateSettingsstores configuration dataimpl Settingsdefines behavior that is specifically aboutSettings
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
CreateWidgetandWidgetbecause they define the shape of JSON at the HTTP boundary Settingsbecause 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
Settingsto 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
AppStatealone - 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:
AppStatecontains aPool- 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
implblocks - 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
structis often just a named data shape - an
implblock 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:
AppStateis a struct because the pool needs to move through the router as one named unit.impl Settingsexists because configuration-loading behavior belongs to theSettingstype.create_widgetandget_widgetstay 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?
Read next
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:
into_iter()consumes the vector of rowsmap(...)transforms each rowcollect()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
rowsinto 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:
- fetch rows
- transform rows
- 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:
- Is this pipeline borrowing items or consuming them?
- Is this pipeline clearer than the equivalent
forloop 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()vsiter()makes ownership visiblecollect()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:
rows.into_iter().map(...).collect()means consume rows, transform them, and build a new collection.rows.into_iter().next()means consume the vector and take the first item if one exists.for row in &rowsmeans borrow the collection for inspection or stepwise work without consuming it.
Reflection prompts
- In the
list_widgetspipeline, what value gets moved, and what new value gets built? - Why is a closure-based
mapa 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?
Read next
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-Cto stop. No daemon, no cleanup chores later.-p 5432:5432— Postgres’ default port, exposed onlocalhost.-e POSTGRES_PASSWORD=postgres— sets the password for the defaultpostgressuperuser. Thepostgres:17image 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 → — connect, query, decode.
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>:
Ais the Rust value you bind when the statement runsBis the per-row Rust value returned by a query
In the example above:
Command<User>inserts aUserQuery<(), User>returnsVec<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
- Chapter 2: Selecting walks through reading rows back into typed Rust values.
- For the optional Rust-learning companion, see Async/await and the driver task mental model.
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 valueB— 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_namemust 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 queriesQuery::raw_with(sql, encoder, decoder)for parameterized raw queriesCommand::raw(sql)andCommand::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, andDELETE ... WHERE RETURNINGremains 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 commandCommand::raw_with(sql, encoder)— parameterized raw commandQuery::raw(sql, decoder)— zero-parameter raw queryQuery::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 RustAinto parameter bytes.Decoder<B>turns one row into a RustB.
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:
| Pattern | Use 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_raw | Simple-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 Session —
execute, 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
| Field | What it controls |
|---|---|
min_idle | Minimum number of warm connections kept open. |
max_size | Hard ceiling on simultaneous connections (idle + in-use). |
acquire_timeout | How long pool.acquire() waits before returning PoolError::Timeout. |
idle_timeout | How long an idle connection lingers before being closed. |
max_lifetime | How long any connection (idle or in-use) lives before being recycled. |
health_check | Test 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_ais not visible fromconn_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_lifetimeor a failed health check), all of that connection’s prepared statements go with it. The nextprepare_*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(©, 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(©, 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. UseBINARYfor now. COPY FROM PROGRAMandCOPY ... 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:
| Variant | When 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:
| SQLSTATE | Class | Meaning |
|---|---|---|
23505 | unique_violation | Duplicate key. |
23503 | foreign_key_violation | Missing FK target. |
23502 | not_null_violation | NULL into a NOT NULL column. |
40001 | serialization_failure | Serializable transaction must retry. |
40P01 | deadlock_detected | Deadlock; retry the whole transaction. |
57014 | query_canceled | Statement timeout fired. |
57P01 | admin_shutdown | Server 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
- Chapter 10: Custom codecs shows how to write your own
Encoder<A>/Decoder<A>for types babar doesn’t know about out of the box. - For the optional Rust-learning companion, see Error handling and service boundaries.
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 exactlyoids().len()entries ontoparams.Some(bytes)for a value,Nonefor SQLNULL.oids()— the Postgres OIDs of the parameter slots, in order.format_codes()—0for text format,1for binary; defaults to text. Use binary for everything you can.types()— richer type metadata; default implementation derives this fromoids().
The Decoder<A> methods (format_codes and types again have
defaults you can usually skip):
decode(&self, columns)— consume the firstn_columns()entries ofcolumnsand produce anA.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()saysint4(23) but the column isint8(20), the driver returnsError::SchemaMismatchwith both OIDs. Look them up withSELECT 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/OUTfunctions and locale settings. - Handle NULL explicitly. A NULL column arrives as
Noneincolumns. If your type can’t be NULL, decode it directly. If it can, expose anullable(...)wrapper or useOption<A>from your caller. encodeerrors are user errors, not panics. ReturnErr(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:
- pulls a connection from the pool with
pool.acquire() - uses schema-aware
query!/command!for application SQL - reserves
Command::rawfor unsupported setup SQL such as the DDL ininitialize - maps
babar::Errorandbabar::PoolErrorto 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::rawfor unsupported single statements that should still use the extended protocol and typed params/rows - use
simple_query_rawonly 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 name | Fields |
|---|---|
db.connect | db.system, db.user, db.name, net.peer.name, net.peer.port |
db.prepare | db.system, db.statement, db.operation |
db.execute | db.system, db.statement, db.operation |
db.transaction | db.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:
TlsMode | What babar does |
|---|---|
Disable | Never attempt TLS. Plain TCP. |
Prefer | Ask for TLS; if the server refuses, fall back to plain TCP. |
Require | Demand 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 advertisesSCRAM-SHA-256-PLUSover 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’spg_hba.confselected an auth method babar doesn’t speak. Switch the role toscram-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 anAuthfailure.
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:
| Span | Where it fires | Useful fields |
|---|---|---|
db.connect | Session::connect | db.system, db.user, db.name, net.peer.name, net.peer.port |
db.prepare | prepare_command / prepare_query | db.statement, db.operation |
db.execute | session.execute, command.execute | db.statement, db.operation |
db.transaction | session.transaction, tx.savepoint | db.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
| Subscriber | When to reach for it |
|---|---|
tracing_subscriber::fmt | Local development, structured logs to stdout. |
tracing-bunyan-formatter | JSON logs your aggregator already understands. |
tracing-opentelemetry + an OTLP exporter | Distributed 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().awaityourself and feed it intometrics::histogram!(or whichever crate you use). - Query latency: derive from the
db.executespan duration viatracing-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 periodicQueryand 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.executep99 spiked at 14:32?” — span histograms from your tracing backend. - “Was that an in-flight query or a connect-time stall?” —
db.connectvsdb.preparevsdb.executespan breakdown. - “Which service held that connection open?” — the
application_nameyou set, surfaced bypg_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 type | OID | Rust type | Codec value | Module |
|---|---|---|---|---|
int2 / smallint | 21 | i16 | int2 | primitive |
int4 / integer | 23 | i32 | int4 | primitive |
int8 / bigint | 20 | i64 | int8 | primitive |
float4 / real | 700 | f32 | float4 | primitive |
float8 / double precision | 701 | f64 | float8 | primitive |
bool | 16 | bool | bool | primitive |
text | 25 | String | text | primitive |
varchar | 1043 | String | varchar | primitive |
bpchar / char(n) | 1042 | String | bpchar | primitive |
bytea | 17 | Vec<u8> | bytea | primitive |
| any (NULL-aware wrapper) | n/a | Option<T> | nullable(C) | nullable |
T[] | array OID | Vec<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 type | OID | Rust type | Codec value | Module | Feature |
|---|---|---|---|---|---|
uuid | 2950 | uuid::Uuid | uuid | uuid | uuid |
date | 1082 | time::Date | date | time | time |
time | 1083 | time::Time | time | time | time |
timestamp | 1114 | time::PrimitiveDateTime | timestamp | time | time |
timestamptz | 1184 | time::OffsetDateTime | timestamptz | time | time |
date | 1082 | chrono::NaiveDate | chrono_date | chrono | chrono |
time | 1083 | chrono::NaiveTime | chrono_time | chrono | chrono |
timestamp | 1114 | chrono::NaiveDateTime | chrono_timestamp | chrono | chrono |
timestamptz | 1184 | chrono::DateTime<Utc> | chrono_timestamptz | chrono | chrono |
interval | 1186 | babar::codec::Interval | interval | interval | interval |
numeric | 1700 | rust_decimal::Decimal | numeric | numeric | numeric |
json | 114 | serde_json::Value / T: Deserialize | json / typed_json::<T>() | json | json |
jsonb | 3802 | serde_json::Value / T: Deserialize | jsonb / typed_json::<T>() | json | json |
inet | 869 | std::net::IpAddr | inet | net | net |
cidr | 650 | babar::codec::Cidr | cidr | net | net |
macaddr | 829 | babar::codec::MacAddr | macaddr | macaddr | macaddr |
macaddr8 | 774 | babar::codec::MacAddr8 | macaddr8 | macaddr | macaddr |
bit(n) | 1560 | babar::codec::BitString | bit | bits | bits |
varbit | 1562 | babar::codec::BitString | varbit | bits | bits |
hstore | server-assigned | babar::codec::Hstore | hstore | hstore | hstore |
citext | server-assigned | String | citext | citext | citext |
tsvector | 3614 | babar::codec::TsVector | tsvector | text_search | text-search |
tsquery | 3615 | babar::codec::TsQuery | tsquery | text_search | text-search |
vector | server-assigned | babar::codec::Vector | vector | pgvector | pgvector |
geometry (PostGIS) | server-assigned | T: geo_types::* | geometry::<T>() | postgis | postgis |
geography (PostGIS) | server-assigned | T: geo_types::* | geography::<T>() | postgis | postgis |
range<T> | range OID | babar::codec::Range<T> | range(C) | range | range |
multirange<T> | mr OID | babar::codec::Multirange<T> | multirange(C) | multirange | multirange (implies range) |
Composing codecs
Most type-system muscle lives in combinators, not new codec modules:
| Combinator | What 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.
| Variant | Shape | When it fires |
|---|---|---|
Io | Io(std::io::Error) | TCP, TLS, or socket I/O failure (DNS, refused, reset, EOF). |
Closed | Closed { sql: Option<String>, origin: Option<Origin> } | The session was closed and the call lost its connection. sql and origin carry the in-flight statement. |
Protocol | Protocol(String) | The server sent something babar cannot interpret as a valid protocol exchange. |
Auth | Auth(String) | SCRAM rejected, password wrong, role cannot log in, or no password was configured. |
UnsupportedAuth | UnsupportedAuth(String) | The server selected an authentication method babar does not implement. |
Server | Server { code, severity, message, detail, hint, position, sql, origin } | A PostgreSQL ErrorResponse. code is the five-character SQLSTATE. |
Config | Config(String) | Client-side configuration is invalid. |
Codec | Codec(String) | An encoder or decoder rejected the bytes or the row shape. |
ColumnAlignment | ColumnAlignment { expected, actual, sql, origin } | The decoder expected expected columns but RowDescription advertised actual. |
SchemaMismatch | SchemaMismatch { position, expected_oid, actual_oid, column_name, sql, origin } | The decoder’s declared OID at position differs from the OID PostgreSQL returned. |
Migration | Migration(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
| SQLSTATE | Class | Common cause | Typical reaction |
|---|---|---|---|
23505 | unique_violation | Duplicate key on insert/upsert. | Map to a 409 in your service; consider INSERT ... ON CONFLICT. |
23503 | foreign_key_violation | Inserting a row whose parent does not exist. | 422 / validation error. |
23502 | not_null_violation | Missing required column. | 422 / validation error. |
23514 | check_violation | A CHECK constraint rejected the row. | 422 / validation error. |
40001 | serialization_failure | Conflicting concurrent transactions at SERIALIZABLE. | Retry with backoff. |
40P01 | deadlock_detected | The deadlock detector aborted your transaction. | Retry; investigate the lock order. |
Authentication and resource
| SQLSTATE | Class | Common cause |
|---|---|---|
28P01 | invalid_password | Wrong password. |
28000 | invalid_authorization_specification | Role cannot log in or pg_hba.conf rejected the connection. |
53300 | too_many_connections | Server max_connections reached. |
57P03 | cannot_connect_now | Server is starting up or recovering. |
Schema
| SQLSTATE | Class | Common cause |
|---|---|---|
42P01 | undefined_table | Missing table, often a missing migration. |
42703 | undefined_column | Missing column or schema drift. |
42P07 | duplicate_table | Setup attempted to create an existing table. |
Choosing what to retry
A practical starting policy:
| Variant / code | Retry? |
|---|---|
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 / SchemaMismatch | No. Fix the code or schema expectations. |
Other Error::Server | No 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
| Feature | What it enables | Default? |
|---|---|---|
rustls | The pure-Rust TLS backend (TlsBackend::Rustls). Pulls in rustls, tokio-rustls, and rustls-native-certs. | yes |
native-tls | Platform 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.
| Feature | Codec module | Headline types | Extra deps |
|---|---|---|---|
uuid | babar::codec::uuid | uuid::Uuid ↔ Postgres uuid | uuid |
time | babar::codec::time | time::Date / Time / PrimitiveDateTime / OffsetDateTime | time |
chrono | babar::codec::chrono | chrono::NaiveDate / NaiveTime / NaiveDateTime / DateTime<Utc> | chrono |
numeric | babar::codec::numeric | rust_decimal::Decimal ↔ Postgres numeric | rust_decimal |
json | babar::codec::json | serde_json::Value and typed_json::<T>() for Serialize + Deserialize | serde, serde_json |
array | babar::codec::array | array(C) combinator for one-dimensional arrays | fallible-iterator |
range | babar::codec::range | range(C) combinator over discrete and continuous ranges | — |
multirange | babar::codec::multirange | multirange(C) (Postgres 14+); implies range | — |
interval | babar::codec::interval | babar::codec::Interval | — |
net | babar::codec::net | inet, cidr (IpAddr, Cidr) | — |
macaddr | babar::codec::macaddr | MacAddr, MacAddr8 | — |
bits | babar::codec::bits | BitString for bit / varbit | — |
hstore | babar::codec::hstore | Hstore (BTreeMap<String, Option<String>>) | — |
citext | babar::codec::citext | String ↔ citext extension type | — |
text-search | babar::codec::text_search | TsVector, TsQuery | — |
pgvector | babar::codec::pgvector | Vector for the pgvector extension | — |
postgis | babar::codec::postgis | geometry::<T>() / geography::<T>() over geo-types | geo-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
| Method | Required 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)
| Method | Type | Default | Notes |
|---|---|---|---|
.password(p) | impl Into<String> | none | Sent to the server only as part of the auth handshake. |
.application_name(n) | impl Into<String> | none | Surfaces in pg_stat_activity.application_name. Cheapest observability win. |
.connect_timeout(d) | Duration | none | Wall-clock cap on Session::connect. |
.tls_mode(m) | TlsMode | Disable | Disable / Prefer / Require. Opt in to Prefer or Require explicitly. See ch12. |
.require_tls() | — | — | Sugar for .tls_mode(TlsMode::Require). |
.tls_backend(b) | TlsBackend | Rustls (with rustls feature) | Rustls or NativeTls. |
.tls_server_name(n) | impl Into<String> | host | Override SNI / certificate-name match. |
.tls_root_cert_path(p) | impl Into<PathBuf> | system roots / webpki-roots | PEM bundle of additional root CAs. |
TLS-mode and backend enums
| Enum | Variants | Re-exported as |
|---|---|---|
TlsMode | Disable, Prefer, Require | babar::config::TlsMode |
TlsBackend | Rustls, NativeTls | babar::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
| Method | Type | Default | Notes |
|---|---|---|---|
.min_idle(n) | usize | 0 | Keep at least n warm connections when traffic permits. |
.max_size(n) | usize | 16 | Hard cap on total connections in the pool. |
.acquire_timeout(d) | Duration | 30 seconds | How long pool.acquire() waits before returning PoolError::Timeout. |
.idle_timeout(d) | Duration | unset (no idle timeout) | Close idle connections older than this. |
.max_lifetime(d) | Duration | unset (no lifetime cap) | Recycle connections after this age regardless of idle state. |
.health_check(h) | HealthCheck | HealthCheck::None | Per-acquire validation policy (off by default). |
PoolError
| Variant | When |
|---|---|
PoolError::Timeout | acquire_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::PoolClosed | The pool itself has been closed. |
Picking values
Some tested starting points:
| Service shape | max_size | acquire_timeout | min_idle |
|---|---|---|---|
| HTTP service, low/medium traffic | 8–16 | 5–10s | 0 |
| HTTP service, high traffic | ≈ #worker threads × 2 | 1–3s | ≥ 2 |
| Long-running batch / ETL | 1–4 | 30s+ | 0 |
Beyond that, watch:
pg_stat_activityfor connection count vs server’smax_connections.- Pool acquire latency (you wrap it yourself; see Chapter 13).
- p99 query latency vs pool size — if increasing
max_sizedoesn’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
Sessionis 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_withQuery::raw/Query::raw_withsql!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
RowDescriptionso 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
SELECTstatements can be checked against a live database whenBABAR_DATABASE_URLorDATABASE_URLis 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.
Where to read next
- Why babar — a shorter statement of intent.
- Design principles — the API rules behind these choices.
- The background driver task — runtime mechanics and shutdown.
- The typed-SQL macro pipeline — how the typed SQL surface lowers into runtime statement values.
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.
Where to read next
- Design principles — typed boundaries, validation, and runtime model.
- The driver task — how the background task keeps the socket consistent.
- Comparisons — trade-offs against other Rust Postgres clients.
- The typed-SQL macro pipeline — how the public typed-SQL surface is assembled.
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 statementsCommand<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
RowDescriptionat prepare time. - Schema-aware
query!can optionally verify supportedSELECTstatements 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.
Where to read next
- The driver task for the cancellation-safety runtime story.
- The typed-SQL macro pipeline for the macro architecture.
- Comparisons for trade-offs against other Rust Postgres clients.
- Book Chapter 9 — Error handling for how validate-early decisions show up at runtime.
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:
SELECTprojections and predicates for readsINSERT ... VALUES,UPDATE ... WHERE, andDELETE ... WHEREfor 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 factsquery!andcommand!consume those facts- the result is a
Query<P, R>orCommand<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:
- authored schema facts are a first-class input
- typed SQL lowers into ordinary runtime values instead of a separate execution system
- 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.
| Dimension | babar | tokio-postgres | sqlx | diesel |
|---|---|---|---|---|
| Primary shape | Typed Postgres client | Async Postgres driver | Async SQL toolkit | ORM / query DSL |
| Database scope | Postgres only | Postgres only | Multiple databases | Multiple databases |
| Query API | Typed runtime Query<P, R> / Command<P> values | Raw SQL strings plus codec traits | Raw SQL, macros, row mapping helpers | Schema-aware DSL and derives |
| SQL checking style | Optional online verification plus prepare-time validation | Mostly runtime | Strong compile-time emphasis | Schema-driven compile-time DSL |
| Explicit codec model | Yes, codecs are imported values | Usually trait-based (ToSql / FromSql) | Mostly inferred / mapped through traits and macros | Mostly hidden behind derives / schema mapping |
| Current maturity | Newer, intentionally focused surface | Most battle-tested async Postgres option | Large ecosystem and polished tooling | Mature ORM ecosystem |
| Strong fit | Postgres-specific apps that want explicit typed values and protocol visibility | Teams that want established async Postgres coverage today | Teams that want compile-time SQL workflows or multi-database support | Teams 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
babarwhen 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-postgreswhen you want the most established async Postgres driver in Rust today, broader production history, or a feature babar still defers such as broaderCOPY,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
babarwhen you want Postgres-specific APIs, explicit runtime codecs, and normal builds that do not depend on compile-time database connectivity. - Choose
sqlxwhen 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
babarwhen you want SQL to stay SQL and prefer the protocol seam — codecs, prepare, COPY, transactions, pooling — to be the visible API. - Choose
dieselwhen 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 thing | babar |
| The most battle-tested async Postgres driver in Rust | tokio-postgres |
| Compile-time-verified SQL, multi-database support | sqlx |
| A schema-aware ORM with a strong DSL | diesel |
Where to read next
- Roadmap — what’s deferred (and therefore what
tokio-postgrescovers 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:
- Opens the TCP connection and runs the startup + auth handshake.
- Spawns a background task (
tokio::spawn) and gives it the read half and write half of the now-authenticated stream. - Hands you back a
Sessionvalue that holds anmpsc::Sender<Command>— the channel into the driver task — plus a small amount of cached server state (parameters, backend keys).
Every public call on Session — query, 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
TcpStreamhalves and anoneshotper 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.
Where to read next
- Async/await and the driver task mental model — the optional Rust-learning companion.
- Book Chapter 6 — Pooling — for the layer above the driver task.
- Book Chapter 13 — Observability — for the spans the driver task emits.
- Design principles — for why this fits the rest of babar’s shape.
Roadmap
See also:
MILESTONES.mdin 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>, theEncoder/Decodertraits, 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 STDINfor 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) ornative-tls. tracingspans 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/NOTIFYis 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,
babaris focused on enabling writing SQL
Where to read next
- 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::Poolstored in application state - startup code that creates a
herdstable if it does not exist yet - a
GET /healthzendpoint so you can prove the service is alive - a
POST /herdsendpoint to register a herd - a
GET /herdsendpoint to list herds - a
GET /herds/:idendpoint to fetch one herd
We will build that in two stages:
- get the runtime, router, and database bootstrap in place
- 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:
psqlso you can inspect the database manually- the companion examples in this repository:
crates/core/examples/quickstart.rscrates/core/examples/todo_cli.rscrates/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 fndoes 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:
mainstays 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:
- borrow the pool from
AppState acquire()a connection- run a typed
CommandorQuery - 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(¤t_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 /healthzcallshealthzGET /herdscallslist_herdsPOST /herdscallscreate_herdGET /herds/:idcallsget_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:
- Axum matches the route
- Axum extracts the inputs for that route
- the handler runs a typed database operation
- 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
textcodec
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:
- acquires a pooled connection
- executes the typed insert command
- queries the generated id
- queries the inserted row
- returns
201 CreatedplusJson<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 OKwith 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:
- emit structured
tracingevents in the service - keep request, handler, and database spans correlated
- attach deployment metadata like service name, environment, and version
- 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 flowcrates/core/examples/todo_cli.rs— CRUD-shaped babar usage without HTTPcrates/core/examples/axum_service.rs— the closest full HTTP + Postgres example in the repository