Configuration
Every budget is opt-in. A policy with nothing configured reports what it sees and fails nothing, which is what makes QueryGuard safe to add to an existing project: it tells you the truth before it starts blocking anything.
Registration
builder.Services.AddQueryGuard(options =>
{
options.Enabled = builder.Environment.IsDevelopment();
options.DefaultPolicy = QueryGuardPolicy.Create("default")
.WithMaxQueries(20, QueryGuardSeverity.Warning)
.WithRepeatedQueryThreshold(3);
});
builder.Services.AddDbContext<AppDbContext>((provider, db) =>
{
db.UseSqlite(connectionString);
db.AddInterceptors(provider.GetRequiredService<QueryGuardCommandInterceptor>());
});
var app = builder.Build();
app.UseRouting();
app.UseQueryGuard(); // after UseRouting; see below
Two things people miss:
- Attaching the interceptor is a separate step. Registering services is not enough; EF Core has to be
told about it. Outside a container,
options.UseQueryGuard()does it in one call. UseQueryGuard()goes afterUseRouting(). The scope name comes from the matched route pattern, so earlier means every request lands in one(unmatched)scope.
Budgets
| Method | What it limits | Default severity |
|---|---|---|
WithMaxQueries(n) |
Counted commands in the whole scope | Failure |
WithMaxOccurrencesPerFingerprint(n) |
Occurrences of any one query | Failure |
WithMaxDuplicateGroups(n) |
How many queries reached the repetition threshold | Failure |
WithMaxTotalDuration(t) |
Summed command duration | Warning |
WithSlowQueryThreshold(t) |
Slowest single command | Warning |
WithRepeatedQueryThreshold(n) |
When a repetition becomes a candidate warning | Warning (always) |
Every budget is a maximum: exactly at the limit passes.
Severity is per rule, and the defaults differ for a reason. Counting rules default to Failure because
query count is deterministic: same code, same count. Timing rules default to Warning because they are
not, and a guard that fires intermittently on a shared runner teaches people to distrust every other
finding.
Start with WithMaxOccurrencesPerFingerprint. It is the rule that actually catches an N+1: a
total-count budget can stay satisfied while one query quietly repeats.
QueryGuardPolicy.Create("companies")
.WithMaxQueries(20, QueryGuardSeverity.Warning) // a canary
.WithMaxOccurrencesPerFingerprint(5); // the actual guard
Per-endpoint policies
A policy is selected by route pattern, so /api/companies/1 and /api/companies/2 share the policy
for GET /api/companies/{id} rather than creating one each.
options.ForEndpoint("GET /api/reports/{id}", policy => policy
.WithMaxQueries(40)
.WithRepeatedQueryThreshold(6));
An override starts from DefaultPolicy, so a capture setting or allowlist entry added to the default
is not silently lost for every endpoint that has an override.
What counts as a query
Reader and scalar commands count; writes do not. A budget of ten reads means ten reads regardless of how many entities the endpoint saves.
policy.WithCountedKinds(QueryCommandKind.Reader); // exclude scalars too
Note that "what a command does" is not the same as "which EF Core method executed it". On SQLite, EF Core
runs INSERT … RETURNING through the reader path to read the generated key back: QueryGuard classifies
that as a write anyway, or a budget of ten reads would mean something different on every provider.
Capture and privacy
options.Capture = new QueryGuardCaptureOptions
{
CaptureParameterValues = false, // default; leave it
CaptureFirstStackTrace = false, // default; see below
RedactStringLiterals = true, // default
RedactNumericLiterals = true, // default
MaxSamplesPerFingerprint = 3,
MaxNormalizedSqlLength = 4096,
};
CaptureParameterValues = true puts real user data into every report QueryGuard produces, including
any you then attach to a CI artifact or a public issue. It exists because a query executed with 51
different keys is stronger evidence than the same query 51 times, but that is a trade to make
deliberately.
CaptureFirstStackTrace = true costs 20–30× the rest of the capture path: measured, in
benchmarks. Excellent while hunting a specific repeated query on a development
machine; not something to leave on.
RedactNumericLiterals has a real trade-off: it also merges queries differing only by a literal such
as LIMIT 10 versus LIMIT 100. An inlined number is more often an identifier than a page size, so the
default treats it as data.
Documenting intentional repetition
Never reach for a global off switch: there isn't one. Record the exception with its reason, which stays visible in reports:
// Next to the query, when it belongs to one call site.
db.ReportSections.TagWith("QueryGuard:Ignore reason=three-sections-bounded-by-layout")
// Or on the policy, when it belongs to an endpoint.
policy.AllowFingerprint("QG-FP-1A2B3C4D", reason: "Bounded provider lookup; at most three sections.");
Full guidance: false positives.
Logging
options.LogSummaryWhenClean = false; // default
options.ExcludedRoutePrefixes.Add("/internal");
A clean request logs nothing by default. QueryGuard runs on every request, and a clean summary each time
is noise that trains people to filter it out entirely. /health, /healthz, /metrics, and
/favicon.ico are excluded out of the box.
Event IDs are stable and documented on QueryGuardEventIds: they are part of the observable contract, so
a dashboard can be built on them. LogLevel.Error is reserved for QueryGuard's own failures, never for an
application exceeding a budget, so alerting on Error stays meaningful.
Where a repeated query came from
A test scope records the call site of each distinct query by default, so a failure names the code rather than only the SQL:
[FAIL] max-occurrences-per-fingerprint: QG-FP-FDB5F469 executed 50 times; the budget is 5.
SQL: SELECT COUNT(*) FROM "Departments" AS "d" WHERE "d"."CompanyId" = ?
origin: samples/QueryGuard.SampleApi/Program.cs:line 89
One trace per distinct query, never one per execution. Framework and generated frames are filtered out,
so a named method is shown by name and a lambda is shown by file and line: a minimal-API endpoint
compiles to something like Program.<>c.<<<Main>$>b__0_3>d.MoveNext(), which carries no information the
location does not.
Turn it off with captureOrigin: false, or supply your own redactor to control capture exactly:
await using var scope = QueryGuardScope.Start("GET /api/companies", policy, captureOrigin: false);
This is on in a scope and off on a request path, because it costs 20–30× the rest of the capture path: free in a test, not free in production. See ADR-0007 and benchmarks.
In tests
// Where the context is configured.
options.UseSqlite(connectionString).UseQueryGuard();
// In the test.
await using var scope = QueryGuardScope.Start(
"GET /api/companies",
QueryGuardPolicy.Create("companies").WithMaxOccurrencesPerFingerprint(1));
var response = await client.GetAsync("/api/companies");
QueryGuardAssert.Passes(await scope.CompleteAsync());
UseQueryGuard() and QueryGuardScope.Start default to the same ambient accessor, so there is nothing
to match up and calling UseQueryGuard() twice is a no-op rather than a double count. Pass an accessor
explicitly only when the interceptor came from a container.
With WebApplicationFactory, also set TestServerOptions.PreserveExecutionContext: see
troubleshooting.