Performance
A statement is built by appending to buffers, rendered once, and handed to the driver. The design target is one SQL string per query, one encode per parameter, one allocation per row of result, and nothing per row of work; the numbers below are what that costs before a driver or a network is involved.
What a statement costs
cargo bench -p besu measures the builder against a dry database and a hand-written dialect,
so no server is needed. On a Ryzen-class machine, release build:
Those numbers include to_sql, and therefore include a clone of the query. A query that runs
moves its values instead and pays less. The bulk insert is dominated by cloning a hundred
pre-built rows for the same reason; executing one moves them.
Where the time goes
- One SQL string.
Sqlstarts with a capacity estimate and every clause is written into that buffer. Identifiers are quoted in place withDialect::write_ident, so no identifier gets its ownString; placeholders are written the same way, and PostgreSQL's$1is built from digits rather thanto_string. - One argument vector. Each value is moved into
Sql::argswhen it is bound. Execution does not clone a parameter; the driver converts each already-bound value into its own argument type once. - No allocation per condition. A comparison stores its operands inline in the expression
node, so
users.age.gt(18)appends one node to the enclosing vector and allocates nothing. A query holds tens of nodes, not millions. - Buffers sized up front. A bulk insert reserves its whole row buffer from the iterator's size hint, so it allocates once rather than growing through a chain of reallocations.
- Decoding is one pass.
all()allocates oneVec<R>sized to the row count and callstry_getonce per column. There is no row map, no identity map and no second statement.
Three honest caveats
- The table handle is not literally zero-sized. Each
Columnstores its name as a&'static str, which is 16 bytes on a 64-bit target. A five-column handle is 80 bytes, allCopy. It is small and free to pass around, not a ZST. - Storing operands inline makes an expression node larger — about 104 bytes rather than
32 — so that a
WHEREclause costs no allocations. The trade is a few hundred bytes of vector space against two allocations per condition. - The capacity estimate is an estimate. It errs high, so a statement can carry a few
hundred spare bytes of
Stringcapacity; it is sized to avoid copies, not to be exact.
The numbers also exclude everything below Besu: parameter encoding inside the driver, the wire round trip, the server's parsing and planning, and result transfer. On a local database those dominate a 157 ns build by orders of magnitude.
Running the benchmarks
Each driver has its own bench, which needs its server:
Without the variable the database half of the bench prints a line and registers nothing, so
cargo bench still works with no containers. The container commands and the review
expectations are in Contributing.
A query is built fresh, so hoist nothing
A builder borrows its executor and carries the row type, so hoisting one rarely helps: it holds the borrow for the whole scope and it cannot change its parameters without being rebuilt anyway. Rebuilding costs a few hundred nanoseconds, which is noise next to a round trip.
The exception is the rendered Sql. A cursor borrows the statement, so a stream needs it to
outlive the cursor; build the query once, render it once, and pass the Sql around:
Do not keep your own cache of rendered SQL to avoid the build. A Sql is bound to the
arguments it was built with, so a different value needs a different statement anyway, and the
driver's prepared-statement cache already makes a repeated SQL string cheap.
Streaming a large result set
all() collects every row before it returns. stream decodes one row at a time, so a million
rows costs a million rows of work rather than a million rows of memory.
Inside a transaction the stream borrows the transaction mutably, so drop it before the next statement or the commit:
What is checked
Gotchas
to_sql()clones the query. It takes&selfso the builder stays usable; if you do not need it afterwards, awaiting the builder avoids the clone.to_sql()and execution have different error types.to_sql()returnsbesu::Error; awaiting the query returns the driver's error. A function that does both needs a boxed error or a mapped one.inspect()is for humans. It inlines the parameters into a newStringand must never be executed.- The bench numbers are the builder, not the database. They tell you Besu is not the bottleneck; they say nothing about a query plan.
- A stream is not free. It has a cursor and a per-row await; for a handful of rows
all()is simpler and not measurably slower. values_chunkedis not atomic. It gets past the parameter limit by sending several statements, each of which commits unless it runs inside a transaction. See Batch API.
