skip to content

In a Ruby 4.0 service quoting foreign-exchange amounts, how would you use BigDecimal to convert and round to cents correctly?

level: seniorimportance: must knowfreq 55%

answer

  1. bundled gem since 3.4
  2. build from Strings, not Float products
  3. round once, explicit half: mode
  4. BigDecimal.mode is thread-local
  5. store integer minor units

basics

~20 s

Add bigdecimal to the Gemfile, since it is a bundled gem from Ruby 3.4, build amounts and rates from Strings, multiply as BigDecimal and round once at the end with an explicit mode such as round(2, half: :even).

solid answer

~40 s

On Ruby 4.0 `bigdecimal` is a bundled gem (since 3.4), so under Bundler `require "bigdecimal"` raises `LoadError` unless the bundle includes it, normally through a Gemfile entry. Build values from Strings: `BigDecimal("1234.56") * BigDecimal("1.0837")` is exactly `1337.892672`, whereas the Float product is `1337.8926720000002` and `BigDecimal()` of that Float keeps the error. Round once, at the end, with the mode in the call: `round(2, half: :even)` gives `1337.89` as a BigDecimal. Do not rely on `BigDecimal.mode(BigDecimal::ROUND_MODE, ...)`: it is per-thread state, so it neither reaches other threads nor resets between jobs. Divide with `div(value, digits)` because `/` has limited precision, and persist integer minor units or a decimal column, formatting with `to_s("F")`.

code

ruby · 2 lines
ruby
# Gemfile
gem "bigdecimal"

go deeper

for a junior

Remember that money should not be a Float, and that BigDecimal built from a String keeps decimal digits exactly.

for a middle

Explain building values from Strings, rounding with an explicit half: mode, and why the Gemfile must list bigdecimal on Ruby 3.4 and later.

for a senior

Show production judgement: round once at a defined boundary, avoid thread-local BigDecimal.mode, state division precision, and persist integer minor units.

for a principal

Own the rounding policy per currency and the storage format across services, so every consumer computes the same quote from the same inputs.

