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.

← All articles