Compile-time-safe SQL for Rust β PostgreSQL and SQLite, async and sync.
Write real SQL. It is checked at cargo build against the schema your migration
files define β table names, column names, types, nullability. If it compiles,
the query is correct. No DSL, no method chains, no runtime "column not found".
1.0.0-alpha.3 β stable in shape, early in life; expect a few more alpha iterations before a full 1.0. On crates.io as a pre-release: pin
bsql = "1.0.0-alpha.3"(a barecargo add bsqlresolves the older stable0.27β a different, unrelated library). Built with Claude Code (design first, review second, implementation third).
// migrations/0001_init.sql β CREATE TABLE users (id int PRIMARY KEY, email text NOT NULL);
// Typed at build time against the migration above.
// `SELECT nope FROM users` would be a compile error, not a runtime surprise.
bsql::query!(UserById, "SELECT id, email FROM users WHERE id = $1");
let user = conn.query_one::<UserById>((42_i32,)).await?;
// user.id: i32 user.email: String (a nullable column would be Option<T>)- If it compiles, the SQL is correct. Every query is validated at build time
against the schema your migration
*.sqlfiles describe β names, types, and nullability. Wrong column, wrong type, typo β the build fails. - One query function, and it always checks β no accidental unchecked path.
In sqlx a missing
!(query()vsquery!()) silently skips validation. bsql has a raw-SQL escape hatch too, but it is deliberate and named apart: the unchecked verbs carry a distinct suffix (query_rawfor raw text,query_paramsfor raw text plus runtime parameters), so you opt into unchecked SQL on purpose β never by forgetting a!. - Pure SQL text β no builder. CTEs, JOINs, window functions, subqueries β no
.filter().eq()method chains to learn. If PostgreSQL or SQLite supports it, you just write it. - Async and sync are both first-class. Both drivers plug the same socket into
ONE transport-generic core, so parity is a compiler guarantee, not
hand-maintained twins. The sync driver drops tokio entirely β pure
fn, no async runtime. Switch asyncβsync by swapping one feature line (and dropping.await). - PostgreSQL and SQLite, same macro. The same
query!carrier runs on both for typed reads, decoding into the same records β and SQLite verifies each value's storage class at runtime (a mismatch is a classified error, never a silent coercion). Typed writes throughquery!(execute/execute_batch/query_batch) are a PostgreSQL capability; on SQLite the typed flagship is read-only, and dynamic writes (execute_raw/execute_params) work on both. - Tiny footprint. ~1.7β1.8 MB peak memory for a real PostgreSQL workload β the leanest client measured there (and a near-tie with raw C on SQLite), ~8Γ under a C/libpq client, ~10Γ under Go/pgx, and ~3.6Γ under the nearest Rust client β and the whole TLS/SCRAM stack is feature-gated, so a localhost / trust-auth build is a handful of crates.
#![forbid(unsafe_code)]on every shipped crate. Nounwrap/expectin production code. NULL isOption<NonZeroU32>, not a sentinel. The hot decode path is proven panic-free and byte-stable by a machine-checked codegen gate.- Things nobody else does β automatic N+1 detection, a build-time
destructive-migration gate, Rust types generated from your migrations, typed
binary
COPY, and sub-millisecond schema-per-test isolation. See What makes it different.
You need to see this π«’ β seven clients across four languages (bsql, C/libpq, Go/pgx, tokio-postgres, sqlx, diesel), PostgreSQL over loopback TCP, full methodology and captured logs so you don't have to trust the table.
The short version, measured on an Apple-silicon laptop over the same PostgreSQL:
the blocking driver bsql::pg_sync is on par with C/libpq β a few percent
ahead on reads, a few percent behind on INSERT, i.e. effectively a wash on the same
machine (the true apples-to-apples comparison, both synchronous). The async
driver is on par with C on point reads (within the ~1 Β΅s an async runtime spends
parking a would-block read, which a blocking client does not) and faster on larger
results β and it is the fastest async driver here: clearly ahead of
tokio-postgres (~1.5Γ on point reads) and of diesel / Go/pgx, with sqlx close behind
(within ~6% on point reads, further back as results grow). On memory bsql is the
leanest in the PostgreSQL field β ~1.7β1.8 MB peak, ~3.6Γ under the nearest Rust
client, ~8Γ under a C/libpq client, ~10Γ under Go/pgx. Don't take our word for it β
git switch bench && cargo bench.
PostgreSQL
# Cargo.toml (alpha is a pre-release, so pin the exact version)
[dependencies]
bsql = { version = "1.0.0-alpha.3", features = ["macros", "postgres-async"] }
[build-dependencies]
bsql-build = "1.0.0-alpha.3"// build.rs β replays migrations/ into the schema catalog query! reads.
fn main() -> Result<(), bsql_build::BuildError> {
bsql_build::emit("migrations")
}use bsql::pg;
bsql::query!(UserById, "SELECT id, email FROM users WHERE id = $1");
#[tokio::main]
async fn main() -> Result<(), bsql::pg::DriverError> {
let cfg = pg::ConnectConfig::from_dsn("postgres://user@localhost/mydb")?;
let mut conn = pg::Connection::connect(cfg).await?;
let user = conn.query_one::<UserById>((42_i32,)).await?;
println!("{} <{}>", user.id, user.email);
Ok(())
}Blocking driver? Swap the feature to postgres-sync, use bsql::pg_sync, and
drop the async/.await β the query! carriers and record types are identical.
SQLite
Enable features = ["macros", "sqlite", "macros-sqlite"] (the last one is the
build-time conformance oracle β it re-checks each query! against a real SQLite
replay of your migrations). The same carrier runs on bsql::sqlite::Connection
with the same query / query_one / query_opt / query_each verbs and the
same typed records. A PostgreSQL-only query (a uuid column, say) simply does
not implement the SQLite trait, so running it on SQLite is a compile error.
Runnable examples. The examples/ directory is a heavily-commented
tour that doubles as a usage guide β one focused program per feature (basic CRUD,
cross-backend, params vs raw, joins/aggregates, migrations, generated types,
external-type bridges, N+1 detection, typed COPY, pipelining/batch, streaming,
transactions, pooling, schema-per-test). The SQLite ones run with zero setup:
cargo run -p bsql-examples --bin basic_sqlite.
N+1 detection, for free (feature n1-detect)
The bug ORMs quietly encourage β one query per row of a previous result. bsql catches it for you:
let authors = conn.query::<AllAuthors>(()).await?;
for a in authors.iter() {
// one query per author, from the same source line β the anti-pattern
let books = conn.query::<BooksByAuthor>((a.id,)).await?;
}
if let Some(n1) = conn.n1_report() {
// N1Report { sql: "SELECT β¦ FROM books WHERE author_id = $1",
// file: "src/catalog.rs", line: 42, count: 250 }
eprintln!("N+1 at {}:{} ran {}Γ", n1.file, n1.line, n1.count);
}The same query! run 25+ times from the same call site within one logical
operation is flagged and attributed to the exact source line (via
#[track_caller]), with the SQL and repeat count. Diagnostics-only β it never
batches, blocks, or alters a result, so a false positive is at most a spurious log.
Zero-cost when off (the default): a production build compiles no detector field,
no branch, no #[track_caller] cost β the query verbs stay byte-identical. One
shared detector across PostgreSQL and SQLite.
A migration that destroys data won't compile silently
A DROP TABLE, ALTER TABLE β¦ DROP COLUMN, DROP SCHEMA β¦ CASCADE, TRUNCATE, or
DROP DATABASE in a migration is a build error β refused unless you acknowledge
the data loss on the line above it:
-- migrations/0007_drop_legacy.sql
-- bsql:ack-destructive
DROP TABLE legacy_sessions; -- compiles ONLY because of the ack line aboveWithout the -- bsql:ack-destructive line, cargo build fails and names the
offending statement. So a DROP that slipped in during a rebase can't reach
production silently β you either meant it (and said so) or you find out at build
time. The check runs on the same migration AST the schema catalog is built from, and
covers both directory migrations and a set baked into the binary with
embed_migrations!().
Rust types generated from your migrations
Declare a type in SQL, get a Rust type for it β no derive, no hand-written name, no drift:
-- migrations/0003_types.sql
CREATE TYPE mood AS ENUM ('happy', 'sad');bsql::user_types!(); // generates: pub enum Mood { Happy, Sad }
bsql::query!(GetMood, "SELECT mood FROM users WHERE id = $1");
let m: Mood = conn.query_one::<GetMood>((1_i32,)).await?.mood;Rename or delete a variant in a later migration and every use of the old name
stops compiling β drift is a build error, by construction. Enum evolution
(ALTER TYPE β¦ ADD VALUE / RENAME VALUE) is replayed so the generated enum always
matches the files, and variant order mirrors PostgreSQL's, so the derived Ord
matches the server's sort. Composites (CREATE TYPE addr AS (...)) generate a
struct; domains are transparent to their base type. No other Rust SQL library does
this β only bsql parses your migration set at build time.
Safe-by-construction bulk load β typed binary COPY
The raw text COPY path is the classic footgun: you hand-format the data, and one
mis-escaped tab or newline silently corrupts a row. copy! removes the text
entirely:
copy!(LoadUsers, "users", (id, email)); // validated against the catalog
conn.copy_in_typed::<LoadUsers>(
people.iter().map(|p| (p.id, p.email.as_str())) // one typed tuple per row
).await?;copy! checks the target table, columns and their types against the same catalog
query! reads; copy_in_typed then streams each row as a binary COPY in
constant memory. A wrong column type or arity is a compile error; an embedded
tab / newline / quote rides the binary field verbatim (nothing to mis-escape); and
it is faster than the text path on both client and server. The raw copy_in stays
as the escape hatch for pre-formatted data.
Schema-per-test isolation in sub-millisecond (feature test-harness)
#[bsql::test]
async fn creates_a_user(conn: &mut bsql::pg::Connection) {
conn.query_raw("CREATE TABLE users (id int)").await.unwrap(); // in an ISOLATED schema
} // schema auto-dropped, even if the test panicsEach test runs in its own freshly-created PostgreSQL schema (a CREATE SCHEMA, not a
whole database β so it's sub-millisecond, not a per-test database spin-up), so
cargo test's default parallelism never leaks state between tests. The isolation
rides the connect-time search_path, which survives a pool's RESET ALL, so a
pooled connection can't escape its schema. Teardown runs even if the test panics
(the schema is dropped in a catch_unwind, then the panic re-raised, so
#[should_panic] still works). Write a plain fn taking &mut bsql::pg_sync::Connection
and the same attribute gives you the blocking driver β it picks by async-ness.
Migrations, applied at runtime β atomic, ordered, exactly-once
let report = conn.run_migrations(
bsql::MigrationSource::directory("migrations")
).await?;
// report.applied β the migrations newly applied this run, in orderApplies your set to a live database on all three drivers, exactly once and in
lexicographic order, tracked in a _bsql_migrations ledger. Each migration + its
ledger row is one transaction β a failure rolls back and stops with a classified
error naming it, later migrations untouched. An edited-after-apply migration
(checksum drift), a reorder, or a deletion is a classified error, never silently
re-run. Concurrent boots serialize: PostgreSQL via a non-blocking advisory-lock poll
that stays deadlock-free even with CREATE INDEX CONCURRENTLY; SQLite via
BEGIN IMMEDIATE + an in-transaction re-check. Or bake the set into the binary with
embed_migrations!() β no filesystem at run time.
Production-grade connection lifecycle
let pool = pg::Pool::builder(cfg, 16)
.max_lifetime(Some(Duration::from_secs(1800)))
.idle_timeout(Some(Duration::from_secs(300)))
.slow_query_threshold(Some(Duration::from_millis(100)))
.on_diagnostic(|ev| tracing::info!(?ev)) // RAISE NOTICE, slow queries, pool statsβ¦
.build();
match conn.query_one::<GetUser>((id,)).await {
Err(e) if e.is_disconnect() => reconnect(), // connection died β reconnect
Err(e) => return Err(e), // query error β the connection is fine
Ok(u) => u,
}Health-gated checkout (a peer that died while idle is swapped transparently, and
get() stays bounded even on a half-open socket, never the ~15-minute kernel
hang), graceful shutdown (Pool::close sends a clean Terminate β no server
error-log flood), TCP keepalive, max_lifetime / idle_timeout reaping,
is_disconnect() reconnect-vs-retry classification, server-side statement_timeout,
and a client-side liveness window so a black-holed in-flight query is bounded too.
Diagnostics ride a dep-free DiagEvent sink β zero-cost when none is installed
(no clock read, no event built, hot path untouched).
Bring your own types β external-crate bridges
Decode a column straight into a foreign crate's type, with bsql depending on and forcing nothing:
// build.rs
bsql_build::Catalog::from_migrations("migrations")
.bridge("timestamptz", "chrono::DateTime<chrono::Utc>", "crate::to_chrono")
.emit()?;// your one infallible converter β the orphan-proof seam
pub fn to_chrono(ts: bsql::Timestamptz) -> chrono::DateTime<chrono::Utc> { /* β¦ */ }Now a timestamptz column in any query! decodes as chrono::DateTime<Utc>. The
target type and converter travel as strings, so bsql-build gains no dependency on
chrono. You can't impl bsql::Cell for chrono::DateTime (both are foreign β E0117),
but a free function compiles for any foreign target β so this works for uuid::Uuid,
serde_json::Value, rust_decimal, anything.
Speed is half the story; the other half is what the driver lets you do. An honest side-by-side with the Rust field β competitors get credit for what they have.
Capability matrix β bsql vs tokio-postgres / sqlx / diesel
Legend: β full Β· β partial Β· β none.
| capability | bsql | tokio-postgres | sqlx | diesel |
|---|---|---|---|---|
| Compile-time SQL check vs real schema | β
query! replays migrations |
β unchecked string | β
query! |
β typed DSL only |
| β¦with no live DB / cache at build | β offline from migration files | β | β needs DB or committed cache | β schema.rs usually from a DB |
| Plain SQL text (not a DSL) | β | β (unchecked) | β | β query-builder DSL |
| N+1 query detection | β
conn.n1_report(), zero-cost off |
β | β | β |
| Typed/safe binary COPY | β
copy_in_typed, compile-checked |
β hand-wired binary | β text COPY only | β copy_from, not compile-checked |
| Atomic typed pipelining / bulk batch | β
pipeline / execute_batch, all-or-nothing, ~1 RTT |
β untyped pipelining | β | β |
| Build-time migration safety gate | β destructive-op ack + checksum drift | β | β checksum drift | β up/down by version |
| First-class sync AND async, one API | β
shared Core<S> |
β async only | β async only | β separate diesel + diesel-async |
| Zero-per-row-alloc streaming | β
query_each, O(1) RAM, 0 alloc/row |
β streams, allocs/row | β streams, allocs/row | β default materializes |
| Out-of-band query cancellation | β
detached CancelToken |
β
cancel_token() |
β cancel-on-drop | β |
#![forbid(unsafe_code)] (shipped crates) |
β every crate | β some unsafe |
β some unsafe |
β links libpq (C FFI) |
Unix-domain socket β connect to a same-machine DB without the network layer (faster than localhost) |
β ~2.4β2.9Γ vs loopback TCP | β | β | β (libpq) |
Same query! on PostgreSQL and SQLite |
β one carrier, both backends | β PG only | β separate Sqlite/Pg types |
β separate backends |
#![forbid(unsafe_code)]on every shipped crate;deny(unwrap_used, expect_used).- The compile-checked path pins each column's PostgreSQL OID at build time β a wrong Rust type for a column is a compile error, and a wrong parameter type is rejected loudly on every binding surface (typed at compile, dynamic at the server, prepared at the client, SQLite by storage class) β never a silent byte-for-byte reinterpretation.
- The build-time check is against your committed migration files (the
version-controlled source of truth), not a live-DB introspection that can go
stale. An out-of-band
ALTER TABLEapplied by hand with no migration file is invisible at build time β but a runtime OID guard catches such drift as a classified error on the wire, never a silent wrong value. - What the checker asks of you in return: name your result columns (
SELECT *is rejected β the shape must be explicit), and add a cast where an expression's type is genuinely ambiguous (a bareSUM(x)βSUM(x)::int8, an uninferable expression βexpr::type). This is the ergonomic price of typing every column; a plain column, a join,COALESCE, and a cast-annotated aggregate all infer with no annotation. - The wire decoders are proven total (no panic on any input) by a dep-free
fuzz gate; the inbound hot dispatch is proven panic-free and byte-stable by a
codegen gate. NULL is
Option<NonZeroU32>; SQL identifiers spliced into DDL go through a validate-onlySafeIdentnewtype. - Over TLS, SCRAM-SHA-256-PLUS channel binding (opt-in-strict) anchors auth to the server's certificate, closing the valid-cert relay/MITM gap that cert + hostname verification alone leaves.
- 64-bit Linux / macOS / Windows. TCP everywhere; unix sockets on unix.
No CI β by design. All gates run locally via cargo and a bsql-devgates crate
(dependency-frontier pin, runtime-vs-build boundary pin, intra-doc-link wall,
cross-platform check, hot-path codegen ceiling, decoder fuzz):
cargo clippy --workspace --all-targets # lint wall β 0 warnings
cargo test --workspace # unit + integration (offline)
cargo test -p bsql-devgates # the pins + walls
# live suites are #[ignore] and need a local PostgreSQL:
cargo test -p bsql-postgres-async --test sq_live -- --ignoredBenchmarks + their methodology live on the bench branch
(git switch bench && cargo bench), kept off the code branch so a
normal clone stays lean.
Built with Claude Code. Design first, architectural review second, implementation third β every slice reviewed adversarially before it landed.
~2,400 tests across the workspace β unit, integration, compile-fail (trybuild),
live-database, dependency-free fuzz, and machine-checked codegen gates. Not just
tests that the code works, but tests that broken code is rejected at compile
time. Hardened by a real-load fault-injection pass (TLS byte-fragmentation,
mid-stream faults, million-row streams, concurrency) β the sort of thing that
catches what green unit tests hide.
Don't follow the author's name. Don't assume an older library is automatically the safer bet. Run the benchmarks yourself, read the tests, check the code.
CLAUDE.md is the exhaustive engineering reference (conventions, layout, every
gate and safety invariant) β read it if you want the full picture.
MIT OR Apache-2.0, at your option.