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
- PostgreSQL 18 — Lexical structure
— the limit is
NAMEDATALEN-1= 63 bytes, and longer names are truncated rather than rejected - Laravel 13 — Migrations — the schema builder derives an index name from the table and its columns when none is passed
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.