Skip to main content

PG.L5.MORPHS_NO_INDEX — Polymorphic column pair with no index leading on its type

  • Category: performance
  • Level: 5
  • Confidence: deterministic
  • Downtime class: none — nothing here is a schema change
  • Stability: stable
  • Suites: audit
  • Applies to: PostgreSQL 18

The id alone names nothing

A polymorphic relation is two columns and one meaning. commentable_id = 7 does not identify a row: there is a post 7, an invoice 7 and a user 7, and only commentable_type says which one is meant. So every access through the relation constrains both columns, always, without exception.

With no index over the pair, each of those reads the whole table. The cost grows with the table rather than with the number of relations, which is why the pattern is comfortable for a year and then is not.

Nothing in the schema hints at it. A foreign key at least announces the relationship it belongs to, and the server can be asked about it. A polymorphic pair is two ordinary columns whose meaning lives entirely in the application.

Flagged

Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->string('commentable_type');
$table->unsignedBigInteger('commentable_id');
});

Preferred

Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->morphs('commentable');
});

morphs() writes both columns and the index. The flagged shape is what hand-written columns produce, and what is left behind when a later migration replaces a nullableMorphs() with its two parts.

The type column has to come first

A B-tree serves a left prefix, so the order decides which questions the index can answer:

Index onAnswers the paired lookup?Also answers
(commentable_type, commentable_id)yesa type-only lookup, which happens
(commentable_id, commentable_type)yesan id-only lookup, which does not
(commentable_type)no — narrower than the pair
(commentable_type, commentable_id, created_at)yes — the pair is the left prefix

Both of the first two rows serve the relation, and only one of them is the index morphs() writes. The type column leads because it is the low-cardinality half and the half the resolver fixes first; leading with the id spends the index's first level on a column that never appears alone.

That is why an index the wrong way round is reported as its own sentence — replace this index rather than add one. Told merely that no index exists, a reader adds a second one over the same two columns, and PG.L7.INDEX_REDUNDANT then reports that.

Indexes that exist but do not count

Only the indexes the reading judged comparable are counted: a partial index, an expression index and one with a non-default operator class are all real indexes that serve no such lookup, and an index left behind invalid by a canceled CREATE INDEX CONCURRENTLY is ignored by the planner. A unique index does count — PostgreSQL implements it with a B-tree over exactly its columns — and so does a wider index whose leading columns are the pair.

False positives, and the one this rule cannot rule out

The pair is identified by naming: a <prefix>_type column holding characters beside a <prefix>_id column holding an integer or a uuid. The catalog can prove those columns exist and what they hold. It cannot prove that morphs() wrote them, and nothing in a database can.

So a pair that merely happens to be named this way — a document_type enum beside an unrelated document_id, say — is a false positive by construction. Put it on the ignore list; there is no reading of the catalog that would tell the two apart, and a rule that guessed would be silently wrong in both directions.

The id side deliberately does not accept varchar or text. Laravel writes neither for a morph id, and accepting them would report every pair whose id is a slug.

Why there is no lint half

The lint suite would have to ask what type each column a migration creates holds, and a CREATE TABLE statement carries none: the classifier exposes a statement's kind, its targets and an index's key columns, and nothing else. Reading the definitions back out of the SQL text with a pattern is the one shortcut available, and it is the shortcut that reports a pair nobody wrote or misses one somebody did — invisibly, from the finding's point of view.

The catalog is where both columns and every index exist at once, so that is where the question is a fact rather than an inference.

Sources

  • PostgreSQL 18: multicolumn indexes — a multicolumn B-tree is usable for a query constraining its leading columns, and constraining only a later column leaves it no better than a scan. That is the whole left-prefix judgment.
  • PostgreSQL 18: partial indexes — why a partial index is not counted as coverage: it indexes only the rows its predicate admits, so it cannot answer the lookup a relation makes over all of them. The same reasoning excludes an expression index and one with a non-default operator class.
  • Laravel: polymorphic relationships — why both columns are always constrained together: the key is unique only within one type, so the relation cannot resolve a row from the id alone.