LaunchKit
← All posts
· 19 min read · by The LaunchKit team · 0 views

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.