#[query_budget(N)] makes a route's database query count a build-time contract. Declare the ceiling on the handler, and the build fails if any statically reachable path can issue more than N queries — on every branch, whether or not a test exercises it.

Rust
use autumn_web::{get, query_budget};

#[get("/posts")]
#[query_budget(2)]
pub async fn index(repo: PgPostRepository) -> AutumnResult<Markup> {
    let posts = repo.find_all().await?;                                // 1
    let posts = repo.preload(posts, Post::preload().author()).await?;  // 2
    Ok(render(&posts))
}

Autumn can do this because it owns 100% of the query-issuing surface: every statement reaches the database through a #[repository] method, a preload batch, or a diesel-async executor handed the request's Db handle. The handle is always named in the handler's signature, so the queries a handler can issue are visible to the build.

How this differs from the runtime tools

Autumn already ships two N+1 tools, and both are reactive:

ToolWhen it firesCoverage
Dev inspector N+1 badgewhile you browseonly paths you happen to click
TestResponse::assert_max_querieswhile a test runsonly paths a test exercises
#[query_budget(N)]cargo buildevery reachable path, tested or not

They are complements, not replacements. The compile-time gate proves an upper bound; the runtime tools show you the actual SQL. Nothing about #[query_budget] runs during a request — production enforcement remains the job of request timeouts and load shedding.


The worked example: a page listing rows and their associations

Red build

A post index that renders each post's author. The author lookup sits inside the loop over posts — the classic N+1:

Rust
use autumn_web::{get, query_budget};

#[get("/posts")]
#[query_budget(2)]
pub async fn index(repo: PgPostRepository) -> AutumnResult<Markup> {
    let posts = repo.find_all().await?;
    let mut authors = Vec::new();
    for post in &posts {
        authors.push(repo.find_author(post.author_id).await?);
    }
    Ok(render(&posts, &authors))
}

cargo build fails:

Text
error: `#[query_budget(2)]` cannot be proven: a database query
       (`find_author`) runs inside a loop, so this handler's query count
       grows with the size of the collection — the classic N+1.

       Batch the per-row lookup into one query with `preload(...)`, put
       `#[query_cost(N)]` on the loop statement when the iteration count
       is bounded by something the analysis cannot see, or opt the handler
       out with `#[query_budget(unbounded, reason = ...)]`. See
       docs/guide/query-budgets.md.
  --> src/routes/posts.rs:9:5
   |
 9 |     for post in &posts {
   |     ^^^

Note what did not have to happen: no test ran, no request was made, and the page was never opened in a browser.

Green build

Replace the per-row lookup with one batched preload:

Rust
use autumn_web::{get, query_budget};

#[get("/posts")]
#[query_budget(2)]
pub async fn index(repo: PgPostRepository) -> AutumnResult<Markup> {
    let posts = repo.find_all().await?;                                // 1
    let posts = repo.preload(posts, Post::preload().author()).await?;  // 2
    for post in &posts {
        let _author = post.author()?;   // already loaded — not a query
    }
    Ok(render(&posts))
}

The loop is still there; it just no longer issues anything. Two queries, budget of two, clean build.

Both halves are compiled in CI as trybuild fixtures — see autumn/tests/compile-fail/query_budget_n_plus_one.rs and autumn/tests/compile-pass/query_budget_valid.rs.


How the count is computed

ConstructCost
Straight-line statementssum
if / match armsmaximum — only one arm runs, so the bound is the worst one
return, break, continuethe early path does not include the cost of the code it skips. if cached { return repo.find_cached().await; } repo.find_fresh().await is 1. A return inside a loop does not reach the code after the loop. A break does
A loop whose body issues a query on a path that starts another passunbounded (rejected under a finite budget)
A loop with a literal bound (for _ in 0..3)× 3 for a path that starts another pass. A path that leaves the loop (break, return) is paid once: for _ in 0..3 { repo.a().await?; break; } is 1
A loop whose body issues nothing0 — loops are free until they query
A chain rooted at a Db / repository handle1, however many builder methods (on_primary(), scoped(), limit(), …) it carries — splitting the chain across let bindings, or picking it with if / match, does not change the count
.preload(rows, Post::preload().author().tags())one per association — two here, the batched WHERE … IN (…) loads, plus 1 for a finder ahead of it in the same chain
A diesel executor call (.load(&mut *db), .first(…), .get_result(…))1
An associated function handed the handle (Post::published(&mut db))reported — put #[query_cost(N)] on the statement
db.tx(|conn| …) / db.tx_with(…) / db.tx_immediate(…)1, plus the callback body counted once — the callback's conn is tracked, so a helper handed it is still reported. The receiver must be a database connection (Db, PgConnection, state.db(), maybe.unwrap() on an Option<Db>, the callback's conn): on a repository or another value, the callback may run many times, and its conn is a handle
repo.find_in_batches(…) / find_each(…)unbounded — a keyset walk issues one query per batch, a count set by the table's size
An Option/Result combinator closure (map, and_then, unwrap_or_else, …)counted once when the receiver is known to be an Option or a Result, and then on a bool; otherwise it may run per element

