sqlguard

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 8, 2026 License: MIT Imports: 3 Imported by: 0

README

sqlguard

Go Reference Go Report Card Go Version CI CodeQL GitHub tag License codecov

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.

Install

go get github.com/KARTIKrocks/sqlguard

CLI tool:

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

Detection Rules

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)

Configuration

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, regex

Unknown 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-star

In-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...)

Security & redaction

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.

Usage

Runtime Middleware

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.
}
N+1 Query Detection

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.

Noise control (finding de-duplication)

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.

CLI Static Scanner

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.

EXPLAIN Plan Analyzer

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.

GORM Integration
go get github.com/KARTIKrocks/sqlguard/integrations/gormguard
import (
    "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),
)
sqlx Integration
go get github.com/KARTIKrocks/sqlguard/integrations/sqlxguard
import (
    "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 *
pgx Integration (native pgx / pgxpool)

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/pgxguard
import (
    "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 / xorm Integrations

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 Integration

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/entguard
drv, _ := 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.

SQL Parsers (accuracy vs. zero dependencies)

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.

Custom Rules
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)
}

Development

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/bin

Coverage

The 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 through database/sql), via pgx's own tracer seam. Composes with existing tracers (otelpgx, ddtrace) via multitracer. Covers Query/QueryRow/Exec and SendBatch.
  • gormguard / bunguard / xormguard — hook each ORM's native before/after callback seam (gorm.Plugin, bun.QueryHook, xorm contexts.Hook).
  • entguard — decorates ent's dialect.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.

Limitations

  • The static scanner resolves inline literals, same/cross-package constants, constant concatenation, and fmt.Sprintf with a constant format string (via go/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

License

MIT

Documentation

Overview

Package sqlguard is a production-safe SQL query analyzer for Go applications.

It detects slow queries, dangerous SQL patterns, and performance issues both at runtime (via a database/sql driver wrapper) and statically (via the CLI).

The runtime guard wraps at the driver.Driver layer, so it returns a real *sql.DB and analyzes every query — including those issued by ORMs and query builders — without a method list to keep in sync.

Register a wrapped driver by name:

sqlguard.Register("sqlguard-pg", "pgx")
db, _ := sql.Open("sqlguard-pg", dsn)
db.Query("SELECT * FROM users") // logs warning about SELECT *

Or wrap an existing driver.Connector directly:

db := sqlguard.OpenDB(connector)

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func OpenDB

func OpenDB(c driver.Connector, opts ...middleware.Option) *sql.DB

OpenDB wraps a driver.Connector and returns an analyzed *sql.DB. Use this when you already hold a connector (e.g. pgx's stdlib.GetConnector).

func Register

func Register(name, baseDriver string, opts ...middleware.Option) error

Register wraps the database/sql driver registered under baseDriver and registers the analyzed result under name. Afterwards sql.Open(name, dsn) yields a *sql.DB whose every query is analyzed.

Types

This section is empty.

Directories

Path Synopsis
cmd
sqlguard command
Package config loads and applies .sqlguard.yml configuration.
Package config loads and applies .sqlguard.yml configuration.
Package explain provides SQL EXPLAIN plan analysis.
Package explain provides SQL EXPLAIN plan analysis.
integrations
gormguard module
pgxguard module
parsers
pgparser module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL