#[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_queries (issue #1262)while 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
A loop whose body issues a queryunbounded (rejected under a finite budget)
A loop with a literal bound (for _ in 0..3)body cost × 3
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 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
A #[model] static finder (Post::published(&mut db))1
db.tx(|conn| …) / db.tx_with(…)1, plus the callback body counted once — the callback's conn is tracked, so a helper handed it is still counted
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 (unwrap_or_else, ok_or_else, …)counted once — it is not an iterator adapter

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.

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 handleload_links(&mut db, id). Its body is another function; the macro sees only the call.
  • A macro body that awaits while naming the handlehtml! { … (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 #[model] static finder, 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()), and into transaction callbacks. Two 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 handleJob::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.

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