Skip to content

Binds & Values

Every value you pass to the builder — to where_eq, insert, having, limit, anywhere — ends up as a bound parameter, never inlined into the SQL string. Compiling yields a (sql, binds) tuple: the SQL contains only placeholders ($N on Postgres, ? on MySQL/SQLite), and binds is a Vec<Value> handed to the driver separately. This page covers Value — the dialect-agnostic representation of a bound parameter — and IntoBind, the trait that converts ordinary Rust types into it.

rust
use chain_builder::{Postgres, QueryBuilder, Value};
let (sql, binds) = QueryBuilder::<Postgres>::table("users")
    .select(["id"])
    .where_eq("status", "active")
    .where_gte("age", 18i64)
    .to_sql();
// SELECT "id" FROM "users" WHERE "status" = $1 AND "age" >= $2
// binds == [Value::Text("active".into()), Value::I64(18)]

The string "active" never appears in sql — only in binds. That separation is the core SQL-injection guarantee (see the Security Model); when executing through sqlx, the same Vec<Value> is translated into the driver's argument buffer (see Executing with sqlx).

The Value enum

rust
#[non_exhaustive]
pub enum Value {
    Null,
    Bool(bool),
    I64(i64),     // the canonical integer binding type
    F64(f64),
    Text(String),
    Bytes(Vec<u8>),
    // feature-gated:
    Json(serde_json::Value),               // feature = "json"
    Uuid(uuid::Uuid),                      // feature = "uuid"
    DateTimeUtc(chrono::DateTime<Utc>),    // feature = "chrono"
    NaiveDateTime(chrono::NaiveDateTime),  // feature = "chrono"
    NaiveDate(chrono::NaiveDate),          // feature = "chrono"
    NaiveTime(chrono::NaiveTime),          // feature = "chrono"
    Decimal(rust_decimal::Decimal),        // feature = "decimal"
}

Value is #[non_exhaustive]: new variants can appear without a breaking change, so any match you write over it must include a wildcard arm (the same rule as BuildError).

IntoBind conversions

Everything that can be bound implements IntoBind. The complete table:

Rust typeValue variantNotes
i8, i16, i32, i64I64lossless
u8, u16, u32I64lossless
u64, usize, isizeI64wraps above i64::MAX — see below
f32, f64F64f32 widened
boolBool
&str, String, &StringText
Vec<u8>, &[u8]BytesBLOB
Option<T> where T: IntoBindNull / innerNoneNull, Some(v)v's conversion
Valueitselfidentity pass-through
serde_json::ValueJsonfeature json
uuid::UuidUuidfeature uuid
chrono::DateTime<Utc>DateTimeUtcfeature chrono
chrono::NaiveDateTimeNaiveDateTimefeature chrono
chrono::NaiveDateNaiveDatefeature chrono
chrono::NaiveTimeNaiveTimefeature chrono
rust_decimal::DecimalDecimalfeature decimal

Integers — and the u64 wrap

All integer types convert to Value::I64, the canonical integer binding type. For i8i64 and u8u32 this is lossless.

⚠️ u64 / usize / isize values above i64::MAX wrap silently.

The narrowing uses Rust's as i64 truncation semantics — silent two's-complement wrap, not an error and not saturation:

rust
use chain_builder::{IntoBind, Value};
assert_eq!(((i64::MAX as u64) + 1).into_bind(), Value::I64(i64::MIN));
assert_eq!(u64::MAX.into_bind(), Value::I64(-1));

This is intentional (the conversions exist for ergonomics), but it means a u64 row count or hash above 9_223_372_036_854_775_807 binds as a negative number. If your values can exceed i64::MAX, bind them explicitly — e.g. as Value::Text(v.to_string()) — instead of relying on the blanket impl.

Option<T> — nullable binds

None binds SQL NULL; Some(v) binds exactly what v alone would:

rust
use chain_builder::{IntoBind, Value};
assert_eq!(Option::<i64>::None.into_bind(), Value::Null);
assert_eq!(Some(5i64).into_bind(), Value::I64(5));

