Configuration
Budgets are opt-in. Without a budget, QueryGuard reports findings but does not fail a test.
Registration
Register the services, attach the interceptor, and add middleware after routing:
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();
Registering services alone does not attach the interceptor.
Without a DI container, use options.UseQueryGuard() when configuring EF Core.
Budgets
| Method | What it checks | Default severity |
|---|---|---|
WithMaxQueries(n) |
Total counted commands | Failure |
WithMaxOccurrencesPerFingerprint(n) |
Count for each query ID | Failure |
WithMaxDuplicateGroups(n) |
Number of groups that reach the repetition threshold | Failure |
WithMaxTotalDuration(t) |
Sum of command durations | Warning |
WithSlowQueryThreshold(t) |
Slowest command | Warning |
WithRepeatedQueryThreshold(n) |
Repetition count that triggers a candidate warning | Warning (always) |
Maximum budgets pass at the limit. The repetition threshold warns when the count reaches it. Timing rules default to warnings because database speed varies between runs.
To limit repeated queries:
var policy = QueryGuardPolicy.Create("companies")
.WithMaxQueries(20, QueryGuardSeverity.Warning)
.WithMaxOccurrencesPerFingerprint(5);
This warns above 20 counted commands and fails if one query runs more than five times.
Per-endpoint policies
Match the route pattern, not a specific URL. Overrides inherit DefaultPolicy:
options.ForEndpoint("GET /api/reports/{id}", policy => policy
.WithMaxQueries(40)
.WithRepeatedQueryThreshold(6));
What counts as a query
Reader and scalar commands count by default. Writes do not. To count only reader commands:
policy = policy.WithCountedKinds(QueryCommandKind.Reader);
QueryGuard also checks SQL for writes because EF Core can execute
INSERT ... RETURNING through the reader path.
| SQL | Classification |
|---|---|
SELECT ';DELETE' |
Read |
/* tag */ INSERT ... RETURNING ... |
Write |
SET NOCOUNT ON; INSERT ... |
Write |
The check skips strings, quoted identifiers, and comments when finding statement boundaries. It is not a full SQL parser: CTEs and stored procedure bodies are not interpreted. Without a clear write statement, the EF Core execution kind is kept. Failed commands with no execution kind use the read fallback.
Capture and privacy
Default capture options:
options.Capture = new QueryGuardCaptureOptions
{
CaptureParameterValues = false,
CaptureFirstStackTrace = false,
RedactStringLiterals = true,
RedactNumericLiterals = true,
MaxSamplesPerFingerprint = 3,
MaxNormalizedSqlLength = 4096,
};
- Keep parameter values disabled to avoid putting user data into reports.
- Connection strings are not captured.
- Number redaction groups queries such as
LIMIT 10andLIMIT 100together. - Set
MaxSamplesPerFingerprint = 0to keep counts and timing without samples. - Stack traces add capture cost. See benchmarks.
Documenting intentional repetition
Add a reason next to the query or on its policy:
var sections = db.ReportSections.TagWith("QueryGuard:Ignore reason=three-sections-bounded-by-layout");
policy = policy.AllowFingerprint(
"QG-FP-1A2B3C4D",
reason: "Bounded provider lookup; at most three sections.");
Ignored findings stay visible with their reason. See when a finding is wrong.
Logging
options.LogSummaryWhenClean = false; // default
options.ExcludedRoutePrefixes.Add("/internal");
Clean requests do not log by default. These paths are excluded:
/health, /healthz, /metrics, and /favicon.ico.
QueryGuardEventIds defines stable log event IDs.
LogLevel.Error is used for QueryGuard errors, not exceeded budgets.
Where a repeated query came from
Test scopes capture one filtered stack trace per query ID by default. Reports can show the application method or file and line:
origin: samples/QueryGuard.SampleApi/Program.cs:line 89
Disable it for a test scope:
await using var scope = QueryGuardScope.Start("GET /api/companies", policy, captureOrigin: false);
On the request middleware path, stack traces are off by default.
Enable them with CaptureFirstStackTrace = true.
The measured scenarios were
20–30 times slower with stack traces.
SQL string privacy
String redaction covers single-quoted strings, PostgreSQL E'...' escape strings,
and $$...$$ or $tag$...$tag$ strings.
A backslash before a quote in an ordinary string is ambiguous without the database SQL mode. QueryGuard hides the rest of that command. This can shorten the SQL and group more queries together. Use parameterized SQL to avoid this ambiguity.
Setting RedactStringLiterals = false keeps string contents in reports.
SQL display length and fingerprints
With the built-in QueryGuardRedactor, MaxNormalizedSqlLength limits retained SQL to
4096 characters by default, plus a truncation marker.
The ID uses the complete normalized, redacted SQL before truncation. Changing the display limit does not change the ID. Long queries with different endings keep separate IDs and budgets, even if their displayed text looks the same. Queries that differ only in redacted values still share an ID.
After upgrading from truncated hashing, review fingerprint allowlists for long queries. Also review baseline counts, since previously merged query groups may now be separate.
Custom IQueryGuardRedactor implementations return the text that is both hashed and retained.
To hash more text than a custom redactor returns, supply a custom IQueryFingerprintFactory.
In tests
Use TrackQueries for HTTP tests or QueryGuardScope for services.
UseQueryGuard() and QueryGuardScope.Start(...) share the default session accessor.
Calling UseQueryGuard() twice does not add a second interceptor.
If your interceptor comes from DI, pass that container's accessor to the scope.