A repository future is counted where it is built, not where it is awaited, so collecting futures in a .map(…) and driving them with join_all later is caught as the same N+1.

How a handle is tracked

The analysis follows the handle through every name that holds it:

  • Bindings. The analysis reads each of these binding forms: let, let … else, assignment, if let, while let, match arms, for patterns, closure parameters and transaction callback parameters. A type annotation also marks a handle: let r: PgPostRepository = …. An Arc<PgPostRepository>, Box<…>, Rc<…>, dyn PostRepository or impl PostRepository is a handle too, and so is a State<…> or Extension<…> extractor of any of them (State(repo): State<PgPostRepository>). Another extractor holds a handle only for an exact handle type: Json<CreateRepository> is a request body, not a handle. A type annotation made only of standard and primitive types, such as let ids: Vec<i64> = …, marks the binding as plain. rustc checks it, so it cannot hold a handle.
  • Scopes. A let in a block ends with the block. An assignment to a name declared outside the block stays after the block. A value that a block, an arm or a break gives keeps what it holds: let alias = { let moved = repo; moved }; makes alias a handle. An assignment clears a handle only when the value shows what it holds (slot = None;). alias = make(); or alias = a + b; has the old type, so it keeps the handle.
  • Branches. After an if, a match or a loop, a name holds a handle when it holds one on any path. The bindings at a return, break or continue apply where that exit lands.
  • Containers. A value built from a handle holds it: [repo], vec![repo], (repo, 1), Some(repo), Ctx { db }. A parameter of type Vec, VecDeque, Option, map, set, tuple, array or slice of a handle type also holds handles (Vec<PgPostRepository>, Option<Db>, HashMap<i64, PgPostRepository>). So does a Result with a handle or such a container on either side.
    • A name bound to a struct or tuple literal records what each part holds. In let ctx = PageCtx { repo: &repo, user };, ctx.repo is a handle and ctx.user is not.
    • An index (repos[0]), a field (pair.0), a pattern (Some(r), for r in repos) or ? gives a handle.
    • A method that the container's own type has and that takes self is not a query: repos.into_iter(), maybe.unwrap(), maybe.map(|r| r.id), result.ok(), and the Iterator adapters that take self (map, filter, count, last, max_by_key, collect, …). Each type has its own list. The method gives a handle when it returns a part (maybe.unwrap()), and a container when it returns an iterator or an Option of a part (repos.into_iter(), it.last()).
    • A method that borrows the container (&self or &mut self) is reported, also when the type has it: repos.len(), repos.push(repo), repos.as_slice(), maybe.as_ref(), maybe.take(), it.next(), it.any(…). Rust looks for a self method before a &self method, and for a &self method before a &mut self method, so an application trait method len(self) on Vec<PgPostRepository> runs in place of Vec::len. The macro has no type information to rule that out. What the method gives may hold handles at any depth. Every other method on the container is reported too (repos.refresh_all(), or an extension-trait repos.ok()), and so are clone and extend.
    • To read a container of handles without a report, use an index (repos[0], &repos[1..]), a pattern (let [first, ..] = &repos[..], if let Some(r) = &maybe), a for loop (for r in &repos) or a method that takes self. Otherwise, put #[query_cost(N)] on the statement, and give the binding a type made only of standard and primitive types: #[query_cost(0)] let n: usize = repos.len();. Without the type, a later use of n is reported too.
    • A handle in an Option or a Result has only their methods. So lazy.checkout() on a Result<LazyDb, E> is reported. Call lazy.expect("…").checkout() instead.
    • A method or function given a handle may store it: after list.push(repo) or fill(&mut list, &repo), list holds a handle. A store or an assignment through a &mut alias (let slot = &mut list; slot.push(repo);, *slot = …) is a store into list. A callback stores what it returns (slot.get_or_insert_with(|| &repo)). Every method on a user struct that holds a handle (ctx.clear() on Ctx { repo }) is reported too.
    • A callback's result holds what the callback returns, whatever the receiver holds: ids.iter().map(|_| &repo) gives handles, and so do fold, then and unwrap_or_else. A method that borrows its receiver and is given such a callback (find_map, try_fold, try_for_each, any) is reported: a trait method in its place may call the callback many times. A return in the callback counts too. A closure that names a handle holds it, so ids.iter().map(make) and make() keep the handle.
    • map_or, map_or_else and fold give only what their default and callback give: Some(repo).map_or(0, |_| 1) is plain. A callback on a Result parameter gets only its side: in result.map_err(|e| …), e is the error, not the handle. result.ok() and result.err() also give only their side, and result.map(|_| 1) keeps only the Err side. Ok(repo) has a plain Err side, and Err(repo) has a plain Ok side.
    • A container of containers or of user values (Vec<Vec<PgPostRepository>>, Option<Vec<…>>, [ctx], repos.chunks(2), or repos.iter().map(|r| Ctx { repo: r })) keeps that shape for all its parts. Every method on it or on a part of it is reported. Arc<Vec<…>> is a plain container: a smart pointer adds no depth. left.append(&mut right) adds the parts of right, so left stays a plain container.
    • A helper handed the container is reported.

When the parts are not known, for example in a parameter or a destructured value, every part counts as a handle. This can over-count, but it never under-counts. A query future in an array (join_all([repo.a(), repo.b()])) is counted where it is built, so the array does not count as a container.

What the analysis refuses to guess

Anything the analysis cannot read is reported, never assumed query-free. That is the whole point: a false positive costs you one annotation, a false negative ships an N+1 to production.

  • A helper function handed the handle — load_links(&mut db, id). Its body is another function; the macro sees only the call.
  • An associated function handed the handle — Post::published(&mut db). It has the same shape as ReportBuilder::build(&mut db), which can issue any number of queries. Put #[query_cost(1)] on the statement that calls a one-query finder. An awaited constructor (PgPostRepository::new(&mut db).await, Arc::new(&repo).await) is reported too. No std constructor gives a future, so it is an async fn, and an async fn can run queries. This is also true when the .await comes later (let pending = PgPostRepository::new(&mut db); pending.await). An .await on any value that holds a handle is reported, unless the value is a query or accessor on a handle, a call, or an async block.
  • An operator handed the handle — repo + x, -repo. An Add or Neg impl is a function, and its output can be a future. A comparison (repo == other) gives a bool and is not reported.
  • A macro body that awaits while naming the handle — html! { … (fetch(&mut db).await?) … }. A macro body is token soup to syn. A template that merely passes the handle to a render helper is fine: only an await inside the body makes it suspicious, and logging/formatting macros (tracing::debug!, format!, …) are never suspicious.
  • A closure that may run per element — anything that isn't a transaction callback.
  • A preload spec that isn't a literal builder chain — a spec built elsewhere and passed in as a variable.

