cmd / nullscan
nullscan is a static check that flags pgx scans where a SQL column
is nullable but the Go destination is a non-nullable type (int64,
string, time.Time).
It pairs with the package-local SQL layout in go / postgres.
Why
A pgx scan into a non-nullable Go type panics at runtime on the first
row that returns NULL. Each such bug used to surface as a production
incident, then a one-line patch. nullscan turns that class of crash
into a failing build on the offending pull request.
The rule is decidable from db/schema.sql plus the SQL text. If the
schema says a column can be NULL and the query does not COALESCE it or
lift it out through a JOIN to a NOT NULL column, the Go destination
must accept NULL.
How it works
nullscan loads db/schema.sql for column nullability, then walks
every package under the repo looking for pgx.RowToStructByName[T]
and pgx.RowToStructByPos[T] callsites. For each site it resolves the
query through the q<Name> = sqlFile("<file>") pattern, derives the
per-projection nullability of the SELECT, and flags any struct field
scanned into a non-nullable type when its column is nullable.
Struct fields map to columns by db:"col" tag for by-name scans and
by index for positional scans.
Scope
Inference handles the cases common in these queries: bare table.col,
coalesce(...), count(...), literal projections, and LEFT JOIN
propagation. Anything harder (subqueries in the projection, UNION,
CTEs, expressions other than COALESCE or count) is treated as unknown
and left alone. The check reports a mismatch only when it is sure, so
it does not cry wolf.
There is no per-field ignore directive. If nullscan flags a real
false positive, fix it by COALESCE-ing the column or projecting from a
NOT NULL column through a JOIN. If the analyzer is wrong, fix the
analyzer.
In CI
nullscan runs as one line in the cibot Checkfile:
nullscan: go run ./cmd/nullscan
Output is plain text, one line per finding:
<query>.sql: field PersonRow.Email (string) scans nullable column "email" from people/queries/fetch_person.sql
A failed run pastes cleanly back into an agent prompt.
A lightweight alternative to sqlc
The package-local SQL technique
keeps hand-written structs and embedded .sql files with no codegen
step. The tradeoff is that nothing checks the Go types against the
schema the way sqlc does when it generates code.
nullscan recovers the specific safety property that matters most in
practice: NULL-into-non-nullable mismatches. Combined with
pgfmt for formatting, the embedded-SQL approach keeps the
query, SQL file, and call site together while catching the bug class
that would otherwise reach production.
sqlc still does more. It generates typed methods and validates whole
result shapes. nullscan covers one decidable rule over a known SQL
subset, which is enough to make the no-codegen technique safe for the
queries this repo actually writes.