DEV Community

Cover image for Why Money Calculations Should Not Use Floating Point
Vasyl Kyryliuk for VAT Engine

Posted on Originally published at vat-engine.app

Why Money Calculations Should Not Use Floating Point

Financial software often starts with code that looks completely harmless:

const price = 19.99;
const vatRate = 0.19;

const vat = price * vatRate;
Enter fullscreen mode Exit fullscreen mode

The formula is not the surprising part.

The representation is.

Most mainstream programming languages represent values such as 19.99, 0.1, and 0.19 using binary floating point.

That representation is extremely useful for scientific computing, graphics, statistics, simulations, and many other problems.

Money has a different set of requirements.

A payment, invoice, VAT amount, or accounting total usually needs to be:

  • reproducible;
  • represented at a known currency precision;
  • rounded according to an explicit rule;
  • consistent across services;
  • safe to persist and export;
  • understandable later.

Those requirements make binary floating point a poor default canonical representation for money.

For VAT software, even a small inconsistency can eventually appear in net amounts, VAT amounts, gross totals, reports, exports, or reconciliation.

Key takeaways

  • Many decimal fractions cannot be represented exactly using binary floating point.
  • Money is usually better modeled as an amount plus currency and scale.
  • Integer minor units make the monetary input exact.
  • VAT rates can also be represented using integer basis points.
  • Rounding belongs in the calculation contract, not only in UI formatting.
  • Decimal libraries are also valid when their precision and rounding rules are explicit.
  • Correct arithmetic still requires the correct tax class, rate, country, and transaction context.

The classic floating-point example

Open a JavaScript console and run:

0.1 + 0.2
Enter fullscreen mode Exit fullscreen mode

You do not get exactly:

0.3
Enter fullscreen mode Exit fullscreen mode

Instead, JavaScript produces:

0.30000000000000004
Enter fullscreen mode Exit fullscreen mode

This is not a JavaScript bug.

It is a consequence of binary floating-point representation.

Many decimal fractions that are simple in base 10 do not have a finite representation in base 2.

A similar problem exists in decimal notation:

1 / 3 = 0.333333...
Enter fullscreen mode Exit fullscreen mode

There is no finite decimal representation of one third.

Likewise, values such as 0.1 cannot be represented exactly using a finite binary fraction.

The runtime therefore stores the closest available approximation.

For many applications, that approximation is perfectly acceptable.

For money, it is usually better not to make that approximation part of the monetary domain model.

Money is discrete at a defined currency precision

Consider:

€19.99
Enter fullscreen mode Exit fullscreen mode

For ordinary EUR calculations, that amount can be represented as:

1,999 cents
Enter fullscreen mode Exit fullscreen mode

Instead of:

const price = 19.99;
Enter fullscreen mode Exit fullscreen mode

the core financial representation can be:

const priceMinor = 1999;
Enter fullscreen mode Exit fullscreen mode

Now:

const a = 10; // €0.10
const b = 20; // €0.20

const total = a + b;

console.log(total);
// 30
Enter fullscreen mode Exit fullscreen mode

There is no floating-point approximation involved in:

10 + 20 = 30
Enter fullscreen mode Exit fullscreen mode

The application can convert the value into a formatted monetary string later:

30 minor units → €0.30
Enter fullscreen mode Exit fullscreen mode

VAT Engine follows this model in its VAT calculation API.

For EUR:

€1.00   → 100
€19.99  → 1999
€119.00 → 11900
Enter fullscreen mode Exit fullscreen mode

The API therefore uses fields such as:

gross_amount_minor
net_amount_minor
vat_amount_minor
Enter fullscreen mode Exit fullscreen mode

rather than using binary floating-point monetary values as the core VAT calculation input and output.

Not every currency has two decimal places

A common simplification is:

Store everything in cents.

That works for currencies such as EUR and USD, but it is not a general money model.

Currencies can use different minor-unit scales.

Conceptually, a monetary value therefore needs at least:

amount
currency
scale
Enter fullscreen mode Exit fullscreen mode

A domain type could look like:

type Money = {
  amountMinor: bigint;
  currency: string;
  scale: number;
};
Enter fullscreen mode Exit fullscreen mode

For example:

EUR → 2 decimal places
JPY → 0 decimal places
Enter fullscreen mode Exit fullscreen mode

Other currencies can use different defined scales.

VAT Engine's money model validates the currency and expected minor-unit scale instead of treating every currency as if it automatically had two decimal places.

Floating point mixes representation with rounding

Suppose we calculate 19% VAT on:

€19.99
Enter fullscreen mode Exit fullscreen mode

A floating-point implementation may start with:

const net = 19.99;
const vatRate = 0.19;

const vat = net * vatRate;
Enter fullscreen mode Exit fullscreen mode

Mathematically:

19.99 × 0.19 = 3.7981
Enter fullscreen mode Exit fullscreen mode