Each of these fails with a diagnostic naming the call and the annotation that resolves it.

Escape hatches

#[query_budget(unbounded, reason = "…")] — the whole handler

For legitimately dynamic work, where a ceiling would be a lie:

Rust
#[query_budget(unbounded, reason = "operator backfill, bounded by an explicit page size")]
pub async fn backfill(repo: PgPostRepository, ids: Vec<i64>) -> AutumnResult<()> {
    for id in ids {
        repo.reindex(id).await?;
    }
    Ok(())
}

The reason is optional to the compiler and expected by review: it is the sentence the next reader needs.

#[query_cost(N)] — declare one statement's cost

Both annotations are read at statement level. You cannot annotate a sub-expression or a call buried inside a closure — hoist it to its own statement first. A compound statement is one statement, so #[query_cost(10)] on a for loop declares the whole loop's cost, which is the supported way to bound a loop whose iteration count the analysis cannot see:

Rust
#[query_budget(10)]
pub async fn refresh(repo: PgPostRepository, ids: Vec<i64>) -> AutumnResult<()> {
    #[query_cost(10)]   // the page size is capped upstream at 10
    for id in ids {
        repo.reindex(id).await?;
    }
    Ok(())
}

Because the annotated statement's interior is not analysed, an annotation on a loop or block hides everything inside it. Keep the scope as small as the fix allows.

Use it when a helper's query count is known but not visible:

Rust
#[query_budget(3)]
pub async fn show(mut db: Db) -> AutumnResult<Markup> {
    #[query_cost(2)]
    let links = load_links(&mut db, 1).await?;   // counted as 2
    let tags = tags::table.load(&mut *db).await?; // counted as 1
    Ok(render(&links, &tags))
}

The annotated statement's interior is not analysed — N is taken as given, so keep it honest and next to the helper it describes.

#[query_exempt(reason = "…")] — drop one statement from the ledger

For a call the analysis flags but which issues nothing:

Rust
#[query_budget(1)]
pub async fn show(mut db: Db) -> AutumnResult<Markup> {
    let posts = posts::table.load(&mut *db).await?;
    #[query_exempt(reason = "reads the warm cache only; verified query-free")]
    let banner = banner_for(&mut db).await?;
    Ok(render(&posts, &banner))
}

Both statement annotations are consumed by #[query_budget] and never reach rustc — they are only meaningful inside an annotated function.


Attribute order

#[query_budget] reads the handler and emits it unchanged, so it composes with the route macro in either order. The same holds for #[secured], #[step_up], #[authorize], and #[throttle], which wrap the body in an async block the analysis walks through — pinned by the trybuild fixtures in autumn/tests/compile-pass/:

Rust
#[get("/posts")]          // route macro outermost — preferred
#[query_budget(2)]
pub async fn index(repo: PgPostRepository) -> AutumnResult<Markup> { /* … */ }

