Skip to content

Database Solutions

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

Founder, Easinvy

Published
Read
10 min
On this page

In short

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:

ValuetoFixed(2)Math.round(x * 100) / 100roundMoney
10.01 * 0.5 → 5.00499999999999989"5.00"5.015.01
1.005"1.00"11.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:

BoundaryRepresentationWhy
At rest in Postgresnumeric(12,2), a string in JSExact, and the driver refuses to make it a float
In the counter's arithmeticIEEE 754 doubleThe browser has no decimal type; every step is rounded
On the wire to the gatewayInteger paiseThe 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

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 →

Keep reading