EUR cannot represent:

€3.7981
Enter fullscreen mode Exit fullscreen mode

as a final monetary amount.

At some point the result needs to be rounded.

For example:

€3.7981 → €3.80
Enter fullscreen mode Exit fullscreen mode

That rounding decision is unavoidable.

The problem with using floating point as the money representation is that approximation already exists before the application's financial rounding policy is applied.

When values move through:

browser
→ API
→ worker
→ database
→ report
→ export
Enter fullscreen mode Exit fullscreen mode

different implementations can also:

  • round at different stages;
  • serialize numbers differently;
  • aggregate before or after rounding;
  • use different numeric types.

A more predictable design keeps the monetary input exact and makes the rounding step explicit.

Minor units make the monetary input exact

Represent:

€19.99
Enter fullscreen mode Exit fullscreen mode

as:

1999
Enter fullscreen mode Exit fullscreen mode

and represent the VAT rate separately.

For a VAT-exclusive price with a 19% rate:

VAT =
1999 × 19 / 100
Enter fullscreen mode Exit fullscreen mode

which gives:

379.81 minor units
Enter fullscreen mode Exit fullscreen mode

The currency cannot represent:

0.81
Enter fullscreen mode Exit fullscreen mode

of a cent.

Now we have reached the actual financial decision:

How should the fractional minor unit be rounded?

Using half-up rounding:

379.81 → 380
Enter fullscreen mode Exit fullscreen mode

The result becomes:

Net:   €19.99
VAT:   €3.80
Gross: €23.79
Enter fullscreen mode Exit fullscreen mode

The important difference is that:

1999
Enter fullscreen mode Exit fullscreen mode

was exact.

Rounding happened deliberately at the monetary calculation boundary rather than being mixed with the representation of the input value.

VAT rates can be represented as integers too

Monetary amounts are not the only decimal-looking values involved in VAT.

A VAT rate such as:

19%
Enter fullscreen mode Exit fullscreen mode

is often represented in application code as:

0.19
Enter fullscreen mode Exit fullscreen mode

But the rate can also be represented using an integer.

VAT Engine's calculation contract uses basis points.

For example:

19.00% = 1900 basis points
20.00% = 2000 basis points
7.00%  = 700 basis points
Enter fullscreen mode Exit fullscreen mode

A calculation response can therefore contain:

{
  "vat_rate_bps": 1900
}
Enter fullscreen mode Exit fullscreen mode

For a VAT-exclusive calculation, the core arithmetic can be expressed as:

VAT minor units =
amount_minor × rate_bps / 10000
Enter fullscreen mode Exit fullscreen mode

Using:

amount_minor = 1999
rate_bps     = 1900
Enter fullscreen mode Exit fullscreen mode

gives:

1999 × 1900 / 10000
Enter fullscreen mode Exit fullscreen mode

The fractional result is then rounded according to the defined calculation rule.

The Calculate VAT API exposes both money and VAT rates using these explicit integer representations.

VAT-inclusive pricing still requires fractional arithmetic

Integer representation does not mean that all intermediate mathematical results are integers.

Suppose:

Gross: €19.99
VAT rate: 19%
Enter fullscreen mode Exit fullscreen mode

or:

gross_minor = 1999
rate_bps    = 1900
Enter fullscreen mode Exit fullscreen mode

Because VAT is already included in the gross amount, it has to be extracted.

The formula is:

VAT =
Gross × Rate / (100% + Rate)
Enter fullscreen mode Exit fullscreen mode

Using basis points:

VAT minor units =
1999 × 1900 / (10000 + 1900)
Enter fullscreen mode Exit fullscreen mode

or:

1999 × 1900 / 11900
Enter fullscreen mode Exit fullscreen mode

This produces a fractional minor-unit result.

That value is then rounded using the defined rounding rule.

The important distinction is:

Integer money representation does not eliminate rounding. It makes the point where rounding is required explicit.

For a deeper explanation of inclusive versus exclusive VAT arithmetic, see VAT-Inclusive vs VAT-Exclusive Pricing: The Math Developers Get Wrong.

Rounding is part of the financial contract

Once an intermediate result contains a fraction of a minor unit, the application needs a defined policy.

Possible approaches include:

round half up
round half away from zero
round half to even
truncate
round per line
round only after aggregation
Enter fullscreen mode Exit fullscreen mode

These approaches are not interchangeable.

For example, imagine several order lines where each line creates a fractional cent of VAT.

One system might:

calculate → round every line → sum
Enter fullscreen mode Exit fullscreen mode

while another might:

calculate every line → sum exact intermediates → round once
Enter fullscreen mode Exit fullscreen mode

Those systems can produce different totals.

That does not necessarily mean either system has a floating-point bug.

It means the rounding contract differs.

