Skip to main content

Nullability

This is the reason the extension exists rather than a catalog query.

Two different questions​

pg_attribute.attnotnull says whether a source column is declared NOT NULL. Whether a result column can be NULL is a different question, and an outer join is where the two come apart:

SELECT ord, name, source_table::text, base_not_null, result_not_null
FROM pg_describe('SELECT o.id, c.email FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id');
ord | name | source_table | base_not_null | result_not_null
-----+-------+--------------+---------------+-----------------
1 | id | orders | t | t
2 | email | customers | t | f

customers.email is declared NOT NULL, and base_not_null reports that correctly. The result column is NULL anyway for an order with no customer, because the join null-extends the whole right side when it finds no match.

A tool reading attnotnull alone types that field non-nullable and hands you a null-dereference on the first guest order. Outer joins are not an exotic corner; they are most of the reporting queries anyone writes.

Use result_not_null. base_not_null is exposed because it is occasionally useful to know the difference — for a migration report, say — not because you should type against it.

How it is computed​

By walking the query's join tree, recursing into both arms of each join before applying that join's own rule, and collecting every base relation beneath a null-extended side. That handles the nesting a per-join implementation gets wrong:

a LEFT JOIN (b JOIN c) b and c are BOTH nullable
(a LEFT JOIN b) JOIN c only b is nullable
a LEFT JOIN (b LEFT JOIN c) b and c

In the first case, an outer join that finds no match null-extends its entire right subtree at once, so the inner join between b and c is irrelevant. All three shapes are in the regression suite; an implementation inspecting only each join's immediate arms passes the second and fails the first.

FULL JOIN nulls both sides. RIGHT JOIN nulls the left.

Grouping sets​

GROUP BY ROLLUP, CUBE and GROUPING SETS null-extend grouping columns in their super-aggregate rows — the total row of a rollup has NULL where the grouping column was. When a query uses them, no provenance-bearing column is reported non-null.

What is not modelled​

All of these report nullable or unknown, so all of them fail safe:

  • Set operations. UNION branches are subquery range table entries, so target-list Vars do not resolve to a base relation and no flags are reported.
  • Subqueries and CTEs. Same reason.
  • CHECK constraints, partial indexes, WHERE x IS NOT NULL. Predicates that guarantee non-nullness are not consulted, so a column that cannot be NULL in practice may still be reported nullable.
  • Expressions. upper(x), count(*) and literals have a type but no source column, so both flags are NULL, meaning unknown.

NULL in result_not_null means unknown, not not null. Treat it as nullable.

In generated TypeScript​

pg-describe-gen follows the same rule: result_not_null = t becomes T, anything else becomes T | null.

export interface ListRecentOrdersRow {
id: string // orders.id — NOT NULL, and not outer-joined
email: string | null // customers.email — NOT NULL, but LEFT JOINed
}

Over-declaring a null costs one impossible check. Under-declaring one costs a crash. When the analysis is uncertain, it picks the cheap mistake.

Parameters are the exception: they are declared non-null. Passing null is legal SQL, but it is almost always a bug rather than an intent, and | null on every parameter makes the generated types tiresome to use.