This composes with the feature-gated types too (Option::<uuid::Uuid>::None.into_bind() == Value::Null). One semantic trap: where_eq("col", None::<i64>) emits "col" = $1 with a NULL bind — and col = NULL is never true in SQL. For "IS NULL" tests use where_null; for "filter only when present" guard the call with when.

Value pass-through

Value implements IntoBind as the identity, so you can construct a variant explicitly whenever you need to override the blanket conversions (e.g. the u64 case above):

rust
assert_eq!(Value::I64(1).into_bind(), Value::I64(1));

Feature-gated types

Each optional integration adds Value variants and IntoBind impls. Enable them in Cargo.toml (features = ["json", "uuid", "chrono", "decimal"] — see Getting Started).

jsonserde_json::Value

Binds as Value::Json. On all three backends the JSON is serialized with to_string and stored as TEXT — it is not bound as a native jsonb parameter. (Intended for TEXT columns; Postgres has no implicit cast from a text parameter to json/jsonb, so inserting into a jsonb column needs an explicit ::jsonb cast in raw SQL. For jsonb querying see where_jsonb_contains.)

uuiduuid::Uuid

rust
let id = uuid::Uuid::nil();
let (sql, binds) = QueryBuilder::<Postgres>::table("t")
    .select(["id"])
    .where_eq("id", id)
    .to_sql();
// SELECT "id" FROM "t" WHERE "id" = $1
// binds == [Value::Uuid(id)]

Native uuid binding on Postgres; on MySQL/SQLite it binds through sqlx's Uuid encoding for those backends.

chrono — dates and times

Four variants, one per chrono type: DateTimeUtc (chrono::DateTime<chrono::Utc> — timezone-aware, UTC), NaiveDateTime, NaiveDate, NaiveTime:

rust
let nd = chrono::NaiveDate::from_ymd_opt(2026, 6, 9).unwrap();
let (sql, binds) = QueryBuilder::<Postgres>::table("t")
    .select(["id"])
    .where_eq("day", nd)
    .to_sql();
// SELECT "id" FROM "t" WHERE "day" = $1
// binds == [Value::NaiveDate(nd)]

decimalrust_decimal::Decimal

For money and exact-numeric columns:

rust
use rust_decimal::Decimal;
use std::str::FromStr;

let price = Decimal::from_str("19.99").unwrap();
let (sql, binds) = QueryBuilder::<Postgres>::table("products")
    .select(["id"])
    .where_eq("price", price)
    .to_sql();
// SELECT "id" FROM "products" WHERE "price" = $1
// binds == [Value::Decimal(price)]

Bound natively on Postgres (NUMERIC) and MySQL (DECIMAL).

⚠️ SQLite stores Decimal as TEXT — comparisons are lexicographic.

SQLite has no native decimal type, so the exact value is bound as its string form (d.to_string()). The round-trip is exact, but any SQL-side comparison or ORDER BY against a TEXT column compares strings, not numbers: "19.99" < "5" lexicographically. Use Decimal on SQLite for exact storage/round-trip only; for numeric range queries, store a scaled integer (cents) or compare with CAST(price AS REAL).

Inspecting a query: to_sql_pretty

For logs and debugging, to_sql_pretty() (3.1.0+) renders the SQL plus one line per bind. try_to_sql_pretty() is the fallible twin and surfaces the same BuildError as try_to_sql():

rust
let qb = QueryBuilder::<Postgres>::table("users")
    .select(["id"])
    .where_eq("status", "active")
    .where_gt("age", 21i64);
println!("{}", qb.to_sql_pretty());
// SELECT "id" FROM "users" WHERE "status" = $1 AND "age" > $2
// binds:
//   $1 = Text("active")
//   $2 = I64(21)

On ?-placeholder dialects (MySQL/SQLite) the bind labels carry a 1-based ordinal for readability (?1 = …); the SQL itself still uses bare ?. The output includes the literal bind values — don't log it if a bind may carry sensitive data. The output format is for humans — not a stability contract.

Where the binds go from here

to_sql() / try_to_sql() stop at (String, Vec<Value>) — useful for logging, testing, or driving a non-sqlx driver yourself. With a sqlx_* feature enabled, to_sqlx_query() and the fetch_* helpers translate each Value into the backend's argument buffer and execute it — the SQL string still never contains a value. See Executing with sqlx.