A finance assistant summarized a quarter: "Revenue grew to $1.24M, up 12% from $1.1M." The underlying figures were €1.24M and €1.10M. The model had translated the currency symbol, not the amount, and it had computed the percentage from rounded values, so the real growth was 11.6 percent. Every number in the answer was plausible. Two were wrong in ways a reader would never catch.
Language models are excellent at producing text that looks like a financial report and poor at being a calculator. They tokenize numbers as text, have no dimensional awareness, and are trained to be helpful rather than to refuse. Ask for a conversion and you'll usually get a confident answer with the right shape and an occasional factor-of-100 error.
The fix isn't a better prompt. It's an architectural rule: the model may select and describe values, but it must not produce or transform them. Numbers, currencies, and units come from typed data, are computed by code, and are formatted by a locale-aware formatter. The model's job is the sentence around them.
Why numbers break in text models
Three properties of LLMs make arithmetic unreliable in ways that are hard to predict.
Tokenization. Numbers split into tokens that don't align with digits. Models see patterns, not place value. This is why 17 × 23 can come out wrong while a paragraph about multiplication is flawless.
No units, only associations. "500" is just a token sequence. Nothing in the representation says cents or dollars, kilograms or pounds. When a prompt says "the invoice total is 125000," the model has to infer the unit from context, and it will infer confidently.
Fluency pressure. A model that says "I can't compute that" feels less helpful than one that produces a number. Training rewards the confident answer. The failure is not random; it's biased toward plausible output.
There's also a subtler problem: even when the arithmetic is right, the representation is often wrong. "1,234" means one thousand two hundred thirty-four in the US and one point two three four in Germany. "$1,200" could be dollars, pesos, or an ambiguous local symbol. "5'10"" is five feet ten inches, but the apostrophe may render as a quote. Precision in the value doesn't help if the format is misread.
The failure classes are worth naming, because each has a different control:
| Failure | Typical shape | What catches it |
|---|---|---|
| Wrong magnitude | Cents reported as dollars, 100x error | Unit assertion against source |
| Wrong currency | Same numeric value, wrong ISO code | Schema requires an explicit code |
| Silent coercion | USD and EUR added together | Currency check on arithmetic |
| Decimal drift | 0.1 + 0.2 != 0.3 in floats |
Decimal or integer minor units |
| Truncated precision | KWD rounded to two places | Exponent from ISO 4217 table |
| Dimensional error | kW reported where kWh is meant | Units library with dimensions |
| Rounding artifact | Total differs from sum of lines | Explicit rounding policy |
| Locale misread | 1.234 parsed as 1.234 vs 1234 |
Locale-aware parser |
Most teams fix the first two with a prompt and leave the rest. The prompt fix works until the day a Kuwaiti customer, a European number format, and a temperature conversion arrive in the same support queue.
Make the model return typed values, never formatted strings
The contract change that prevents most of these bugs: the model's structured output carries an exact decimal string and an ISO code. No symbols, no thousands separators, no implicit units.
{
"amount": "1234.50",
"currency": "EUR",
"as_of": "2026-10-21",
"source": "invoice:INV-41882"
}
Four rules make this work.
Amounts are strings, not JSON numbers. A JSON number goes through a float in most parsers. 0.1 + 0.2 is not 0.3 in binary floating point, and a value like 12345678901234567.89 loses precision before your code sees it. Strings survive the round trip; parse them into Decimal immediately.
Currency is a code, never a symbol. $ is used by at least a dozen currencies. € is unambiguous today but still doesn't tell you the accounting unit. ISO 4217 codes are the only safe identifier.
Units are explicit and machine-readable. "unit": "kg" or "unit": "lb", not "weight in kilos." For compound units, use a parseable syntax (m/s, kg*m/s^2) or a structured object.
The model cites the source field for each value. Not "I calculated this" but "invoice line 3." If it can't cite, it can't state a number.
Then validate the schema before rendering. A Decimal that fails to parse, a currency outside your allowlist, or a unit outside your dimension registry is a hard failure, not something to coerce.
Minor units and the currencies that don't divide by 100
The single most common currency bug is assuming two decimal places. ISO 4217 defines a minor unit exponent per currency, and it isn't always 2.
| Currency | Exponent | Minor unit | Note |
|---|---|---|---|
| USD, EUR, GBP | 2 | cent | The common case |
| JPY, KRW, VND, ISK | 0 | none | Whole units only |
| KWD, BHD, JOD, OMR, TND | 3 | fils, dirham | Three decimals |
| CLF, UYW | 4 | — | Index units, four decimals |
If your database stores numeric(12,2) for every currency, you are silently truncating Kuwaiti dinar. If your code multiplies by 100 to get minor units, JPY amounts become 100x too large.
The robust representation is integer minor units plus the exponent, with the exponent loaded from a maintained table rather than hard-coded:
from decimal import Decimal, ROUND_HALF_EVEN
from dataclasses import dataclass
EXPONENTS = {
"USD": 2, "EUR": 2, "GBP": 2, "CHF": 2,
"JPY": 0, "KRW": 0, "VND": 0, "ISK": 0,
"KWD": 3, "BHD": 3, "JOD": 3, "OMR": 3, "TND": 3,
"CLF": 4, "UYW": 4,
}
@dataclass(frozen=True)
class Money:
minor: int
currency: str
def __post_init__(self):
if self.currency not in EXPONENTS:
raise ValueError(f"unsupported currency {self.currency}")
@property
def exponent(self) -> int:
return EXPONENTS[self.currency]
@classmethod
def parse(cls, amount: str, currency: str) -> "Money":
exponent = EXPONENTS[currency]
value = Decimal(amount)
quantum = Decimal(1).scaleb(-exponent)
rounded = value.quantize(quantum, rounding=ROUND_HALF_EVEN)
return cls(int(rounded.scaleb(exponent)), currency)
def add(self, other: "Money") -> "Money":
if other.currency != self.currency:
raise ValueError("currency mismatch")
return Money(self.minor + other.minor, self.currency)
def format(self, locale: str) -> str:
value = Decimal(self.minor).scaleb(-self.exponent)
return format_money(value, self.currency, locale)
Two details matter more than they look. ROUND_HALF_EVEN (banker's rounding) is the default in many financial systems and in Python's decimal context; using ROUND_HALF_UP because it feels intuitive introduces systematic bias across many transactions. And scaleb with the exponent keeps you in decimal space, never float.
The add method's currency check is the other half of the defense. Mixed-currency arithmetic should raise, not silently coerce. Any conversion must be explicit, with a rate and a date.
🚨 Production warning: Never store money as a float, and never accept a float from an LLM or an API without converting through a string. The bug appears as a one-cent discrepancy that nobody notices until reconciliation.
Rounding is a policy, not a detail
Ask three engineers how to round and you'll get three answers, each defensible in a different context. Write down which one applies where.
Half-up is what most people expect for display: 2.5 becomes 3. It's also the source of upward bias when applied to many values.
Half-even avoids that bias and is standard in accounting contexts. Python's decimal default is ROUND_HALF_EVEN; so is IEEE 754's default rounding mode for binary floats, which surprises people who expect half-up.
Truncation toward zero is common in tax calculations in some jurisdictions and wrong almost everywhere else.
The harder question is where rounding happens:
- Round at the line item, then sum. The total may differ from the unrounded sum by a few cents. This matches how many invoices are legally required to present values.
- Sum then round once. More accurate arithmetically, but the line items may not add up to the displayed total, which generates support tickets.
For invoices, tax, and anything a customer will add up by hand, round per line and compute the total from rounded lines. For analytics and internal reporting, keep full precision through the calculation and round only at presentation. Document which policy applies; a mixed policy produces reports that don't reconcile.
Percentages deserve their own rule. If the model says "up 12%," the number should be computed as (new - old) / old in Decimal, then rounded once at display. Computing it from already-rounded display values (1.24 and 1.10) gives 12.7 percent, not 11.6 — the difference between a rounding artifact and a wrong claim.
Locale formatting is not cosmetic
Intl.NumberFormat in JavaScript handles the common cases correctly, including grouping separators and currency placement:
const fmt = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
});
fmt.format(1234.5); // "1.234,50 €"
In Python, Babel's format_currency does the same, and parse_decimal handles the inverse for user input:
from babel.numbers import format_currency, parse_decimal
format_currency(1234.5, "EUR", locale="de_DE") # '1.234,50 €'
parse_decimal("1.234,56", locale="de_DE") # Decimal('1234.56')
parse_decimal("1,234.56", locale="en_US") # Decimal('1234.56')
Four traps that libraries don't solve for you:
Symbol ambiguity in input. A user typing "$1,200" into a multi-currency app may mean USD, CAD, AUD, or MXN. Never infer; ask or use the account's default currency with a visible label.
Grouping that isn't three digits. en-IN groups as 12,34,567, and some locales use spaces or apostrophes. Don't regex-parse numbers; use a locale-aware parser.
Symbol placement and spacing. French formats often place the symbol after the amount with a non-breaking space. Hard-coded templates produce "€ 1 234,50" in one place and "€1,234.50" in another. Consistency matters more than which convention you pick, but pick one per locale.
Digits. Arabic, Persian, and several Indic locales have native digit shapes. Intl.NumberFormat with a locale uses them by default; if your design or font can't render them, you need nu-latn explicitly rather than discovering it in production.
A fifth, sneakier trap: the currency's display precision in CLDR doesn't always equal the ISO 4217 exponent. Some currencies are commonly displayed with fewer digits than the standard allows (or more, for accounting). Intl.NumberFormat follows CLDR's currencyData, which is usually but not always what you want. For accounting output, set minimumFractionDigits and maximumFractionDigits explicitly from your own policy.
Unit conversion needs dimensions, not lookup factors
Currency has codes and exponents. Physical units have something harder: dimensionality. Converting feet to metres is a factor; converting watts to kilowatt-hours is not, because they measure different things (power versus energy) and the conversion requires time.
The failure mode in AI answers is usually dimensional, not arithmetic. A model asked "how much energy does a 2 kW heater use in 3 hours" may answer "6 kW" because the numbers divide nicely. That's a unit error that no amount of decimal precision fixes.
Use a units library with dimensional analysis. In Python, Pint is the standard:
import pint
ureg = pint.UnitRegistry()
Q_ = ureg.Quantity
power = Q_(2, "kW")
duration = Q_(3, "hour")
energy = (power * duration).to("kWh")
print(energy) # 6 kilowatt_hour
distance = Q_(3, "ft") + Q_(4, "in")
print(distance.to("m")) # 1.0668 meter
try:
Q_(5, "kg") + Q_(2, "m")
except pint.DimensionalityError as exc:
print("dimension mismatch:", exc)
Pint raises on incompatible dimensions, which is exactly the behavior you want at the boundary. Convert at ingestion, store in a canonical unit, and convert again only for display.
Three unit-specific hazards worth knowing:
Offset units. Celsius and Fahrenheit have offsets; a temperature difference of 10 °C equals 18 °F, not 50 °F. Pint distinguishes degC from delta_degC for this reason. Any answer involving temperature changes needs the delta unit, and most generated answers get it wrong.
Same name, different size. A US gallon is 3.785 L; an imperial gallon is 4.546 L. A US fluid ounce is 29.57 mL; an imperial one is 28.41 mL. A "ton" is 2,000 lb in the US and 2,240 lb in the UK; a tonne is 1,000 kg. A calorie is 4.184 J; a food Calorie is a kilocalorie. None of these is exotic — they appear in ordinary product and nutrition data.
Decimal versus binary prefixes. A MB is 10^6 bytes under SI; a MiB is 2^20. Storage and network vendors mix them constantly. Pick IEC prefixes for memory and SI for bandwidth, and say which you mean.
Fuel and agriculture. A barrel of oil is 42 US gallons, not 55. An acre is 4,046.86 m². A bushel differs by commodity. If your domain touches these, the conversion table is domain data that needs an owner, not a constant in a prompt.
Exchange rates are time series, not numbers
If an answer involves more than one currency, the rate is a fact with a timestamp, a direction, and a source.
Store rates as rows: base, quote, rate, as_of, source, fetched_at. Convert with Decimal, and record which rate row was used in the answer's provenance. Then:
Never convert twice. USD → EUR → GBP using two independently rounded rates accumulates error and can disagree with the direct rate. Pick a base currency and convert once.
Never use today's rate for a historical amount. "What did we spend in euros last quarter" requires the rates at transaction time, or an explicit statement that you're using a current rate for comparison.
State the direction. "1 EUR = 1.08 USD" and "1 USD = 0.926 EUR" are the same fact, and mixing them is a factor-of-1.17 error. Store the pair explicitly and format with the rate shown.
Don't let the model choose the rate. It will produce something close to a rate it saw in training data, which may be years old. Rates come from a feed; the model gets the converted value and the date.
For display, show the rate and the date when the amount is material: "€1,240 (converted at 1.08 USD/EUR on 21 Oct 2026)." That single parenthetical prevents most "why is this number different from my bank" conversations.
Dates, durations, and time zones are units too
The same discipline applies to time, and it fails more often because teams treat it as a formatting concern rather than a measurement.
A duration without a unit is meaningless: "the job took 90" could be seconds, minutes, or milliseconds. A timestamp without a zone is ambiguous for one hour every autumn in every DST-observing region, and Postgres will happily accept a timestamp without time zone that means something different to the application and the reporting tool.
Three rules cover most of it:
Store instants in UTC, with an explicit type. timestamptz in Postgres, TIMESTAMP WITH TIME ZONE semantics in your ORM, ISO 8601 with offset at the API boundary. The type is the documentation.
Store the user's zone separately when it matters. "9 a.m. local time" is a rule, not an instant. If you store only the UTC instant, a user who moves time zones finds their reminder shifted.
Make durations typed. duration_ms: 90000 beats duration: "1.5 minutes". The model can describe it; code should carry it.
Then there's the arithmetic models get wrong most often: date differences across DST, "three business days from Friday," and month arithmetic. 2026-01-31 plus one month is undefined without a policy — libraries differ on whether it's February 28 or March 3. Put the policy in code, and let the model state the result rather than compute it.
Percentages have a parallel trap. A change from 10 percent to 12 percent is 2 percentage points, not 2 percent. A model that writes "up 2%" when it means percentage points is wrong by a factor of six in relative terms. Force the distinction in the output schema: {"delta_relative": "0.20", "delta_points": "2.0"}.
If your answer includes a relative change, state the base: "up 20% from 10% to 12%" removes the ambiguity entirely, at the cost of four extra words.
Where the model is genuinely useful
None of this means the model has no role in numeric work. It's good at the part that's actually language: turning messy human input into typed values, and turning typed values into a readable sentence.
Parsing. "Split the 1200 euro invoice three ways, but Sam already paid 200" is a sentence a rules engine handles badly and a model handles well — if the output is a schema and every field is validated. The model extracts total: "1200.00", currency: "EUR", parts: 3, already_paid: "200.00", and code does the division.
Normalizing units from prose. "About five and a half feet" becomes {"value": "5.5", "unit": "ft"}. Validate the unit against a registry, reject anything unknown, and convert in code.
Explaining a computed result. Given {"before": "1100000", "after": "1240000", "currency": "EUR"}, the model can write "Revenue grew 12.7% to €1.24M." The computation happened in code; the model chose the words.
Flagging suspicious values. A model can notice that a 400 percent margin or a negative latency is odd. Treat that as a signal for review, not as a correction — it should raise a flag, not silently rewrite the number.
The boundary is consistent: extraction in, formatting out, computation never. That boundary is enforceable with a schema and a validator, and it survives model upgrades because it doesn't depend on the model's arithmetic.
Verify numerically before you render
The last line of defense is a check that recomputes what the answer claims. You don't need a model for this; you need the source data and a few assertions.
A numeric linter for generated answers can check:
- Every number in the prose appears in the validated value set. Extract numerals from the rendered text, normalize, and match against the computed values. A number that isn't in the set is either a model invention or a formatting mismatch — both need attention.
- Totals equal the sum of their components under the stated rounding policy.
- Percentages recompute from the underlying values, not from rounded displays.
- Units are dimensionally consistent with the quantity being described. Revenue is currency, latency is time, throughput is per-time.
- Currency codes match the source records. A USD invoice cannot produce a EUR total without a recorded conversion.
- Magnitudes are sane. A monthly cloud bill of $12,000,000 or a latency of 4,000 seconds should trip a bound and route to review, not render.
The first check catches the most failures and is the easiest to implement. It won't catch a subtle error where the model restates a correct number with the wrong label ("12% of customers" when the value is 12 customers), so pair it with a spot-check of the noun attached to each figure.
💡 Practical note: Log every numeric validation failure with the prompt version, model, and the offending span. A cluster of failures on one template usually means the prompt is asking for a derived value the model should never compute.
When approximation is honest
Not every answer needs four decimal places. "About 6 kWh," "roughly $1.2M," "around 40 percent" are appropriate when the decision doesn't hinge on precision, and false precision is its own kind of error.
The rule: approximate the presentation, never the computation. Round for readability at the end, from an exact value, and mark it as approximate. Don't let the model round mid-calculation and then present the result as exact.
There's also a category where an order-of-magnitude answer is the correct answer: capacity planning, rough budgets, sanity checks. In those cases, say "order of magnitude" and give the assumption. "Roughly 6 kWh, assuming the heater runs at full power for all three hours" is more useful than a precise number with an unstated assumption.
What you should never do is hedge a number you could have computed exactly. "Approximately $1,234.56" reads as uncertainty about a value that came from a ledger. Compute exactly, present clearly, and reserve hedging for genuine estimates.
A checklist for numeric answers
Input
- Is every amount a decimal string with an ISO 4217 code?
- Is every physical quantity paired with a parseable unit?
- Are exchange rates stored with pair, timestamp, and source?
Computation
- Does all arithmetic happen in
Decimalor integer minor units, never float? - Are mixed currencies rejected rather than coerced?
- Are dimensions checked by a units library, not assumed?
- Is the rounding policy explicit and applied once?
Presentation
- Is formatting locale-aware, with an explicit currency label?
- Are symbols never used as the sole currency identifier?
- Is the rate and date shown when a conversion is material?
- Are approximate values marked as approximate?
Verification
- Does every numeral in the prose map to a validated value?
- Do totals reconcile under the stated rounding policy?
- Do magnitudes pass sanity bounds?
The pattern behind all of it is unglamorous: the model writes the sentence, the code owns the number. Teams that adopt it stop seeing factor-of-100 errors, stop arguing about rounding in code review, and start catching the real problems — like a source system that recorded a currency wrong three months ago. That bug was always there. The difference is that now the answer doesn't hide it behind a plausible sentence.