## Why not Float for an FX quote A quote converts an amount at a rate and rounds to the currency's minor unit. With Floats both inputs are already approximations: ```ruby 1234.56 * 1.0837 # => 1337.8926720000002 ``` The error is tiny, but rounding thresholds, totals over many lines and equality checks against a stored amount turn tiny into wrong. **`BigDecimal`** stores decimal digits with arbitrary precision, so the same product is exactly `1337.892672`. ## Getting BigDecimal on Ruby 4.0 `bigdecimal` is no longer a default gem. Since **Ruby 3.4** it is a **bundled gem**: installed with Ruby, but not on the load path of a Bundler-managed app unless declared. - Under Bundler, `require "bigdecimal"` raises **`LoadError`** when nothing in the bundle includes the gem, with a hint that it "is not part of the default gems since Ruby 3.4.0" and that you can add it to your Gemfile. - Ruby 3.3 only warned in the same situation, so an upgrade from 3.3 turns the warning into a failure. - The fix is one line: `gem "bigdecimal"` in the Gemfile. Ruby 4.0.7 ships bigdecimal 4.0.1. ## Building values correctly | Input | Result | Safe for money? | |---|---|---| | `BigDecimal("1234.56")` | exact `1234.56` | yes | | `"1.0837".to_d` (after `require "bigdecimal/util"`) | exact `1.0837` | yes, but lenient: it reads only the leading number, so `"45.67 degrees".to_d` is `45.67` | | `BigDecimal(0.1)` | `0.1`, from the Float's shortest decimal form | only if the Float was typed, not computed | | `BigDecimal(1234.56 * 1.0837)` | `1337.8926720000002` | no - the error is already in the Float | | `BigDecimal(Rational(1, 3))` | raises `ArgumentError` (can't omit precision for a Rational) | pass a precision: `BigDecimal(Rational(1, 3), 20)` | Older bigdecimal versions raised `ArgumentError` for a Float without a precision too; since bigdecimal 3.2.3 it is accepted, which makes the Float trap easier to fall into. ## Rounding once, explicitly 1. Parse the amount and the rate as BigDecimal from their String sources. 2. Multiply (and chain further rates) without intermediate rounding. 3. Round **once** at the boundary, to the currency's decimals, **naming the mode in the call**: `usd.round(2, half: :even)`. 4. Convert to minor units for storage: `(rounded * 100).to_i`. `BigDecimal#round` with positive digits returns a BigDecimal, with no digits an Integer. The rounding modes documented under `BigDecimal.mode` are `ROUND_HALF_UP` (the default, alias `:half_up`), `ROUND_HALF_EVEN` (`:half_even`, `:banker`), `ROUND_HALF_DOWN`, `ROUND_UP`, `ROUND_DOWN` (`:truncate`), `ROUND_CEILING` and `ROUND_FLOOR`; `round` takes either a mode or the `half:` keyword. `BigDecimal.mode(BigDecimal::ROUND_MODE, :banker)` changes the default for the **current thread only** (it is kept in thread-local storage). Setting it in a boot file does not reach worker threads, and setting it inside a request leaks into the next job that thread runs. Passing the mode per call avoids both. ## Division, precision and output - `BigDecimal("1") / 3` returns a run of 3s cut at an internal precision limit, not one third: division needs a stated precision. Use **`div(value, digits)`** to state it, for example `amount.div(rate, 20)`. - `div(value)` with **one** argument returns an Integer, by analogy with `Float#div` - a quiet trap. - `to_s` defaults to the `0.xxxxEnn` notation (`0.133789e4`); use `to_s("F")` for `"1337.89"`. - Store **integer minor units** (cents) or a database decimal column; never a Float column. ## A review checklist for quote code 1. Does any value reach BigDecimal through a Float - a JSON number parsed as Float, a `to_f` call, a Float column? 2. Is there exactly one rounding step per output amount, with the mode named in the call? 3. Does every division state its precision? 4. Is `bigdecimal` in the Gemfile, so the app boots on Ruby 3.4 and later? 5. Are stored amounts integer minor units or decimals, and formatted with `to_s("F")` rather than the default notation? ## Alternatives - **`Rational`** gives the same exactness in core Ruby without a gem, and `Rational#round(2, half: :even)` works too; BigDecimal wins on decimal-oriented output and rounding modes. - **Integer minor units throughout** is simplest when no fractional rates are involved.

  • In Ruby, why is setting BigDecimal.mode(BigDecimal::ROUND_MODE, :banker) in an initializer a poor way to enforce banker's rounding?
    BigDecimal keeps its rounding mode in thread-local storage, so the call changes only the thread that runs it. Worker threads started later still use the default `ROUND_HALF_UP`, and code that changes the mode mid-request leaks it into whatever that thread handles next. Passing the mode on each call, `round(2, half: :even)`, makes the rule visible and independent of thread state.
  • With the Ruby bigdecimal gem, what does amount.div(rate) return, and how do you get a decimal quotient?
    With one argument `BigDecimal#div` returns an Integer, the floored quotient, by analogy with `Float#div`. For a decimal result pass the number of significant digits, `amount.div(rate, 20)`, which rounds according to the current mode, and round the result to currency decimals afterwards. Plain `/` also works but uses an internal precision limit.
  • In a Ruby 4.0 app, when would you choose Rational over BigDecimal for an FX chain?
    When the chain involves true fractions, such as inverting a rate or dividing by three, `Rational` keeps them exact with no precision argument and needs no gem. BigDecimal fits when inputs and outputs are decimal strings and you want its rounding modes and `to_s("F")` formatting. Either way, round once at the boundary with an explicit `half:` mode.

saying these in an interview costs you the question

  • Float is fine for money if you call round(2) before saving.
  • require "bigdecimal" always works because it is part of the standard library.
  • BigDecimal(some_float) makes an already imprecise Float exact again.
  • Setting BigDecimal.mode once at boot fixes rounding for every thread.
  • BigDecimal division is always exact, so one third needs no precision.