PG.L5.MONEY_TYPE — The type called money is not the one you want
- Category: safety
- Level: 5
- Confidence: deterministic — the type is the finding, with no name heuristic at all
- Downtime class: none
- Stability: stable
- Suites: audit
- Applies to: PostgreSQL 18 and newer
Three problems, and the first one is silent
Its input and output depend on a server setting. money is parsed and rendered through
lc_monetary. A dump taken where the locale writes 1.234,56 and restored where it writes
1,234.56 does not fail — it reads the same characters as a different number. No error, no warning,
just a different amount. The only evidence is the amount itself.
It carries no currency. The name promises otherwise, and a money column called price reads as
though it knows what currency it is in. It does not: the currency lives in a locale setting that is a
property of the server, not of the row. Two applications on one instance cannot disagree about
it.
Its scale is fixed by that same setting. How many fractional digits survive is not a property of the column, so two servers can disagree about how much of an amount to keep.
Flagged
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->addColumn('money', 'invoice_total');
});
Preferred
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->decimal('invoice_total', 19, 4);
$table->char('currency', 3);
});
The currency goes in a column of its own, where it is a property of the row and travels with it.
Why this is not folded into the float rule
PG.L5.FLOAT_MONEY answers a different question. It needs a name
heuristic, because nothing in a catalog says a column holds money — here the type is the whole
finding, with no guessing at all, which is why this rule's confidence is deterministic and that
one's is heuristic.
They also fail differently: a float loses cents to arithmetic, money changes meaning when the
server does. A project may reasonably accept one and not the other, and separate ids are what makes
that possible — under a single id, an ignore entry for one would silence the other.
The recommendation is nonetheless the same for both, and saying so plainly is more useful than pretending two roads lead to two places.
An array is covered; a domain is not
Both measured on 18.4, because the answer is not obvious in either direction.
money[] is reported. The catalog spells it money[], and the canonicalizer strips the array
marker from the name while the raw spelling keeps it — so the column arrives at the rule as money.
An array of amounts carries exactly the same locale problem, and a silent miss there would have
looked like a clean table.
A domain over money is not, and cannot be. CREATE DOMAIN eur AS money produces columns whose
type the catalog reports as eur. Nothing in the rule can see the money underneath — and there is
no undetermined to emit either, because the rule has no way to tell a domain from an ordinary user
type it correctly has nothing to say about. The limit is stated here, and a live test asserts it, so
it stays a documented boundary rather than a surprise.
What is not flagged
Only money columns are reported, and every other type is somebody else's business or nobody's. A
legacy schema whose reporting genuinely depends on the server locale is the one real reason to keep
the type — record that on the ignore list, where the decision stays visible and the finding stays
counted:
'audit' => [
'ignore' => [
'pairs' => [
['rule' => 'PG.L5.MONEY_TYPE', 'objects' => ['public.legacy_ledger']],
],
],
],
Changing the type rewrites the table
Converting to numeric(19, 4) rewrites every row. 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: the money type — output
depends on
lc_monetaryand input is interpreted under that same setting, so the value is locale-dependent; the documentation recommendsnumericwhere that dependency is unwanted, and notes the fractional precision is determined by the locale.