PG.L5.FK_NULLABLE_IN_UNIQUE — A unique key that does not hold, because one of its foreign keys may be NULL
- Category: idiom
- Level: 5
- Confidence: deterministic
- Downtime class: none
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL 18 and newer
What actually happens
A unique key over (team_id, member_id) reads as one row per pair. Under PostgreSQL's default
NULL treatment it is not that.
Measured on 18.4: three rows sharing the same team_id and carrying a NULL in member_id all
insert successfully. The key is enforced for every row that fills both columns, and for none of the
rows that do not — which are exactly the rows somebody would want it to catch.
Nothing in the schema shows the hole. A reader sees UNIQUE, and UNIQUE is one of the few words
in SQL people believe without checking.
Flagged
Schema::create('team_member', function (Blueprint $table) {
$table->foreignId('team_id')->constrained();
$table->foreignId('member_id')->nullable()->constrained('users');
$table->unique(['team_id', 'member_id']);
});
Preferred
Schema::create('team_member', function (Blueprint $table) {
$table->foreignId('team_id')->constrained();
$table->foreignId('member_id')->constrained('users');
$table->unique(['team_id', 'member_id']);
});
If the column genuinely has to stay optional, keep it and close the hole instead:
CREATE UNIQUE INDEX team_member_uq ON team_member (team_id, member_id) NULLS NOT DISTINCT;
NULLS NOT DISTINCT arrived in PostgreSQL 15, and Laravel exposes it as ->nullsNotDistinct(). A
key declared that way holds across the NULL rows, so this rule says nothing about it.
Why the cut is this narrow
"A nullable foreign key" on its own is the most false-positive-prone thing this package could report. An optional reference is ordinary, correct, and everywhere. Three narrowings apply, and each one is a deliberate decision to stay silent about real cases:
The column must sit inside a unique key. Outside one, a nullable foreign key constrains nothing and claims nothing — there is no contradiction to report.
The key must have at least two columns. A single-column nullable unique key is the
optional-one-to-one idiom — profile_id UNIQUE NULL, superseded_by_id UNIQUE NULL — and it works
precisely because NULLs are distinct. Reporting it would be reporting the pattern for being
itself.
Every member must itself be a foreign-key column. This is the narrowing that makes the rule
shippable. A key mixing a foreign key with a payload column — (user_id, slug) — is the
application's own uniqueness rule, and whether a NULL belongs in it is a domain question this rule
has no standing to answer. A key made entirely of foreign keys is a relationship, and a
relationship with a missing side is not one.
What is not reported
A nullable column inside a primary key. Measured: PostgreSQL refuses it outright —
ERROR: column "a" is in a primary key
— and MySQL rewrites it silently. The state is unreachable from a live catalog, so a check for it would be code that cannot execute.
When the NULL treatment could not be read
Answered undetermined, with the reason unique_null_treatment_unknown — never pass and never
fail. The two shapes are identical in every other respect, so reporting would punish the schema
that already applied the fix (the most expensive false positive a rule can produce), and passing
would claim a constraint nobody verified.
A note on the category
Filed as idiom. There is a reasonable argument for safety: what it reports is a constraint that
does not constrain, which is closer to a data-integrity defect than to a matter of appetite. It stays
idiom because the finding is about a schema shape somebody may have chosen deliberately, so the
category decides which band switches it off rather than whether the finding is true.
Sources
- PostgreSQL 18: unique indexes — unique indexes treat NULLs as distinct by default, so several rows carrying a NULL in a key column can coexist.
- PostgreSQL 18:
CREATE INDEX—NULLS NOT DISTINCTreverses that;pg_index.indnullsnotdistinctreports which mode is in force, which is the flag this rule reads before it dares conclude.