Skip to main content
Version: Next (unreleased)

Static Scanner

sqlguard scan walks Go source, finds the calls that send SQL to a database, recovers the query text, and runs the same static rules the runtime middleware runs. No database, no running application, no test fixtures — it fits in a pre-commit hook or a CI step.

Usage

go install github.com/KARTIKrocks/sqlguard/cmd/sqlguard@latest

sqlguard scan . # current module
sqlguard scan ./internal/repository # one package tree
sqlguard scan --format json ./... # machine-readable
FlagDefaultEffect
--format console|jsonconsoleOutput shape. JSON is an array of {rule, severity, query, fingerprint, message, suggestion, file, line}.
--config <path>auto-discoverLoad a specific .sqlguard.yml.
--no-configIgnore any config file; run every rule at its default.

Exit code is 1 when any issue is found and 0 when clean. Findings go to stderr, so 2> redirection captures them without disturbing whatever your CI step prints to stdout.

[SQLGUARD CRITICAL] delete-without-where
File: internal/repo/sessions.go:42
Query: DELETE FROM sessions
Issue: DELETE without WHERE clause detected. This will delete all rows.
Fix: Add a WHERE clause to limit the scope of the delete.

[SQLGUARD WARNING] select-star
File: internal/repo/users.go:17
Query: SELECT * FROM users WHERE id = $1
Issue: SELECT * detected. Selecting all columns can hurt performance.
Fix: Select only the columns you need.

2 issue(s) found (17 file(s) scanned)

File and Line point at the call, not at the string constant — that is where the fix goes.

What it looks for

Any call whose method name is one of Query, QueryContext, QueryRow, QueryRowContext, Exec, ExecContext, Prepare or PrepareContext, on any receiver — *sql.DB, *sql.Tx, *sql.Conn, an sqlx.DB, a repository interface of your own. The first argument is taken as the SQL — the second for the …Context variants, whose first argument is the ctx.

_test.go files, hidden directories, vendor/ and node_modules/ are skipped, plus anything matching scan.exclude-paths in your config.

What it can resolve

The scanner type-checks the target with golang.org/x/tools/go/packages, so the query does not have to be an inline literal:

Argument shapeResolved?
db.Query("SELECT …")yes
db.Query(selectUsers) — a const in the same packageyes
db.Query(queries.SelectUsers) — a const in another packageyes
db.Query("SELECT id " + "FROM users") — constant concatenationyes
db.Query(fmt.Sprintf("SELECT * FROM %s WHERE id = %d", table, id))yes — the format string is analyzed with verbs neutralized
db.Query(buildQuery(filters)) — a runtime valueno
db.Query(q) where q is a varno

For fmt.Sprintf, numeric verbs become 0 and everything else becomes a placeholder identifier, so the SQL keeps enough structure for the rules: SELECT * FROM %s still trips select-star; LIMIT %d still counts as a LIMIT.

If the target is not a loadable module (no go.mod, or a build failure), the scanner falls back to a plain go/parser walk that still resolves inline literals, so a broken tree is reported rather than silently skipped.

Suppressing a finding

Add a comment on the call line or the line directly above:

// sqlguard:ignore:delete-without-where
db.Exec("DELETE FROM sessions")

Or inside the SQL itself, which also works at runtime. Details in Suppressions.

In CI

# .github/workflows/ci.yml
- uses: actions/setup-go@v7
with:
go-version: "1.27"
- run: go install github.com/KARTIKrocks/sqlguard/cmd/sqlguard@latest
- run: sqlguard scan ./...

The step fails on any finding. To gate only on the serious ones, lower the noisy rules in .sqlguard.yml:

rules:
severity:
select-distinct: "off"
orderby-without-limit: "off"

Severity does not affect the exit code — any reported finding is a non-zero exit — so use "off" (or rules.disable) for rules you do not want to gate on, rather than info.

Limits

  • Runtime-only rules (n-plus-one, slow-query) cannot fire statically.
  • Only values the type checker can fold are seen. A query assembled at runtime is invisible here — that is what the middleware is for.
  • The default fallback parser is best-effort. The scanner does not currently accept a parser flag; it uses the analyzer your config produces.