VAT Engine's core VAT calculation arithmetic works with integer minor units and applies deterministic half-up rounding to the VAT fraction.

The behavior is documented in the Calculate VAT API reference.

Formatting is not calculation

Another useful separation is:

financial value
≠
display string
Enter fullscreen mode Exit fullscreen mode

A core system might store:

{
  "currency": "EUR",
  "net_amount_minor": 1999,
  "vat_amount_minor": 380,
  "gross_amount_minor": 2379
}
Enter fullscreen mode Exit fullscreen mode

A European UI could display:

€19.99
€3.80
€23.79
Enter fullscreen mode Exit fullscreen mode

Another locale might display:

19,99 €
3,80 €
23,79 €
Enter fullscreen mode Exit fullscreen mode

The formatting changed.

The money did not.

Currency formatting belongs at the presentation boundary.

It should not define how the underlying amount is represented or calculated.

An integer alone is not money

This:

1999
Enter fullscreen mode Exit fullscreen mode

is not enough information.

It could represent:

€19.99
$19.99
¥1,999
Enter fullscreen mode Exit fullscreen mode

or another currency amount.

Money therefore needs currency identity as part of the value.

That also means this should not be accepted as ordinary addition:

€10.00 + $10.00
Enter fullscreen mode Exit fullscreen mode

An explicit exchange-rate operation is required first.

A money type can enforce rules that a primitive number cannot.

For example:

type Money = {
  amountMinor: bigint;
  currency: string;
  scale: number;
};
Enter fullscreen mode Exit fullscreen mode

Adding two values can require:

same currency
+
same scale
Enter fullscreen mode Exit fullscreen mode

before the arithmetic is allowed.

Exchange rates are a separate numeric domain

There is an important nuance here.

Saying:

Do not use binary floating point as the canonical representation of money.

does not mean:

Every numeric value inside financial software must always be an integer.

Exchange rates, ratios, measurements, or other values may require precision beyond a currency's final minor-unit scale.

For example:

1 EUR = 4.3176 PLN
Enter fullscreen mode Exit fullscreen mode

is a rate, not a monetary amount.

A cleaner architecture treats these as different domain concepts:

Money    → currency + minor units
VAT rate → basis points
FX rate  → explicitly defined rate representation
Enter fullscreen mode Exit fullscreen mode

The conversion into money then happens at a well-defined boundary with a known rounding policy.

That is much easier to reason about than using one generic floating-point type for every financial concept.

Decimal libraries are also a valid solution

Integer minor units are not the only reasonable way to implement monetary arithmetic.

Arbitrary-precision or fixed-point decimal libraries can also be appropriate.

They are especially useful when:

  • intermediate values require more precision than the final currency scale;
  • decimal quantities need to remain exact;
  • financial formulas involve multiple precision stages.

The important requirements are still the same:

  • precision should be explicit;
  • scale should be explicit;
  • rounding should be explicit;
  • serialization should be predictable.

So the rule is not:

Only integers are acceptable for financial software.

A better rule is:

Do not let binary floating-point behavior silently define the semantics of your money.

For APIs, integer minor units have the additional advantage of creating a simple cross-language contract:

{
  "currency": "EUR",
  "amount_minor": 1999
}
Enter fullscreen mode Exit fullscreen mode

Every consumer sees the same integer.

Keep the amount basis explicit

Correct monetary representation does not remove the need for correct business context.

For VAT calculations, a system also needs to know whether the input represents:

net
Enter fullscreen mode Exit fullscreen mode

or:

gross
Enter fullscreen mode Exit fullscreen mode

VAT Engine exposes this explicitly:

{
  "price_includes_vat": true
}
Enter fullscreen mode Exit fullscreen mode

or:

{
  "price_includes_vat": false
}
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "country": "DE",
  "currency": "EUR",
  "gross_amount_minor": 11900,
  "price_includes_vat": true,
  "tax_class_id": "standard",
  "transaction_date": "2026-01-28"
}
Enter fullscreen mode Exit fullscreen mode

A calculation can return:

{
  "vat_rate_bps": 1900,
  "gross_amount_minor": 11900,
  "net_amount_minor": 10000,
  "vat_amount_minor": 1900
}
Enter fullscreen mode Exit fullscreen mode

The full request and response contract is available in the VAT calculation API documentation.

You can also experiment with gross and net calculations using the VAT calculator.

Exact money does not automatically mean correct VAT

Using minor units solves a particular class of engineering problems.

It does not determine the correct VAT treatment.

A calculation can still depend on:

  • destination country;
  • tax class;
  • transaction date;
  • selected VAT rate;
  • relevant transaction facts.

So:

integer arithmetic
Enter fullscreen mode Exit fullscreen mode

does not automatically imply:

correct VAT treatment
Enter fullscreen mode Exit fullscreen mode

A more complete model is:

