PG.L9.TYPE_IMPLICIT_CAST — A foreign key whose two ends are different types
- Category: idiom
- Level: 9
- Confidence: deterministic
- Downtime class: none
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL 18 and newer
What it checks
A foreign key whose referencing column and referenced column are not the same base type.
Reported:
Schema::create('accounts', function (Blueprint $table) {
$table->id(); // bigint
});
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->integer('account_id'); // int -- narrower than what it points at
$table->foreign('account_id')->references('id')->on('accounts');
});
Not reported:
Schema::create('accounts', function (Blueprint $table) {
$table->id(); // bigint
});
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('account_id')->constrained(); // bigint, derived from the target
});
The usual reason for this rule is FALSE on PostgreSQL
Almost every write-up of this says a mismatched pair still compares but stops using the index. Measured against PostgreSQL 18.4, over a real table with 50,000 rows and an index on the referencing column:
EXPLAIN SELECT * FROM invoices WHERE account_id = 42::bigint;
-> Index Scan using invoices_account_idx on invoices
Index Cond: (account_id = '42'::bigint)
The btree integer operator family carries cross-type operators, so the index is used. A rule built on the index argument would ship advice that is confident, specific and wrong — which this package treats as worse than no advice at all.
What is actually wrong is worse, and it is dated
An int column referencing a bigint key can only ever point at the first 2,147,483,647 of its
target's 9,223,372,036,854,775,807 values.
Nothing about the schema says so. Every test passes, because a test database never gets there. Then the parent sequence crosses that number — years after the migration — and every insert into the child fails with an out-of-range error naming a column nobody connects to a foreign key.
That is what the finding says, and why it names the number.
A declared length is not a mismatch
varchar(50) referencing varchar(100) is created by both engines, costs nothing, and is the most
common shape in a real schema. The comparison is the base type, so it is silent — a rule
comparing declared types would fire on almost every text key it saw.
text and varchar are the same thing here
PostgreSQL stores them identically — pg_type reports the same typlen, typalign and
typstorage for both — so a foreign key across them costs nothing and is silent.
bpchar (char(n)) is deliberately not in that group. It pads to its declared length, so
comparing it against either of the others changes answers as well as costing a conversion.
Audit only, and that is structural
A migration adding a foreign key carries the type of neither column. The types live in the
CREATE TABLE statements, and the referenced table was almost always created by a migration that is
no longer pending when this one runs. The catalog is the only place both ends exist at once.
A lint half would therefore answer undetermined for nearly every foreign key — noise rather than
honesty. What the rule does refuse to do is pass silently: a key whose far end could not be read is
reported as undetermined, never as agreement.
Suppressing it
Level 9 is the strictest band and is not reached unless a project asks for it. Within it, the
ordinary routes apply: a baseline entry, an ignore rule in config/sqlens.php, or lowering the
level below 9.
Sources
- PostgreSQL 18 — Numeric types — the
ranges of
integerandbigint, which is the whole finding - PostgreSQL 18 — Character types —
textandvarchardiffer only in the length constraint, which is why they are equivalent here