PG.L5.FK_NO_INDEX — Foreign key whose referencing column no index covers
- Category: performance
- Level: 5
- Confidence: deterministic
- Downtime class: none — nothing here is a schema change
- Stability: stable
- Suites: lint, audit
- Applies to: PostgreSQL 18
The half of a foreign key PostgreSQL does not index
Creating a foreign key gives you one index for free, and it is the wrong one. The referenced side is already unique — that is what makes it referenceable. The referencing side, the column in the child table, gets nothing.
That matters on every referential action. Deleting a customer, or changing the key a row is
referenced by, forces the server to answer "does any row still point at this?" — and with no
index on order_lines.order_id, the only way to answer it is to read all of order_lines. On a
table of any size that turns a one-row DELETE into a sequential scan, under a lock, at the exact
moment a deploy or a cascading cleanup is running.
PostgreSQL says so in its own manual, and still does not do it for you:
Since a
DELETEof a row from the referenced table or anUPDATEof a referenced column will require a scan of the referencing table for rows matching the old value, it is often a good idea to index the referencing columns too.
Flagged
Schema::table('order_lines', function (Blueprint $table) {
$table->foreignId('order_id')->constrained();
});
Preferred
Schema::table('order_lines', function (Blueprint $table) {
$table->foreignId('order_id')->constrained();
$table->index('order_id');
});
constrained() creates the key and no index. One line closes it.
Coverage is a left prefix, not a mention
A multicolumn B-tree is usable for a lookup that constrains its leading columns. So:
| Foreign key on | Index on | Covered? |
|---|---|---|
(order_id) | (order_id) | yes |
(order_id) | (order_id, created_at) | yes — the key is the left prefix |
(order_id) | (created_at, order_id) | no — the index leads with the wrong column |
(tenant_id, order_id) | (tenant_id) | no — the index is narrower than the key |
A check that asked merely whether the index mentions the columns would call rows three and four covered. That is the direction that matters: reporting a covered key is loud and gets corrected in a minute, while missing an uncovered one is silent and stays wrong for as long as the table lives.
Indexes that exist but do not count
A partial index (WHERE archived_at IS NULL), an expression index (lower(email)) and one
built with a non-default operator class are all real indexes and all appear in the table's index
list. None of them covers the lookup a referential action makes, because that lookup is over every
row, on the column's own value, under the default ordering.
SQLens therefore does not count them, and when they are the only indexes present the finding says so explicitly rather than reporting a bare "no index covers this". The two readings send you to different places: one means add an index, the other means the index you are looking at is not the one this needs.
In a lint run it answers about one case only
Against a live schema the catalog holds every index, so the answer is a fail or a pass. Against a migration it usually is neither: a migration cannot show the absence of an index, because the index may have been created three releases ago in a file the run never read. A fail there would be wrong on most healthy projects and a pass would be wrong on exactly the projects this rule exists for.
So the lint suite reports nothing at all about a foreign key added to a table that already
exists. Not a pass, not an undetermined — nothing. The undetermined is what the rule reported
at first and it was worse than silence: $table->foreignId(...)->constrained() is among the
commonest statements a Laravel migration contains, the message would be identical every time
because it says something about the lint suite's reach rather than about your migration, nothing in
it can be acted on where it is reported, and under strict_undetermined it would fail the gate of
every project that has foreign keys at all.
The one case a migration can settle is the one where it creates the table itself:
Schema::create('orders', function (Blueprint $table) {
$table->id();
});
Schema::create('order_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('orders'); // flagged
});
Nothing earlier can have indexed a table that did not exist yet, so nothing outside this run can
have indexed it — and no migration in the run indexes order_id. Add ->index() (or a
$table->index('order_id') line) and the finding goes away.
The index may arrive in a later migration. The run is the unit, not the file, and deliberately
so: you never edit a migration that has run in production, so the correct place to add a missing
foreign-key index — including one sqlens:audit reported to you — is a new migration. That file
is part of the same pending set, the run sees it, and the finding does not appear.
// 2026_09_06_000001_create_order_lines.php ← already deployed, never edited
Schema::create('order_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained('orders');
});
// 2026_09_20_000002_index_order_lines_order_id.php ← the fix, and the finding is gone
Schema::table('order_lines', function (Blueprint $table) {
$table->index('order_id');
});
What counts as an index here is a CREATE INDEX on that table, and the index PostgreSQL
builds for a UNIQUE or PRIMARY KEY constraint on it. So the textbook pivot draws no finding:
$table->unique(['team_id', 'user_id']) covers the key on team_id, exactly as sqlens:audit
reads it off the deployed schema. Swap the two columns and the finding appears, because a B-tree
only serves a lookup that constrains its leading columns.
Something it cannot read stops it. If the migration creates an index whose column list this
suite cannot read — a partial index, an operator class, an explicit method, a sort option — then it
no longer holds the table's complete index history, and it reports nothing about that table rather
than guessing. sqlens:audit still answers in full against your schema.
"Creates the table" means every table the statement names, the referenced one included. A foreign key names two tables, and which of them carries the key is not recoverable once the statement has been classified, so a migration that creates only one of the pair is left alone — and for the same reason a covering index on either of the two withholds the finding. That under-reports the new-child-against-an-existing-parent shape, and under-reporting is the direction this rule is allowed to be wrong in: the audit suite still answers it in full against your schema.
Why there is no MySQL twin
InnoDB creates the index itself, and not merely in the common case. Measured against a real MySQL
8.4.10 server: a composite key with no author-declared index gets one created; an index on (b, a)
does not satisfy a key on (a, b), so InnoDB creates its own — it left-prefix-matches exactly
as this rule does; an index on (a, x) does satisfy a key on (a), so nothing is added; a
prefix index s(10) does not count and a full index is created beside it; the resulting index
cannot be dropped afterwards (ERROR 1553); and foreign_key_checks=0 changes none of it.
A MY.L5.FK_NO_INDEX would therefore be a check that cannot fire — and a reader who saw it in the
rule list would believe SQLens verifies this on MySQL, when in fact the engine covers it and SQLens
verifies nothing. The absence is the honest answer, and a test holds it so it is not "repaired"
later.
Sources
- Constraints — foreign keys — PostgreSQL 18
- Multicolumn indexes — PostgreSQL 18
- Partial indexes — PostgreSQL 18