How do you round money in Python so a payment reconciliation job matches the ledger to the cent?
answer
- The type boundary comes first
- Never let a float touch the amount
- The default mode is not the billing rule
- Rounding twice is not rounding once
- quantize with an explicit exponent and mode
basics
~10 sStop rounding floats. Parse each amount into a decimal.Decimal from its original string, then quantize to Decimal('0.01') with an explicitly named rounding mode such as decimal.ROUND_HALF_UP, and round once at a defined boundary.
solid answer
~40 sThree rules fix almost every cent-level mismatch. First, values arrive as `decimal.Decimal` built from the original string or from integer minor units — never via a `float`, because `Decimal(2.675)` inherits the binary error and rounds like the float did. Second, round with `quantize`, passing the target exponent as a `Decimal` and naming the mode: `amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)`. The decimal module's default is half-to-even, which is bias-free but is not what most invoicing rules specify, so relying on the default is how two systems silently disagree. Third, decide *where* rounding happens — per line item or once on the total — and do it in exactly one place, because rounding each of a batch of line items and rounding their sum give different answers. Test the half-cent boundaries explicitly; they are where the off-by-one appears.
code
python · 9 linesfrom decimal import Decimal, ROUND_HALF_UP
CENT = Decimal('0.01')
def to_cents(amount: str | Decimal) -> Decimal:
"""Round to whole cents using the billing rule: half away from zero."""
return Decimal(amount).quantize(CENT, rounding=ROUND_HALF_UP)
print(to_cents('2.675'), to_cents('0.005'), to_cents('-2.675'))go deeper
Remember the headline rule: money is not a float. Build a decimal.Decimal from the original string and let a shared helper do the rounding rather than calling round() on an amount yourself.
Explain the mechanics: quantize takes the target exponent as a Decimal and an explicit rounding= mode, the module default is half-to-even, and Decimal(some_float) imports the float's error. Show that you know rounding per line and rounding the total differ.
Diagnose it as a system: find where values cross into floats, where the rounding point sits on each side of the reconciliation, and which rounding mode the counterparty uses. Test half-cent boundaries including negatives, and let InvalidOperation fail loudly rather than swallowing it.
Own the standard: one documented money type and rounding policy across services, storage in minor units or Decimal with a stated exponent, and a review rule that no other code calls quantize directly. Weigh the cost of exact arithmetic against the audit and dispute cost of being a cent out.
## The symptom A payment reconciliation job compares its own computed totals against a ledger and finds a handful of rows off by one cent — never many, never reproducible from the aggregate, and always on amounts that end near a half cent. That is the signature of rounding, and there are three independent causes worth separating. ## Cause one: the values were floats If an amount ever passed through a `float`, the decimal digits are already approximate and `round(x, 2)` rounds whichever side the stored binary value happened to land on. Worse, the fix people reach for first — wrapping it in `Decimal` — does not help if the float is the input: ```python from decimal import Decimal, ROUND_HALF_UP print(Decimal(2.675).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) # 2.67 print(Decimal('2.675').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)) # 2.68 ``` The constructor converts a float *exactly*, error and all. Money must become a `Decimal` at the point it enters the process — from the request body's string, from the database column, from integer minor units — and never round-trip through a float on the way. ## Cause two: the rounding mode was never chosen `quantize` uses the current decimal context's rounding when you do not name one, and that default is `ROUND_HALF_EVEN`. Half-to-even is the right statistical default and the wrong invoicing default: most tax and billing rules specify half away from zero. So `Decimal('2.5').quantize(Decimal('1'))` gives `2`, while the same call with `rounding=ROUND_HALF_UP` gives `3`. Pass the mode explicitly at every call site that rounds money. Setting it globally on the context is possible, but it makes the behaviour of a helper depend on where it is called from, which is precisely the ambiguity you are trying to remove; if you do change it, scope it with a context manager rather than mutating the process-wide context. The mode is a business rule. Write it down next to the code, because someone will ask which regulation it implements, and 'the default' is not an answer. ## Cause three: rounding happened at the wrong point, or twice Rounding each line and summing is not the same as summing and rounding once. With enough lines the two answers diverge by several cents, and the discrepancy grows with the batch size — a batch spanning seventeen line items can easily land a few cents apart from the same batch totalled the other way. ```python from decimal import Decimal, ROUND_HALF_UP CENT = Decimal('0.01') lines = ['12.345', '0.005', '7.125', '2.675'] per_line = sum(Decimal(a).quantize(CENT, rounding=ROUND_HALF_UP) for a in lines) once = sum(Decimal(a) for a in lines).quantize(CENT, rounding=ROUND_HALF_UP) print(per_line, once) ``` Neither is wrong in general; only one matches the ledger. Find out which the counterparty does, encode that decision in one place, and make every path go through it. The failure mode is that half the codebase rounds on write and the other half rounds on read, so the mismatch depends on which side of the boundary a value entered from. ## Making it stick * One module owns money. A single `to_cents(amount)` helper that takes a string or `Decimal`, quantizes with the documented mode, and returns a `Decimal` — with the raw `quantize` call appearing nowhere else. * Consider integer minor units as the storage type. Storing cents as an `int` removes rounding from every intermediate step and confines it to the two conversions at the edges; it is the simplest correct choice for a system that never needs fractional cents. * Watch for `decimal.InvalidOperation`. `quantize` raises it when the result would need more digits than the context precision allows, which turns a silently wrong total into a loud failure — but only if you do not swallow it. * Test the boundaries, not the middle. Fixtures that end in a half cent — and their negatives, for refunds — are where the off-by-one lives. Values that round unambiguously prove nothing. * Reconcile with exact comparison. Once both sides are `Decimal`, equality is meaningful and a tolerance window is no longer a substitute for correctness. ## What a strong answer sounds like Name the type boundary, the explicit rounding mode, and the single rounding point — in that order — and say that the mode is a business rule rather than a coding preference. Candidates who answer only 'use Decimal' have solved one of the three causes and will still be a cent out.
- Why is naming the rounding mode better than setting it on the decimal context once at startup?Because a global context makes a helper's result depend on where it was called from, and on which thread — contexts are thread-local, so a worker thread silently gets the default back. Passing `rounding=` at the call site keeps the business rule visible in the code that implements it. If you must change the context, scope the change with a context manager rather than mutating it process-wide.
- What raises decimal.InvalidOperation during quantize, and is that a problem?`quantize` raises it when the quantized result would need more digits than the context's precision allows — for example quantizing a very large value to cents. It is a feature: it converts a silently truncated total into an explicit failure. Handle it by raising a domain error rather than catching and continuing, and never widen precision blindly to make it disappear.
- When would you store money as an integer number of cents instead?When the domain never needs sub-cent values and the arithmetic is addition and subtraction. Integer minor units make every intermediate exact, remove rounding from all but the two edge conversions, and are trivially comparable and storable. Decimal earns its place when you need fractional units — per-unit rates, interest, tax at more than two places — or currencies with different exponents.
saying these in an interview costs you the question
- Answers only 'use Decimal' and stops there
- Builds the Decimal from a float amount
- Relies on the context's default rounding mode
- Rounds line items and totals in different places
- Adds an epsilon or a tolerance to hide the mismatch
- Treats the rounding mode as a developer preference