Keeping the method attribute outermost matches the convention the other handler attributes (#[secured], #[throttle]) document, and keeps the handler's real return type visible for OpenAPI response schemas.

Some attributes rewrite the handler body before #[query_budget] reads it: #[secured], #[step_up], #[authorize], and #[throttle] wrap it in an async block, and #[cached] wraps it in an immediately-invoked closure. The analysis walks through both shapes, so it never blames you for a closure you did not write. (#[cached] requires Hash arguments and a Clone + Deserialize return, so it cannot take a Db or repository extractor in the first place — a cached function has no queries to budget.)

It also works on plain helper functions, not just routes — useful for pinning a shared query helper's cost where it is defined.

What the expansion leaves behind

Each annotated function gets a hidden constant recording the contract and the proof:

Rust
assert_eq!(__AUTUMN_QUERY_BUDGET_index.declared, Some(2));
assert_eq!(__AUTUMN_QUERY_BUDGET_index.proven_max, Some(2));
assert_eq!(__AUTUMN_QUERY_BUDGET_index.headroom(), Some(0));

See StaticQueryBudget. It carries no runtime behaviour; it exists so tests and tooling can read the proof back.

Scope of the first slice

Deliberately out of scope for now:

  • Allocation, CPU, and latency/cost budgets (the north star; this slice is query count only).
  • Queries issued inside background jobs, #[scheduled] tasks, or plugin code.
  • Runtime enforcement or per-route SLO dashboards — this is a build-time gate, not a production limiter.
  • Proving exact counts for fully dynamic control flow. The gate targets the static and loop-shaped cases and requires an explicit annotation elsewhere.

Soundness contract

Within an annotated function, every construct that can issue a query is either counted or reported — never silently skipped. Counting rests on two framework contracts, both of which the macro states in its diagnostics:

  1. One repository-chain call, one diesel executor call, or one preload association issues one query.
  2. A call site the analysis cannot read declares its own cost with #[query_cost(N)], or is excluded with #[query_exempt(reason = "…")].

A hand-written #[repository] method that issues more than one query must therefore declare it at the call site. Both annotations are opt-ins a reviewer can see in the diff — which is the point: the unprovable parts are visible rather than assumed.

Where the boundary sits

The analysis tracks a handle from where the signature names it (the Db / repository extractor), through fields and conventionally-named accessors (self.repo, state.db, app.pool()), through every binding and container (see How a handle is tracked), and into transaction callbacks. These things sit outside it, by construction:

  • A handle obtained some other way — for example a repository pulled off an application-state extractor by an application-specific method (state.posts()). Take the Db or repository extractor in the signature and the gate sees everything.
  • Queries issued through ambient state rather than a handle — Job::enqueue writing a job row, audit sinks, session and flash writes. These reach the database without any handle in the handler's signature, so no static attribution is possible; they are the same class as the background-job work listed under Scope above.
  • An application trait on a reference to a container. The macro reports every method that borrows a container of handles, because a trait method can run in its place (see How a handle is tracked). It trusts a standard method that takes self: an inherent method comes before a trait method, and two trait methods with one name do not compile. But Rust tries the receiver's own type first. When the receiver is a reference (maybe: &Option<PgPostRepository>), a trait implemented for &Option<PgPostRepository> with a map(self) method runs in place of Option::map. The macro reports (&maybe).map(…), but it does not see that a name holds a reference. Do not implement a trait for a reference to a container of handles.
  • An application item that takes a standard name. The macro trusts a standard name that is bare or under a std path: the type Vec, vec!, format!, drop. It does not trust one under another path (custom::Vec, custom::vec!), or one that the handler body defines or imports (macro_rules! vec, fn drop, use E::V as Some, use E::*, use custom as mem for mem::drop). Such an item applies in its own block, and to a value that leaves it. It cannot see a module-level use or macro_rules! that replaces a bare standard name. Do not give an item of yours a standard name. A std path must start with std, core or alloc: mem::drop and vec::Vec may name a module-level alias, so they are not trusted.

proven_max is not query_count()

The compile-time bound and the runtime counter measure deliberately different things: TestResponse::query_count excludes transaction control statements (BEGIN/COMMIT/SAVEPOINT/…), while the static model charges 1 for the db.tx(…) call itself. A handler with one query inside one transaction has proven_max == 2 and query_count() == 1. Tune each against its own tool.


See also

  • Repositories — #[repository] and the derived finders
  • Dev Request Inspector — the runtime N+1 badge
  • Testing — driving handlers with TestApp; TestResponse::assert_max_queries / assert_no_n_plus_one are the runtime counterpart to this gate