correct transaction facts
+ correct rate selection
+ exact money representation
+ explicit rounding
= reproducible VAT calculation
Enter fullscreen mode Exit fullscreen mode

VAT Engine separates these concerns.

You can inspect:

independently.

Persist the canonical monetary representation

A system can implement its calculator correctly and still lose that consistency later.

For example:

API calculation → integer minor units
database        → converted into floating point
frontend        → Number
export          → decimal regenerated from float
Enter fullscreen mode Exit fullscreen mode

The original calculation was deterministic.

The persistence contract was not.

If minor units are the canonical representation, keep them canonical across:

request
→ calculation
→ persistence
→ read-back
→ export
Enter fullscreen mode Exit fullscreen mode

VAT Engine's authenticated transaction calculation records retain the calculated minor-unit amounts together with their calculation context.

That allows the stored result to be inspected later without reconstructing monetary amounts from presentation strings.

Calculation history is not a sales ledger

There is another important boundary.

A calculation does not necessarily represent a completed transaction.

A checkout may calculate VAT repeatedly:

customer changes country
→ calculate

quantity changes
→ calculate

discount applied
→ calculate

shipping option changes
→ calculate

customer leaves
Enter fullscreen mode Exit fullscreen mode

Those calculations can be useful for technical history and review.

They are not necessarily completed sales.

VAT Engine therefore keeps calculation history separate from committed supply events used by its compliance and reporting workflows.

This prevents a calculation API call from being mistaken for a legal or accounting business event.

A practical TypeScript model

Instead of making every monetary value a generic number:

type Price = number;
Enter fullscreen mode Exit fullscreen mode

use an explicit type:

type Money = {
  amountMinor: bigint;
  currency: string;
  scale: number;
};
Enter fullscreen mode Exit fullscreen mode

For example:

const price: Money = {
  amountMinor: 1999n,
  currency: 'EUR',
  scale: 2,
};
Enter fullscreen mode Exit fullscreen mode

Keep the VAT rate separate:

const vatRateBps = 1900;
Enter fullscreen mode Exit fullscreen mode

A calculation result can then have a clear contract:

type VatResult = {
  netAmountMinor: bigint;
  vatAmountMinor: bigint;
  grossAmountMinor: bigint;
  vatRateBps: number;
};
Enter fullscreen mode Exit fullscreen mode

Using bigint here also avoids accidentally exceeding JavaScript's safe integer range in systems that may process very large amounts.

For smaller bounded values, a regular integer number can also be used safely when the range is explicitly constrained.

The important part is that money semantics are deliberate rather than implicit.

Common mistakes

1. Storing prices as floating-point numbers

const price = 19.99;
Enter fullscreen mode Exit fullscreen mode

This makes binary approximation part of the canonical financial representation.

Prefer a fixed-scale money model.

2. Rounding only for display

value.toFixed(2)
Enter fullscreen mode Exit fullscreen mode

is presentation formatting.

Financial rounding affects the actual stored amount and should happen at the defined calculation boundary.

3. Assuming every currency uses two decimals

Do not make:

amount / 100
Enter fullscreen mode Exit fullscreen mode

a universal currency rule.

Currency scale belongs in the money model.

4. Treating an integer as sufficient monetary context

1999
Enter fullscreen mode Exit fullscreen mode

without a currency and scale is not a complete money value.

5. Mixing currencies

1000 EUR minor units
+
1000 USD minor units
Enter fullscreen mode Exit fullscreen mode

is not a valid ordinary addition.

6. Using decimals without defining rounding

Decimal arithmetic can avoid binary approximation, but the application still needs to define what happens when a result exceeds the currency's supported precision.

7. Converting back to floating point during persistence

A safe calculator cannot protect a system that later discards the canonical money representation.

Money arithmetic should be boring

The best financial arithmetic is rarely clever.

It should be predictable.

Before calculating a monetary result, the system should be able to answer:

What currency is this?
What is its minor-unit scale?
What amount is being calculated?
Does the amount include VAT?
Which VAT rate is being used?
Where does rounding happen?
Which rounding rule applies?
What values will be persisted?
Enter fullscreen mode Exit fullscreen mode

When those decisions are explicit, a large class of subtle financial bugs becomes much easier to prevent.

That is why VAT Engine's VAT calculation API uses minor-unit monetary amounts, integer VAT basis points, an explicit price-inclusion flag, and deterministic VAT rounding for its core calculation path.

The result is not sophisticated mathematics.

That is exactly the point.

Financial arithmetic should be simple enough to reproduce, test, store, and explain later.

You can explore the Calculate VAT API, browse available tax classes, inspect calculation history, or try the free VAT calculator.

Note: This article discusses monetary arithmetic and software design. It is not tax, accounting, or legal advice. The correct VAT treatment of a transaction depends on the applicable rules and the actual transaction facts.

Top comments (0)