PG.L5.FLOAT_MONEY — Money in a floating-point column
- Category: safety
- Level: 5
- Confidence: deterministic — the trigger is the column NAME, and the finding carries that caveat in its own text rather than in this field
- Downtime class: none
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL 18 and newer
What goes wrong, and why nobody notices at the time
A binary float cannot represent 0.10 exactly. Add it ten times and the total is not 1.00 — it is
near enough that every screen shows 1.00 and every comparison against 1.00 fails.
The error arrives as one cent on a statement, months after the schema decision, and whoever finds it has no route back to the column that caused it. That is the whole argument: the failure is small, late, and untraceable.
numeric(19, 4) stores the digits, so the arithmetic is the arithmetic of the invoice.
Flagged
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->double('invoice_total');
});
Preferred
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->decimal('invoice_total', 19, 4);
});
Storing minor units in an integer — price_cents bigint — is the other correct answer, and is never
reported.
This is a heuristic, and the finding says so
Nothing in a catalog says a column holds money. The only available signal is the name, which is a guess about intent — so the finding admits to being a guess rather than presenting itself as a fact.
The dictionary that backs it splits terms into two confidence levels, and the split is the point:
- strong —
price,invoice_total,unit_price,rechnungsbetrag,nettopreis. Money on its own. - weak —
rate,value,total,balance,wert,summe. Ordinary language that is sometimes money.rateis an interest rate and a frame rate;valuenames a hundred things. A rule that fired on those would be noise, and noise is how a team learns to skip a whole level band.
Matching is on whole underscore-separated segments, never substrings: rating is not rate, and
pricing is not price.
What a weak term needs before anything is reported
Exactly two second signals count, and both are structural facts about the table rather than further guesses about the domain:
- A sibling currency column (
currency,currency_code,waehrung). A table that stores a currency stores money in it — the strongest evidence a catalog can offer, because it is somebody else's explicit statement. - A
centssegment in the name.balance_centsis a minor-unit amount whateverbalancemeans elsewhere.
A suffix of _amount is deliberately not a second signal, though it looks like one: amount is
itself a weak term, so accepting it would let a weak term vouch for itself and the confidence split
would stop meaning anything.
Tuning it for your schema
Both directions, without forking the package:
'audit' => [
'money_columns' => [
// Added terms count as STRONG — you have stated the intent the dictionary can only guess at.
'extra' => ['settlement', 'payout'],
// Removed from BOTH confidence levels, so an ignore keeps working if a term is reclassified.
'ignore' => ['rate'],
],
],
Changing the type rewrites the table
Converting to numeric(19, 4) is not a metadata change — every row is rewritten. The lint suite classifies that
operation and carries its downtime class; this audit finding carries none, because an observation
about a state has no duration.
Sources
- PostgreSQL 18: numeric types —
numericstores an exact decimal and is documented as the choice where exactness matters, monetary amounts named outright;realanddouble precisionare inexact binary floats whose stored value may differ from the one written.
The fix material this rule carries
A finding from this rule carries a payload whose strategy is none: this rule has looked,
and there is no safe standard sequence. That is a conclusion rather than an omission — a
finding with no payload at all says only that nobody wrote one.
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.