Decimal vs float for money, from a billing system
Money is a string at rest, a float at the counter and an integer at the gateway. Three representations in one billing system, and what happens at the seams.
Ridam Agrawal
- 10 min
On this page
Should I use float or decimal for money?
Store money as a decimal, which in Postgres means numeric(12,2), and never as a float. Do arithmetic in floats only where the language gives you nothing else, round after every step, and convert to whole minor units at the payment boundary. Easinvy Retail does all three, and this is where each one lives.
Everything below is the money path through a live retail billing system, read out of the code on 17 September 2026. The question the search results argue about is which type. The answer this system gives is that there are three, one per boundary, and the design is the conversions between them.
Why a float cannot hold ₹2.78
A binary floating-point number is a sum of powers of two, and most decimal fractions are not. ₹2.78 held as a 32-bit float is 2.7799999713 — the number comes from Modern Treasury's case for integer cents, which is the clearest statement of the storage argument on this question. The same page shows a 32-bit float holding $25,474,937.47 as $25,474,936.32, and two different summation orders over one list of amounts coming to 16.77 and 16.78.
This product met that in a migration rather than in a textbook. The schema
comment is the record, at schema/inventory.ts:19:
Money is numeric(12,2) throughout. The Mongo model used JS floats, which accumulate error across bill totals, discounts and returns — the single most consequential correctness change in this migration.
Three places named: totals, discounts, returns. What that comment is not is
an incident report. The Mongo-era code is not in this repository and no wrong
bill is recorded anywhere, so the claim here is about a mechanism that was
removed, not about a customer who was overcharged. Counted across
backend/src/db/schema/*.ts on 17 September 2026: 40 numeric columns, no
real, no double precision — thirty-two of them at scale 2, which is
money.
What numeric gives you, and what it hands back
Postgres says what to use, in the type's own documentation:
numeric is
"especially recommended for storing monetary amounts and other quantities where
exactness is required". It rounds ties away from zero, where the floating-point
types round to even, and the docs concede without hedging that calculations on
it are very slow compared to integers or floats.
Follow that advice in JavaScript and the first thing you meet is not a rounding error. It is a string.
numeric arrives from
node-postgres as "499.00", not
499. The driver converts a database type to a string when it has no
registered parser for it, and for numeric that is precisely the point: it is
refusing to lose precision by forcing the value through a double. Drizzle
expresses the same decision at the schema layer, where numeric infers as
string unless you ask for mode: "number".
The comment in db/money.ts says what to do about it:
Postgres numeric arrives as a string in node-postgres, deliberately — it is the driver refusing to lose precision by forcing it through a JS float. That is the behaviour we want, so these helpers convert at the edges rather than making the columns floats to avoid the inconvenience.
The rule that follows: numbers may be used for arithmetic on the way in, but
everything written to a numeric column goes through toMoney first, and
everything read back for arithmetic goes through fromMoney. Counted the same
day: 69 toMoney call sites and 67 fromMoney, across ten query modules.
The two alternatives are the ones that actually get taken — parseFloat at
forty call sites, or changing the column to double precision so the driver
stops being inconvenient. Every page ranking for this question recommends
numeric. None of them mentions that a string you cannot add is the first
thing you see after taking the advice.
Why not Postgres's money type
PostgreSQL ships a type called money, and
its own documentation
is the argument against it. The type's fractional precision "is determined by
the database's lc_monetary setting", its output is locale-sensitive, and a
dump restored into a database with a different lc_monetary "might not work".
Casting from float8 has to pass through numeric anyway, and money divided
by an integer truncates toward zero.
Crunchy Data
arrives at the same verdict from the other side: numeric is "the ideal
datatype", money is "tied to a database locale setting". That page is also
where the same docs sentence this whole search result is arguing about lives —
floating point numbers should not be used to handle money due to the potential
for rounding errors.
What the documentation does not pick for you is the scale. Google's AI answer
for this query, captured on 15 September 2026, recommends NUMERIC(12,4) —
four decimal places, on the grounds that they are crucial for avoiding rounding
bugs during tax. This system uses two, and the next section is why: the tax is
not carried to four decimals, because the statute rounds it to the rupee.
The counter does its arithmetic in floats, because the browser has nothing else
JavaScript has no decimal type. Every number in it is an IEEE 754 double, which is the thing four pages of search results tell you not to use for money — and the billing counter is a React component, so there is nothing else to use.
What it does instead is round after every operation. The line pipeline in
components/bills/CreateBill.tsx, in order:
discountAmount = Math.round(discountAmount * 100) / 100 // to the paisa
netPrice = Math.round(price - discountAmount) // to the rupee
total = Math.round(netPrice * quantity) // to the rupee
grossSubtotal = sum of price × quantity // unrounded
gstAmount = discountedSubtotal × gstPercent / 100 // unrounded, then
totalAmount = Math.round(subtotal + gst + roundOff) // to the rupee
That regime has a name and a defender. Evan Jones argues you can use floating-point numbers for money provided you round after every operation, and points out that a double's fifteen significant digits cover two-decimal amounts up to about ten trillion. His warning is the part worth keeping: rounding bugs "will be hidden until the right (wrong?) input is processed".
Two things in that pipeline are decisions rather than accidents, and both are worth stating plainly.
Line prices round to the whole rupee, not the paisa. A ₹10.50 item nets to ₹11. That is a product decision for counters that deal in rupees and hand back coins. It is not a legal requirement — the statute below rounds the tax and the sum payable, not each line.
Nothing in the browser touches a stored decimal. The numbers computed here
travel to the server as numbers and are written through toMoney. The float
lives inside the arithmetic and dies at the boundary.
The rupee is a law
Section 170 of the Central Goods and Services Tax Act 2017 is why the total on an Easinvy bill is a whole number of rupees, and it is worth quoting rather than paraphrasing:
The amount of tax, interest, penalty, fine or any other sum payable, and the amount of refund or any other sum due, under the provisions of this Act shall be rounded off to the nearest rupee and, for this purpose, where such amount contains a part of a rupee consisting of paise, then, if such part is fifty paise or more, it shall be increased to one rupee and if such part is less than fifty paise it shall be ignored.
Nearest rupee, half-up, in force since 1 July 2017. So gst_amount is whole
and the bill total is whole — and the difference between what the lines come to
and what is actually payable has to be recorded somewhere. That is
bills.round_off, which is stored rather than displayed, and signed
(billing.ts:132). A rounding column that could only be positive would be a
rounding column that could not represent rounding down.
None of the pages competing for this query has a law in it. That is not a criticism, because they are answering the general question — but it is where a general answer stops. The scale of the column is not decided by how many decimals tax "needs". It is decided by what the tax authority will accept.
The gateway wants integers
Razorpay's Orders API takes an amount as a whole number in the smallest
currency sub-unit, which here is paise. So the same ₹499 that is the string
"499.00" in Postgres and a 499 double in the browser goes onto the wire as
the integer 49900.
toPaise is Math.round(rupees * 100), and order creation refuses anything
that does not land on an integer. The guard in lib/razorpay.ts says so in its
own words:
Order amount must be a positive whole number of paise
Three representations of one amount, all of them inside a single request cycle. None is a workaround for the others.
Where it leaks: 5.005
Rounding is where the seams show, and three rounding rules touch the same rupee here.
toMoney is Number(value).toFixed(2). Run in Node 22.14 for this write-up on
17 September 2026:
| Value | toFixed(2) | Math.round(x * 100) / 100 | roundMoney |
|---|---|---|---|
10.01 * 0.5 → 5.00499999999999989 | "5.00" | 5.01 | 5.01 |
1.005 | "1.00" | 1 | 1.01 |
MDN documents the reason
in its own example for toFixed: (2.55).toFixed(1) is '2.5', because 2.55
cannot be represented exactly and the closest float is lower.
Postgres numeric rounds ties away from zero, so a column handed 5.005 would
store 5.01. It is never handed 5.005 — it receives the string "5.00". Section
170 is half-up as well. Of the three rules, the one that rounds down is the
one at the boundary.
And roundMoney — the helper in money.ts that nudges by Number.EPSILON and
gets 1.005 right — is called nowhere. Counted the same day: 13 inline
Math.round(x * 100) / 100 in the backend, 8 in the frontend, 0 calls to the
helper. The correct function is the unused one.
How much that matters is bounded, and the bound deserves to be precise.
grossSubtotal is the only stored figure reaching toMoney unrounded, and it
is informational — the printed total is rounded to the rupee before it gets
there. A one-paisa difference in the gross subtotal of a bill whose lines are
all whole rupees is not a wrong bill. It is still a true statement about the
code, and it is better said here than found by somebody else later.
Quantities go the other way
Quantities in the same schema are numeric(12,3) with mode: "number" — read
back as a JavaScript float, on purpose. Cloth goes out by the metre and rice by
the kilo, the arithmetic happens on the client, and the schema comment says
what keeps it honest: rounding to milli-units at every edge keeps 0.1 + 0.2
out of the database.
aggregateItemQty rounds as it accumulates, for the reason its own comment
gives — three 0.1m cuts of the same cloth must come to 0.3, not to the float
that 0.1 + 0.1 + 0.1 actually is.
So one codebase runs both regimes deliberately: exact strings for money, rounded floats for quantities. The line between them is not importance. It is where the arithmetic happens — in the database, or in a browser.
What this design does not check
The server stores what the counter computed. createBill writes
input.totals.* through toMoney and does not recompute a total from the
lines it was sent, so a client submitting an inconsistent set of totals would
have them stored.
That is a limit of the design, and it is stated here as one. The counter is the only writer, the arithmetic is rounded at every step, and the amounts involved are nowhere near the bounds where the rounding regime breaks down — a server-side recomputation is a thing that could be added on its own merits. What would be dishonest is describing the type discipline above and letting you assume there is a check underneath it that is not there.
Which type, where
Not which type. Which type where:
| Boundary | Representation | Why |
|---|---|---|
| At rest in Postgres | numeric(12,2), a string in JS | Exact, and the driver refuses to make it a float |
| In the counter's arithmetic | IEEE 754 double | The browser has no decimal type; every step is rounded |
| On the wire to the gateway | Integer paise | The API takes whole sub-units and refuses the rest |
The three conversions between those rows are the design. Get one wrong and the
type discipline on either side of it buys nothing — a numeric column fed by a
parseFloat is a float with extra steps, and an exact total sent to a gateway
that wanted paise is a failed payment.
The principle transfers to languages that do have a real decimal type, and the boundaries do not disappear when it does; they move. Deciding where they sit, and what converts across them, is most of what database work turns out to be. The bills in this piece are real bills from Easinvy Retail — the multi-tenant inventory and billing product we designed, build and operate, which is the only reason any of these numbers could be counted rather than recalled.
Questions people ask
- Should I use float or decimal?
- Decimal, for anything that must add up to the paisa. Easinvy Retail stores every monetary column as Postgres numeric(12,2) and uses floats only in the browser, where the language offers nothing else, rounded after each step.
- Why not use float for money?
- A binary float cannot represent most decimal fractions: 2.78 is stored as 2.7799999713. Errors compound across totals, discounts and returns, and two summation orders over the same list can produce two different totals.
- Is 0.1 a float?
- In JavaScript, yes. Every number is an IEEE 754 double, and 0.1 is stored as the nearest binary fraction, so 0.1 plus 0.2 comes to 0.30000000000000004. Postgres numeric stores 0.1 exactly, as a decimal rather than a binary fraction.
- Is there a money data type in SQL?
- PostgreSQL has one, and its documentation ties the type's precision and output to the database locale setting lc_monetary. Easinvy Retail uses numeric(12,2) instead, and the money type is not used anywhere in the schema.
Sources
- PostgreSQL 18 — 8.1.2 Arbitrary Precision Numbers
- PostgreSQL 18 — 8.2 Monetary Types
- CGST Act 2017, Section 170 — Rounding off of tax, etc.
- Modern Treasury — Floats Don't Work For Storing Cents
- Evan Jones — You can use floating-point numbers for money
- Crunchy Data — Working with Money in Postgres
- MDN — Number.prototype.toFixed
- node-postgres — data types
Ridam Agrawal
Founder and engineer at Easinvy. Writes up the parts of building and running a multi-tenant product that were expensive to get wrong. More about the studio →
Related
How to revoke a JWT, from a system that does it
The JWT spec defines no way to revoke a token. Here is the cut-off we run in production, the claim it has to compare against, and the rounding bug it had.
Database Solutions
Schemas designed before they become expensive.
All notes
Write-ups from building and running our own product.