Money and decimals in Rails
Type rails money into a search box and the first several answers are gems. A gem is a reasonable answer and this post gets to it, but it is the fourth decision rather than the first. The first three are which column type holds the amount, what rounds it and when, and what the stored number means in a currency that has no cents. Get those wrong and the gem sits on top of a column that already lost the penny.
Everything below ran on Ruby 4.0.5, activerecord 8.1.3.1 and PostgreSQL 17.7 against a scratch database. The outputs are pasted rather than remembered.
What a float loses, precisely
"Never use a float for money" is correct and usually stated without the mechanism, which makes it
sound like folklore. The mechanism is that binary floating point cannot represent one hundredth. The
stored value near 0.01 is a slightly different number, and the difference compounds.
A thousand pennies, added up three ways in Ruby:
float: 9.999999999999831 == 10.0 ? false
bigdec: 10.0 == 10 ? true
integer: 1000 cents
The same thing inside PostgreSQL, where the column type makes the decision instead of the language:
SELECT sum(0.01::float8) AS float_total, sum(0.01::numeric) AS numeric_total
FROM generate_series(1, 1000);
float_total | numeric_total
-------------------+---------------
9.999999999999831 | 10.00
The PostgreSQL manual states it without hedging: "Inexact means that some values cannot be converted
exactly to the internal format and are stored as approximations, so that storing and retrieving a
value might show slight discrepancies", followed by "If you require exact storage and calculations
(such as for monetary amounts), use the numeric type instead."
The failure worth carrying around is not the rendered total, which rounds to the right string most
of the time. The failure is equality. 0.1 * 3 == 0.3 is false in Ruby, so "is this invoice paid
in full", a balance-reaches-zero branch, and a WHERE total = ? are all allowed to be wrong about a
number that prints correctly on the screen next to them.
What numeric gives you, and what it charges
numeric is PostgreSQL's exact decimal. The manual recommends it by name for this job: "The type
numeric can store numbers with a very large number of digits. It is especially recommended for
storing monetary amounts and other quantities where exactness is required." Declared as
numeric(10,2), the ten is total significant digits and the two is digits after the point.
Two behaviours come attached, and only one of them announces itself.
Exceeding the precision raises. Through Active Record that surfaces as:
ActiveRecord::RangeError
PG::NumericValueOutOfRange: ERROR: numeric field overflow
DETAIL: A field with precision 10, scale 2 must round to an absolute value less than 10^8.
Exceeding the scale does not raise. It rounds, silently, which the manual also documents: "If the
scale of a value to be stored is greater than the declared scale of the column, the system will
round the value to the specified number of fractional digits." Measured on a numeric(10,2) column:
wrote 0.005, database returned 0.01
wrote 0.004, database returned 0.0
That is the correct behaviour for a price and the wrong behaviour for a rate. A per-token API cost, a currency exchange rate, a unit price on a thousand-unit line item all have real digits below the cent, and a two-scale column eats them without comment.
The other cost is speed, and the manual is blunt about it: "calculations on numeric values are
very slow compared to the integer types, or to the floating-point types described in the next
section." For a per-row price that difference is noise. For a SUM over several million rows in a
reporting query it stops being noise.
There is also a money type, and it is a trap in a portable application. Eight bytes, output
"locale-sensitive", and the manual attaches a restore warning: "before restoring a dump into a new
database make sure lc_monetary has the same or equivalent value as in the database that was
dumped." A column whose meaning depends on a server setting is a column you will one day restore
wrong.
Cents in an integer column
The other answer stores an integer count of the smallest unit. A price of 29.00 is 2900, the
column is integer, and every amount in the system is a whole number of cents.
What this buys is that the arithmetic is exact by construction rather than by column declaration.
Addition, subtraction and comparison of integers have no rounding rule to get wrong, SUM over the
column returns an Integer rather than a BigDecimal, and the value survives every serialisation
boundary in the stack intact. JSON has no decimal type, so an amount that travels through an API
payload as 19.99 arrives as a float somewhere; as 1999 it arrives as itself.
The database-side increment is exact for the same reason. A credit balance decremented with
Account.where(id: id).update_all("balance_cents = balance_cents - 500") is one atomic statement
with no rounding anywhere in it, which is the same property
Counter caches by hand leans on for a view counter, applied to a
number somebody will eventually audit.
Two costs, both real.
The range is smaller than people check. A 4-byte integer stops at 2147483647 cents, which is
21474836.47 in units, and Active Record refuses past it before PostgreSQL gets a chance:
ActiveModel::RangeError: 2147483648 is out of range for ActiveModel::Type::Integer with limit 4 bytes
Twenty-one million is a fine ceiling for a subscription price and a poor one for a cumulative
lifetime revenue column, an invoice in Indonesian rupiah, or anything denominated in a currency with
a large unit count. bigint costs four extra bytes and removes the question.
The second cost is that the column no longer says what it means. total_cents is self-documenting;
amount holding cents is a landmine, and the naming discipline has to be enforced by review because
nothing in the schema enforces it.
Where the precision actually leaks
A price typed into a form passes through four places that can change it, each with a different rule,
and only one of them is in your application code. The string arrives in params. Active Record
casts it to a Ruby object. PostgreSQL applies the column's scale on write. The view helper rounds it
again on the way to the screen.
Active Record's half is worth reading rather than assuming, because it rounds before any SQL is
built. In activemodel 8.1.3.1, lib/active_model/type/decimal.rb:
def cast_value(value)
casted_value = \
case value
when ::Float
convert_float_to_big_decimal(value)
when ::Numeric
BigDecimal(value, precision || BIGDECIMAL_PRECISION)
when ::String
begin
value.to_d
rescue ArgumentError
BigDecimal(0)
end
else
...
end
apply_scale(casted_value)
end
and, twenty lines down:
def apply_scale(value)
if scale
value.round(scale)
else
value
end
end
scale here is the column's scale, read from the schema. So assigning 19.999 to an attribute
backed by decimal(10,2):
19.999 -> 20.0
2.675 -> 2.68
0.30000000000000004 -> 0.3
All three of those happened in Ruby, in memory, before save. A spec asserting on the attribute
after assignment sees the rounded value and never proves the database agrees.
The same file explains the third line. convert_float_to_big_decimal caps the digits it keeps at
::Float::DIG + 1, which is 16, so the float noise in 0.1 + 0.2 is rounded off on the way into
the BigDecimal. A column declared plain decimal with no precision takes the value.to_d branch
instead and keeps whatever the float was.
Strings that arrive from a form
The cast is total. Every input produces a value, and nothing raises. Measured on a decimal(10,2)
attribute and an integer attribute, same string into both:
"abc" decimal-> 0.0 integer-> 0
"$19.99" decimal-> 0.0 integer-> 0
"19.99" decimal-> 0.1999e2 integer-> 19
"19,99" decimal-> 0.19e2 integer-> 19
"" decimal-> nil integer-> nil
" 20 " decimal-> 0.2e2 integer-> 20
The third row is the one to stare at. A form field meant to collect cents, receiving the string a
human actually types, produces 19 where 1999 was intended, and 19 is a legal price. The fourth
row is the same hazard wearing a French keyboard: "19,99" becomes 19.0, which is a plausible
price ninety-nine cents away from the right one, and is exactly the input a European customer
supplies.
Rails does have a guard, and it is not the cast. validates :total, numericality: true reads
total_before_type_cast, so it sees the original string:
total is 0.0, valid? false, errors: ["Total is not a number"]
before_type_cast: "abc"
"19,99" is rejected by the same validator for the same reason. Without the validator declared, the
row saves and the database holds 19.0. The protection against a silently halved price is one line
in the model that nothing reminds you to write, and it lives on the validation layer rather than on
the type layer where the damage happens.
Converting a validated string to cents has one more trap in it. The three obvious spellings disagree:
19.99 float.round=1999 BigDecimal.to_i=1999 BigDecimal.round=1999
2.675 float.round=268 BigDecimal.to_i=267 BigDecimal.round=268
1.005 float.round=100 BigDecimal.to_i=100 BigDecimal.round=101
(s.to_f * 100).round goes through a float and gets 1.005 wrong, because 1.005 * 100 is
100.49999999999999. (BigDecimal(s) * 100).to_i truncates and gets 2.675 wrong. Only
(BigDecimal(s) * 100).round is right on both.
Two rounding rules that disagree
Rounding a half is a policy decision, and Ruby, PostgreSQL's numeric and PostgreSQL's float8
do not all make the same one.
SELECT round(0.5::numeric), round(1.5::numeric), round(2.5::numeric),
round(0.5::float8), round(1.5::float8), round(2.5::float8);
n05 | n15 | n25 | f05 | f15 | f25
-----+-----+-----+-----+-----+-----
1 | 2 | 3 | 0 | 2 | 2
numeric rounds halves away from zero. float8 rounds them to even, which is why 0.5 goes down
and 1.5 goes up. Ruby's Float#round matches numeric rather than float8: 0.5.round is 1,
2.5.round is 3, and 2.5.round(half: :even) is 2 when you ask for the other rule explicitly.
BigDecimal#round also rounds half away from zero by default and takes :banker for the other.
So a total rounded in Ruby and the same total rounded in SQL can differ by one cent, in one direction, on exactly the values a discount calculation produces most often. Half-to-even exists because it removes the upward bias you get from always rounding halves up across a large number of rows, and accounting rules in several jurisdictions require one or the other by name. The point is not which is correct. The point is that picking one and applying it in both languages is a decision somebody has to make, and the default is "whichever layer happened to do the arithmetic".
Splitting an amount without losing a penny
Dividing money is where exactness stops being enough, because the right answer does not exist. A thousand cents split three ways is not divisible, and every representation has the same problem.
integer division: [333, 333, 333] sums to 999, lost 1
divmod remainder : [334, 333, 333] sums to 1000
The fix is divmod and a rule for who gets the remainder cent. Floats fail here in a way that looks
fine until the sum is checked: 19.99 / 3 is 6.663333333333333, three of those rounded to the
cent is 6.66 each, and the reconstructed total is 19.98.
The money gem ships this as Money::Allocation.generate(amount, parts, decimal_cutoff = true), with
the property stated in its own comment: "The total of the allocated amounts will always equal the
original amount." Parts can be a count for an even split or an array of weights for a proportional
one. The implementation earns that guarantee by popping parts off the end and computing each split
against a shrinking remaining_amount and parts_sum, so the last part processed divides by its own
weight and takes everything left. Reading the source, that is the first element of the returned
array: generate(1000, 3) gives [334, 333, 333], not [333, 333, 334].
This matters anywhere a single charge is divided: splitting a marketplace payment between a seller and a platform, prorating a subscription across a partial month, applying a percentage discount to the lines of an invoice and expecting the lines to add to the discounted total. Do the arithmetic per line and the sum drifts; allocate once and distribute the remainder and it cannot.
Formatting is a separate problem
number_to_currency is display only, and treating it as a conversion is a mistake. Measured
behaviour in Rails 8.1:
1999 cents / 100.0 -> $19.99
BigDecimal('19.99') -> $19.99
integer division 1999/100 -> $19.00
precision 0 on 2999 -> $30
the string '19.99' -> $19.99
the string 'abc' -> $abc
Three of those are worth naming. 1999 / 100 is Ruby integer division and renders $19.00, so the
.0 in / 100.0 is load-bearing and invisible in review. precision: 0 rounds rather than
truncating, so a 2999 cents price rendered at zero precision reads $30, which is a different
price than the one you charge. And number_to_currency("abc") returns "$abc" rather than raising
or returning nil, so a nil-ish or corrupted amount reaches the page as a dollar sign followed by
garbage.
The correct shape is: keep the exact value in the column, convert to a display value at the edge,
and pass the currency explicitly. Rails will happily format a US dollar amount with a euro sign if
you tell it to, because the helper has no idea what currency the number is in. The column has to
carry that, either as a sibling currency string or by the application only ever having one.
The currency with no cents
Dividing by 100 assumes every currency has a hundredth, and several do not. Stripe's currencies documentation states the API contract: "Enter 1099 to charge 10.99 USD (or any other two-decimal currency). Enter 10 to charge 10 JPY (or any other zero-decimal currency)."
So an amount_cents column populated from a Stripe webhook holds cents for USD and whole yen for
JPY, and the column name is a lie in the second case. Any code path that renders
amount_cents / 100.0 divides a yen amount by 100 and shows a price a hundred times too small.
Stripe's own special-cases table makes the rule worse than a lookup of currency to exponent. Its
entries for Icelandic krona and Ugandan shilling say both became zero-decimal currencies but must
still be sent as two-decimal values ending in 00 for backwards compatibility, so charging 5 ISK
means an amount of 500 and fractions are rejected. Hungarian forint and New Taiwan dollar go the
other way: two decimals are accepted for charges, while a manual payout amount has to be an integer
multiple of 100, which means a HUF balance of 10.45 can only be paid out as 10.
That table is the reference, and it moves, so read it rather than this paragraph before shipping a currency you have not sold in.
A currency's exponent is data, not arithmetic. The money gem carries that table, which is most of what you are buying when you install it; writing it yourself means a hash of currency code to exponent that somebody has to maintain, and the ISK row will be wrong.
How the boilerplate stores a price
The LaunchKit boilerplate stores prices as integer cents, with no money gem in its Gemfile, and
the choice is right for reasons partly unrelated to precision.
The plan price is not a column at all. settings.pricing is t.jsonb "pricing", default: {}, null:
false, and a plan's amount is a key inside it, read as @config["monthly_cents"] in
Pricing::Plan. That constraint decides the question on its own. A jsonb column stores numbers as
numeric and keeps them exactly, including trailing zeros, but Ruby's JSON parsing has no decimal
type to hand them back as. Through Active Record:
monthly_cents 2900 Integer
price 19.99 Float
exact "19.99" String
A decimal put into jsonb comes back a Float. A BigDecimal put into jsonb comes back a String,
because that is what BigDecimal#as_json produces, and the exactness survives only as long as
nobody does arithmetic with it. An integer is the only number that makes the round trip as itself.
The wider tradeoffs of keeping structured configuration in jsonb rather than in columns are
jsonb columns in Rails; for money specifically, jsonb narrows the
choice to one.
subscriptions.amount_cents and referrals.reward_cents are plain integer columns with the
suffix, matching what Stripe sends. transactions.amount is t.integer "amount", null: false, also
cents, without the suffix, which is the naming failure flagged earlier sitting in a real schema.
There is a counter-example in the same database, and it is the right one. ruby_llm_usages carries
t.decimal "input_cost", precision: 16, scale: 10 and five more like it. A per-token model price is
a genuine sub-cent quantity, cents would round every row to zero, and scale 10 is the honest
representation. Integer cents is the correct default and not a universal rule, and this schema
contains both halves of that.
Three places that arithmetic slips
Verified in the product rather than suspected, worst first.
Pricing::Plan#format_cents divides by 100 with no currency exponent lookup. Pricing::CURRENCIES
includes jpy and CURRENCY_SYMBOLS maps it to ¥, so the method is reachable with a zero-decimal
currency. Replicated verbatim:
2900 usd -> $29
2999 usd -> $29.99
5000 jpy -> ¥50
2900 chf -> 29 CHF
A 5000 yen plan advertises as ¥50. For contrast, money 7.1.1 asked the same question answers
Money.new(500, "JPY").format # => "¥500", because it looks the exponent up instead of assuming it.
The fix without a gem is a hash of currency code to exponent and a 10 ** exponent divisor, which
is twenty lines and a table somebody has to keep current.
Admin::ReferralsController#cents converts the founder's typed amount with
[(amount.to_f * 100).round, 0].max, the float spelling from the section above. Typing 1.005
stores 100 rather than 101. The exposure is one cent on a reward amount typed by one person, so
the severity is low and the line is still the wrong one.
Subscription#monthly_amount_cents normalises a yearly plan with amount_cents / 12, Ruby integer
division. A 29900 cent annual plan reports 2491 cents of monthly revenue, and twelve of those is
29892, so the dashboard's MRR understates by 8 cents per annual subscriber. That one is defensible:
MRR is an estimate, the truncation is consistent, and a fractional cent of monthly recurring revenue
is not a number anybody reconciles. Worth knowing it is there before somebody tries to tie the
dashboard to a bank statement.
money-rails, and when to install it
Both halves of the Ruby money stack are current. money 7.1.1 shipped 2026-07-31, MIT, Ruby 3.1 or
newer, 134 million downloads. money-rails 3.0.0 shipped 2026-01-14, MIT, also Ruby 3.1 or newer, 55
million downloads, depending on money ~> 7.0, monetize ~> 2.0, activesupport >= 7.0 and
railties >= 7.0. The repository is alive rather than merely published: the most recent commit at
the time of writing is 2026-09-08, and the CI matrix runs Ruby 3.1, 3.2, 3.3, 3.4, 4.0, head and
JRuby.
The gem's storage model is the one this post argued for, stated in its README: "Represents monetary
values as integers, in cents. This avoids floating point rounding errors." money-rails wraps that in
Active Record. The convention is the integer cents column, and monetize :price_cents on the model
gives you a price reader returning a Money, with t.monetize :price and add_monetize
:products, :price as migration helpers.
What you get beyond the column is the part you should not write yourself: the currency table with
every exponent in it, Money#allocate and #split, a configurable Money.rounding_mode,
Money.default_infinite_precision for the rate case, and Money.disallow_currency_conversion!,
after which adding 100 USD to 100 EUR raises Money::Bank::DifferentCurrencyError: No exchanging of
currencies allowed instead of quietly picking a rate.
What you pay is that every amount becomes a Money object, which is a type boundary that reaches
serializers, view specs, JSON builders and background job arguments. A Money in a job argument is
not a primitive and needs a serializer registered. That is a small, permanent tax spread over the
whole application.
The call, and what would change it
Store cents in an integer column, named with the _cents suffix, bigint for anything cumulative.
Do the arithmetic in integers or in the database. Convert to a display string at the view boundary
and nowhere else. For a single-currency product this is the whole solution and it costs no
dependency.
Use a numeric column with a real scale when the quantity has digits below the cent that mean
something: a rate, a per-unit cost, an exchange rate, a per-token price. Scale 2 is for prices;
anything else needs its own number.
Install money-rails when the application is genuinely multi-currency. Multi-currency means the exponent table, the allocation problem and the "never add these two amounts" rule all arrive at once, and hand-rolling any of the three is how a rounding bug gets into an invoice.
What would flip this. A Rails release that shipped a first-class currency-aware attribute type covering exponent lookup and formatting would delete the middle of this post, and there is no sign of one. On the other side, a schema that outgrew the integer range, or a second currency, is a reason to stop hand-rolling on the day it appears rather than the day after.
The position has a cost and it should be said. Integer cents means every boundary in the application
converts, and a single forgotten / 100 renders a price a hundred times wrong in a way no type
error catches. money-rails makes that class of bug impossible and charges an object wrapper for it.
A single-currency product pays the conversion tax because the wrapper is the larger cost; a
multi-currency one pays the wrapper because the conversion tax is unbounded.
What this post does not cover
Tax. VAT and sales tax rounding rules are jurisdictional, they specify per-line or per-invoice rounding by law rather than by preference, and they deserve their own post rather than a paragraph at the end of this one.
Also absent: currency conversion and where the rate comes from, which is an operational problem
before it is a numeric one; the Money::Bank implementations that go with it; PostgreSQL's
numeric support for NaN and infinity, which are legal values in that type and have no meaning as
a price; MySQL, where DECIMAL behaves comparably but the overflow and rounding messages are not
the ones measured here; and any timing benchmark of numeric against integer, because the
manual's "very slow compared to the integer types" is a real warning whose magnitude depends
entirely on row count and query shape, and a number from this laptop would not transfer to yours.
Comments
No comments yet. Be the first.