Production-safe SQL query analyzer for Go applications.
Detects slow queries, dangerous SQL patterns, and performance issues — both at runtime and statically. Think of it as golangci-lint for SQL queries.
go get github.com/KARTIKrocks/sqlguardCLI tool:
go install github.com/KARTIKrocks/sqlguard/cmd/sqlguard@latest| Rule | Severity | Description |
|---|---|---|
select-star |
WARNING | SELECT * — selects all columns unnecessarily |
leading-wildcard |
WARNING | LIKE '%...' (and ILIKE) — index cannot be used |
non-sargable-predicate |
WARNING | WHERE LOWER(col) = ... — function on column defeats its index |
add-not-null-without-default |
WARNING | ALTER TABLE ... ADD COLUMN ... NOT NULL without DEFAULT — fails / rewrites a populated table |
implicit-join |
WARNING | FROM a, b — comma join; a forgotten condition becomes a cartesian product |
cartesian-join |
WARNING | Multiple tables with no join condition or WHERE — a cartesian product (incl. CROSS JOIN) |
in-list-too-large |
WARNING | IN (...) value list with more than max-length (default 100) elements |
large-offset |
WARNING | OFFSET above threshold (default 1000) — deep pagination scans/discards skipped rows |
select-distinct |
INFO | SELECT DISTINCT — often masks duplicate rows from an unintended join |
delete-without-where |
CRITICAL | DELETE without WHERE — deletes all rows |
update-without-where |
CRITICAL | UPDATE without WHERE — updates all rows |
insert-without-columns |
WARNING | INSERT without an explicit column list (VALUES or ... SELECT) — breaks on schema change |
select-without-limit |
WARNING | SELECT without LIMIT or WHERE — may return excessive rows |
orderby-without-limit |
INFO | ORDER BY without LIMIT — sorts entire result set |
n-plus-one |
WARNING | Same query pattern repeated N times (runtime only) |
slow-query |
WARNING | Query exceeds latency threshold (runtime only) |
seq-scan |
WARNING | Sequential scan detected via EXPLAIN (postgres) |
full-table-scan |
WARNING | Full table scan detected via EXPLAIN (mysql) |
high-cost |
WARNING | High cost operation in query plan |
no-index-used |
WARNING | No index used for a table access detected via EXPLAIN (mysql) |
filesort |
INFO | Using filesort in the query plan — ORDER BY not covered by an index (mysql) |
Drop a .sqlguard.yml at your project root. sqlguard discovers it by walking
up from the scanned (or working) directory until it finds the file or the git
root. The CLI takes --config <path> and --no-config; the file is optional
— without it every rule runs at its default. A fully-commented template lives
at .sqlguard.example.yml.
version: 1
rules:
disable: [orderby-without-limit]
severity:
select-star: info # info | warning | critical | off
select-without-limit: "off" # "off" disables the rule
settings:
leading-wildcard:
min-length: 3 # ignore short LIKE '%x%' patterns
in-list-too-large:
max-length: 100 # flag IN (...) lists longer than this
large-offset:
threshold: 1000 # flag literal OFFSET above this
redact: true # redact literals out of Result.Query (default)
slow-query:
threshold: 200ms # runtime middleware threshold
dedup:
window: 1m # report each repeated finding at most once per window ("0" disables)
scan:
exclude-paths: ["(^|/)legacy/"] # static scanner only, regexUnknown keys and rule names are warnings, not errors, so a config written for
a newer sqlguard still loads on an older binary; set strict: true to make
them fatal. only: [rule, ...] switches to whitelist mode.
Inline suppressions — no config required:
SELECT * FROM users -- sqlguard:ignore
DELETE FROM users /* sqlguard:ignore:delete-without-where */// sqlguard:ignore
db.Exec("DELETE FROM users")
db.Query("SELECT * FROM users") // sqlguard:ignore:select-starIn-SQL directives work at runtime and in the static scanner; the Go-source form is honored by the scanner when it sits on or directly above the call.
Apply the same config to the runtime middleware:
opts, _ := config.Middleware("", ".") // discover from cwd
sqlguard.Register("sqlguard-pg", "pgx", opts...)sqlguard's findings flow into logs, so by default it never emits raw
literal values. Before any Result leaves the process its Query is
redacted — single-quoted strings and numeric literals become ?, while
keywords, identifiers (including "quoted" / `backtick` names) and
structure are preserved:
[SQLGUARD WARNING] select-star
Query: SELECT * FROM users WHERE email = ?
Every Result also carries a Fingerprint: the redacted query with
whitespace collapsed and IN (?, ?, ?) folded to (?). It is a stable,
PII-free, low-cardinality identity — safe as a metrics label or log key, and
the same value the N+1 detector groups on. The JSON reporter emits it as
fingerprint.
Opt out only where the query text is trusted (local debugging):
a := analyzer.Default().WithRawQuery() // standalone analyzer
sqlguard.Register("pg", "pgx", middleware.WithAnalyzer(a))or redact: false in .sqlguard.yml. Fingerprint is populated either way.
sqlguard wraps at the database/sql driver layer, so you get back a real
*sql.DB and every query is analyzed automatically — including queries issued
by ORMs and query builders (sqlc, ent, sqlx, gorm, pgx-stdlib). There is no
wrapper type to thread through your code and no method list to keep in sync.
import (
"database/sql"
"github.com/KARTIKrocks/sqlguard"
"github.com/KARTIKrocks/sqlguard/middleware"
"time"
)
func main() {
// Register an analyzed driver by wrapping an existing one...
sqlguard.Register("sqlguard-pg", "postgres",
middleware.WithSlowQueryThreshold(500*time.Millisecond),
middleware.WithN1Detection(5, 2*time.Second),
)
db, _ := sql.Open("sqlguard-pg", "...") // db is a plain *sql.DB
// ...or wrap a driver.Connector directly (e.g. pgx stdlib):
// db := sqlguard.OpenDB(connector, middleware.WithN1Detection(5, time.Second))
// Use as normal — warnings are logged automatically
db.Query("SELECT * FROM users")
// Output:
// [SQLGUARD WARNING] select-star
// Query: SELECT * FROM users
// Issue: SELECT * detected. Selecting all columns can hurt performance.
// Fix: Select only the columns you need.
}The middleware detects when the same query pattern executes repeatedly — a classic N+1 problem:
sqlguard.Register("sqlguard-pg", "postgres",
middleware.WithN1Detection(5, 2*time.Second), // flag after 5 similar queries in 2s
)
db, _ := sql.Open("sqlguard-pg", "...")N+1 patterns are detected within the configured time window. On the raw
database/sql driver path you get back a plain *sql.DB, so detection is
process-wide (windowed) — there is no handle to scope it per request. The
integration adapters (gormguard, pgxguard, sqlxguard, bunguard,
xormguard, entguard) hold the guard and expose ResetN1() to scope
detection to a single unit of work; call it at a request boundary.
A recurring query would otherwise re-emit the same static warning on every execution. By default the runtime middleware reports each finding (rule + query fingerprint) at most once per minute, so a hot query doesn't flood your logs. Tune or disable it:
sqlguard.Register("sqlguard-pg", "postgres",
middleware.WithFindingDedup(5*time.Minute), // quieter
)
sqlguard.Register("sqlguard-pg", "postgres",
middleware.WithFindingDedup(0), // disable: report every occurrence
)Or set dedup.window in .sqlguard.yml. Slow-query and N+1 findings have
their own emission policy and are unaffected.
The middleware also memoizes analysis per distinct query string (an LRU keyed on
the exact query — correct even for the literal-sensitive rules), so a recurring
query is parsed and rule-checked once rather than on every execution. A repeated
query then costs a cache lookup instead of a full parse (≈1000× cheaper, zero
allocations in the repeat case). Default 1024 entries; tune with
middleware.WithAnalysisCacheSize(n) or disable with n == 0.
Scan your Go source code for SQL issues without running the application:
# Scan current directory
sqlguard scan .
# Scan specific package
sqlguard scan ./internal/repository
# JSON output (for CI pipelines)
sqlguard scan --format json ./...Exit code is 1 when issues are found, 0 when clean — works with CI/CD pipelines.
Connect to a live database and analyze query plans:
# PostgreSQL
sqlguard explain --db "postgres://user:pass@localhost/mydb?sslmode=disable" \
"SELECT * FROM orders WHERE user_id = 42"
# MySQL
sqlguard explain --dialect mysql --db "user:pass@tcp(localhost:3306)/mydb" \
"SELECT * FROM orders WHERE user_id = 42"
# JSON output
sqlguard explain --db "..." --format json "SELECT * FROM orders"Detects sequential scans, missing indexes, filesort, and high-cost operations.
For safety the EXPLAIN runs inside a read-only transaction that is always
rolled back (Postgres and MySQL), and ANALYZE is never used — the
statement is planned, never executed. Input is validated with a
comment- and string-literal-aware multi-statement check (a ; hidden in a
comment or string can't smuggle a second statement). Only SELECT/WITH is
allowed by default; pass --allow-dml to EXPLAIN an INSERT/UPDATE/DELETE
(still rolled back). DDL/SET/transaction-control is always refused.
go get github.com/KARTIKrocks/sqlguard/integrations/gormguardimport (
"github.com/KARTIKrocks/sqlguard/integrations/gormguard"
"github.com/KARTIKrocks/sqlguard/middleware"
)
gormDB, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
// Register as GORM plugin — hooks into all queries automatically
gormguard.Register(gormDB)
// Or customize via the standard middleware options
gormguard.Register(gormDB,
middleware.WithSlowQueryThreshold(500*time.Millisecond),
middleware.WithN1Detection(10, time.Second),
)go get github.com/KARTIKrocks/sqlguard/integrations/sqlxguardimport (
"github.com/KARTIKrocks/sqlguard/integrations/sqlxguard"
"github.com/KARTIKrocks/sqlguard/middleware"
)
sqlxDB := sqlx.MustConnect("postgres", dsn)
db := sqlxguard.WrapSqlx(sqlxDB,
middleware.WithSlowQueryThreshold(500*time.Millisecond),
)
var users []User
db.Select(&users, "SELECT * FROM users") // warns about SELECT *The database/sql driver wrapper covers pgx-stdlib (pgx/v5/stdlib). For the
native pgx APIs (pgxpool.Pool, pgx.Conn — which bypass database/sql
entirely) use pgxguard. It hooks pgx's own tracer seam, so every
Query/QueryRow/Exec and every SendBatch is analyzed without a wrapper
type or a method list.
go get github.com/KARTIKrocks/sqlguard/integrations/pgxguardimport (
"github.com/KARTIKrocks/sqlguard/integrations/pgxguard"
"github.com/KARTIKrocks/sqlguard/middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
cfg, _ := pgxpool.ParseConfig(dsn)
pgxguard.ApplyPool(cfg,
middleware.WithSlowQueryThreshold(50*time.Millisecond),
middleware.WithN1Detection(10, time.Second),
)
pool, _ := pgxpool.NewWithConfig(ctx, cfg)Apply (for *pgx.ConnConfig) and ApplyPool (for *pgxpool.Config)
compose with any tracer already installed via pgx's own multitracer,
so sqlguard coexists with otelpgx, ddtrace and friends rather than
silently overwriting them. Configuration is the standard middleware.Option
set — same as the driver wrapper, no parallel surface to learn.
Coverage: Query / QueryRow / Exec (via pgx.QueryTracer) and
SendBatch (via pgx.BatchTracer). Prepared-statement execution is already
covered by QueryTracer, so PrepareTracer is deliberately omitted to avoid
double-reporting. CopyFrom carries no SQL and is out of scope.
bun and xorm build SQL through their own query layers and expose native
before/after hook seams. bunguard and xormguard plug into those seams and
run every statement through the same shared core — same middleware.Option
set, no parallel surface.
go get github.com/KARTIKrocks/sqlguard/integrations/bunguard
go get github.com/KARTIKrocks/sqlguard/integrations/xormguard// bun — register a QueryHook
db.AddQueryHook(bunguard.New(
middleware.WithSlowQueryThreshold(500*time.Millisecond),
middleware.WithN1Detection(10, time.Second),
))
// xorm — register a Hook
engine.AddHook(xormguard.New(
middleware.WithSlowQueryThreshold(500*time.Millisecond),
))ent runs on database/sql, so the simplest coverage is to point entsql at a
*sql.DB from sqlguard.Register/OpenDB. entguard is the dedicated
alternative: it decorates ent's own dialect.Driver, so it covers every
Exec/Query (and transactions it opens) regardless of how the *sql.DB was
created.
go get github.com/KARTIKrocks/sqlguard/integrations/entguarddrv, _ := entsql.Open(dialect.Postgres, dsn)
guarded := entguard.Wrap(drv,
middleware.WithSlowQueryThreshold(500*time.Millisecond),
middleware.WithN1Detection(10, time.Second),
)
client := ent.NewClient(ent.Driver(guarded))Every adapter (gormguard, bunguard, xormguard, entguard, pgxguard,
sqlxguard) exposes a ResetN1() you can call at a per-request boundary to
scope N+1 detection to one unit of work.
By default the analyzer uses a zero-dependency fallback parser: it strips
SQL comments and string-literal contents before pattern matching, so keywords
inside comments/strings and identifiers like update_at no longer cause false
positives. It never errors — SQL it can't fully understand still yields a
best-effort result, so analysis never breaks your query path.
For exact, structural analysis, opt into a real grammar. These live in separate modules so the core stays dependency-free:
go get github.com/KARTIKrocks/sqlguard/parsers/pgparser # PostgreSQL (pure Go, no cgo)
go get github.com/KARTIKrocks/sqlguard/parsers/mysqlparser # MySQL (pure Go, no cgo)import (
"github.com/KARTIKrocks/sqlguard"
"github.com/KARTIKrocks/sqlguard/middleware"
"github.com/KARTIKrocks/sqlguard/parsers/pgparser"
)
sqlguard.Register("sqlguard-pg", "pgx", middleware.WithParser(pgparser.New()))
db, _ := sql.Open("sqlguard-pg", dsn)
// Or with the standalone analyzer:
a := analyzer.Default().WithParser(pgparser.New())A real parser drives the false-positive-prone facts (statement kind,
WHERE/LIMIT/ORDER BY/FROM presence, SELECT *, SELECT DISTINCT, OFFSET,
explicit INSERT columns) from the AST instead of regex. CTEs, subqueries, and
dialect syntax are handled correctly; anything the grammar rejects (dynamic SQL,
driver placeholders) transparently degrades to the fallback parser.
A few facts stay lexical heuristics even with a real parser, because they read
literal values the AST discards or are intentionally text-level: IN-list size
(in-list-too-large), comma/cartesian joins (implicit-join /
cartesian-join), and the literal/text checks (leading-wildcard,
non-sargable-predicate, add-not-null-without-default). These keep their
zero-dependency, best-effort behavior regardless of the parser.
import "github.com/KARTIKrocks/sqlguard/analyzer"
// Create analyzer with only the rules you want
a := analyzer.New(
analyzer.CheckDeleteWithoutWhere,
analyzer.CheckUpdateWithoutWhere,
)
// Or use all defaults
a := analyzer.Default()
// Analyze a query
results := a.Analyze("DELETE FROM users")
for _, r := range results {
fmt.Printf("[%s] %s: %s\n", r.Severity, r.RuleName, r.Message)
}make help # List all targets
make all # tidy, fmt, vet, lint, build, test (all modules)
make build # Compile all modules; `make cli` builds bin/sqlguard
make test # Run tests across all modules (test-race adds -race)
make lint # Run golangci-lint across all modules
make fmt # gofmt -s + goimports
make tidy # go mod tidy across all modules
make install # Install the CLI to $GOPATH/binThe middleware wraps the database/sql driver chain, so every query
is analyzed regardless of how it's issued (Query/Exec/Prepare/Tx,
context variants, and any ORM/query builder on top — sqlc, ent, sqlx, gorm,
pgx-stdlib). There is no method allowlist to keep in sync; you get back a
real *sql.DB.
Opt-in adapter modules, each built on the same middleware.Guard core,
extend coverage to APIs that bypass or sit above the database/sql driver
path:
pgxguard— native pgx / pgxpool (which never goes throughdatabase/sql), via pgx's own tracer seam. Composes with existing tracers (otelpgx, ddtrace) viamultitracer. CoversQuery/QueryRow/ExecandSendBatch.gormguard/bunguard/xormguard— hook each ORM's native before/after callback seam (gorm.Plugin,bun.QueryHook, xormcontexts.Hook).entguard— decorates ent'sdialect.Driver(Exec/Query + the transactions it opens).sqlxguard— sqlx-only helpers that build SQL outside the driver path:Select/SelectContext,Get/GetContext,Queryx,NamedExec/NamedExecContext.
All six inherit redaction-by-default, stable fingerprints, the parser seam,
and slow-query/N+1 detection from the shared core, and expose ResetN1() for
per-request scoping.
- The static scanner resolves inline literals, same/cross-package constants,
constant concatenation, and
fmt.Sprintfwith a constant format string (viago/types); it cannot resolve values only known at runtime. - The default fallback parser is best-effort; for exact structural analysis use a real parser module (see SQL Parsers above)
- EXPLAIN analyzer requires a live database connection; only Postgres and MySQL dialects are supported