In a Ruby 4.0 service quoting foreign-exchange amounts, how would you use BigDecimal to convert and round to cents correctly?
answer
- bundled gem since 3.4
- build from Strings, not Float products
- round once, explicit half: mode
- BigDecimal.mode is thread-local
- store integer minor units
basics
~20 sAdd 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 sOn 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# Gemfile
gem "bigdecimal"go deeper
Remember that money should not be a Float, and that BigDecimal built from a String keeps decimal digits exactly.
Explain building values from Strings, rounding with an explicit half: mode, and why the Gemfile must list bigdecimal on Ruby 3.4 and later.
Show production judgement: round once at a defined boundary, avoid thread-local BigDecimal.mode, state division precision, and persist integer minor units.
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.