Skip to main content

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 INDEXNULLS NOT DISTINCT reverses that; pg_index.indnullsnotdistinct reports which mode is in force, which is the flag this rule reads before it dares conclude.