Skip to main content

PG.L4.IDENTIFIER_LENGTH — An identifier over PostgreSQL's limit

  • Category: safety
  • Level: 4
  • Confidence: deterministic
  • Downtime class: online
  • Stability: stable
  • Suites: lint
  • Applies to: PostgreSQL 18 and newer

What it checks

Any identifier a migration statement names — table, column, index, constraint — longer than 63 bytes.

Reported:

Schema::create('subscription_billing_adjustments', function (Blueprint $table) {
$table->string('customer_reference_number');
$table->timestamp('effective_from_timestamp');
$table->index(['customer_reference_number', 'effective_from_timestamp']);
});

Not reported:

Schema::create('subscription_billing_adjustments', function (Blueprint $table) {
$table->string('customer_reference_number');
$table->timestamp('effective_from_timestamp');
$table->index(
['customer_reference_number', 'effective_from_timestamp'],
'sba_customer_effective_idx',
);
});

Nothing in that example is long

That is the point, and it is why the rule earns its place.

The table is 32 characters. The columns are 25 and 24. Every name a person typed is comfortably inside the limit — and the index name Laravel derives from all three is 89:

subscription_billing_adjustments_customer_reference_number_effective_from_timestamp_index

Laravel builds an index name from the table plus every column plus a suffix whenever you do not pass one. So the overflow arrives without anyone writing an over-long name, which is exactly the class of defect that survives review: there is nothing on the page to notice.

The unit is BYTES, and that is not a detail

PostgreSQL's limit is NAMEDATALEN - 1 = 63 bytes. Thirty-two two-byte characters are thirty-two characters and sixty-four bytes, so a rule counting characters would pass exactly the identifier the server is about to rename. Measured against 18.4:

NOTICE: identifier "aaaa…" (64 chars) will be truncated to "aaa…" (63)
NOTICE: identifier "<32 two-byte chars>" (64 bytes) will be truncated to 31 characters
CREATE TABLE

The statement SUCCEEDS, and that is the whole danger

Read that last line again. PostgreSQL does not refuse the name — it shortens it and carries on.

The object then exists under a name nobody wrote. The NOTICE goes to a channel no migration runner surfaces. And every later reference by the written name fails — a DROP INDEX, a RENAME, a query that names it — long after the deploy that caused it, with nothing connecting the two.

Its MySQL sibling MY.L4.IDENTIFIER_LENGTH reports the same shape for the opposite reason: there the server refuses the statement outright. The two rules cannot share a measurement, because the two engines do not measure the same thing.

The fix is an argument

Pass a name. Laravel derives one only when none is given, so a second argument takes the length out of the schema builder's hands entirely — and a name you chose can say what the index is for, which the derived one never did.

Then check the databases this migration has already run against — a development machine, a review app, staging. PostgreSQL did not refuse the long name, it truncated it, so those databases hold the object under the shortened one. Renaming it in the migration does not rename it there: the next deploy creates a second object beside the first. Drop or rename the truncated one before this migration reaches them.

The 63 is a compile-time constant, and the rule assumes the default

NAMEDATALEN is fixed when PostgreSQL is built, so a server compiled with a larger one accepts longer names and this rule would report a finding that is not true on that instance. Every packaged build — the distribution ones, the official images, Laravel Cloud's serverless PostgreSQL — ships the default 64, giving the 63 here.

The rule carries no min_version for it, and that is correct rather than an omission: a min_version says "before this server version the rule does not apply", and the limit applies to every version this package supports. A recompiled NAMEDATALEN is not a version, it is a build, and nothing in a migration or a connection string reveals it.

So this is a stated limit rather than a hidden one. If your server is built with a different NAMEDATALEN, suppress the rule — do not expect it to detect that for you.

What the rule cannot see

A name assembled at runtime by code the migration calls. The rule reads the statement the schema builder compiled, so anything built after that point is outside its reach. That is a limit of where the rule stands, not a case it decided to skip.

Why lint only, with no audit half

An over-long identifier never reaches the catalog. PostgreSQL truncated it on the way in — so by the time an audit reads the schema, there is nothing over the limit left to find.

An audit half here would be a check that cannot fail, and this package treats that as worse than an absent one: it reports a clean result over a question it never asked. The migration is the only place the written name still exists.

Suppressing it

The ordinary routes apply: a baseline entry, an ignore rule in config/sqlens.php, or lowering the level below 4. There is nothing to configure — the limit is the engine's, not a preference.

Sources

The fix material this rule carries

A finding from this rule carries machine-readable fix material, using this sequence:

  • explicit_identifier — Give the object a name of your own, short enough for the engine — the fix for an identifier that goes over the limit.

The payload is material for you or an agent to apply. SQLens writes no migration and runs no DDL. See the remediation payload for every field, the placeholder semantics, and the version rules a consumer has to follow.