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.