What is BigDecimal, and Why Do I Care?

Ever wonder why Ruby's BigDecimal works?

Try this in your Ruby console:

0.1 + 0.2 == 0.3
# => false

This isn’t a Ruby quirk. It’s a fundamental limitation of how computers represent decimal numbers using binary floating-point arithmetic. The problem affects every programming language that uses IEEE 754 floating-point numbers - which is nearly all of them.

In fact, you can find an exhaustive reference at the cleverly named https://0.30000000000000004.com/, from ABAP to zsh.

You’ve likely heard that you should use decimal data types instead of floating-point numbers for handling, say, currency values. (If you haven’t heard, you have now.)

Spoilers: in this article, we will discuss the implementation of BigDecimal gem - part of the Ruby standard library, and Ruby’s solution to this particular problem. We’ll do so by implementing progressively more complex versions of the algorithm, and we’ll eventually compare our results with BigDecimal itself. In truth, the algorithm behind BigDecimal is more familiar than you might guess - there’s a lot of special cases and optimizations, but we can get an intuitive feel for it pretty quickly.

Why Not Float?

Like most languages, Ruby’s Float type uses binary floating-point arithmetic. Also like most languages, Ruby Float types for any literal value that’s 1) numeric and 2) has a . in it.

Like so:

irb> 1.class
=> Integer
irb> 1.0.class
=> Float

This can sometimes have confusing results; for example:

2 / 3
# => 0

2 / 3.0
# => 0.6666666666666666

2.0 / 3
# => 0.6666666666666666

If you’ve ever seen someone write an seemingly unnecessary .0 in a numeric literal, it’s likely because they were trying to force a float division instead of an integer one. Of course, this isn’t the only way - any way you cast to a float works - but its likely the most common.

Anyway, while efficient, it cannot precisely represent many decimal fractions:

price = 10.0
tax_rate = 0.0825
total = price * tax_rate
# => 0.8250000000000001

# Rounding errors compound
(0.1 + 0.2) * 3
# => 0.9000000000000001

For many purposes, this isn’t really a problem; a tiny error doesn’t effect many types of calculations. The big problem is expectations; people don’t expect binary rounding errors - they expect decimal rounding errors.

Fortunately, that’s just what BigDecimal provides.

BigDecimal provides arbitrary-precision decimal arithmetic. It represents numbers in decimal form with user-specified precision, eliminating floating-point rounding errors - like so: :

require 'bigdecimal'

price = BigDecimal("10.0")
tax_rate = BigDecimal("0.0825")
total = price * tax_rate
# => 0.825e0 (exactly 0.825)

# Comparisons work as expected
BigDecimal("0.1") + BigDecimal("0.2") == BigDecimal("0.3")
# => true

Of course, this leads to a logical question - one posed in the title of this article.

A Naive Approach

An naive solution might be storing the entire value as a decimal string. You could, indeed, use an algorithm similar to what children are taught to do in schoool - or perhaps what some of you do in your head, at least until you purchased a mobile phone and realized you could let your arithmetic skills (and quite possibly civilization) decline.

Specifically, something like this:

  • First, line up the digits.
  • Start looping through the rightmost digits.
  • Add them together.
  • If the result is greater than 10, subtract 10 and set carry to 1; if not, set carry to 0.
  • Write the result to the output.
  • Continue the loop, adding one whenever the carry bit is set.
  • At the end of the loop, if the carry bit is set, add a leftmost one.

As noted, this is likely how you learned to do multiple digit arithmetic in school; there are other algorithms, such as for use with an abacus or for adding binary digits with logic gates, but this is a simple and familiar one.

Implemented in Ruby, that looks something like this:

class DecimalArithmetic
  def initialize(value)
    @value = value.to_s
  end

  def +(other)
    other = DecimalArithmetic.new(other) unless other.is_a?(DecimalArithmetic)
    a_str, b_str = normalize(@value, other.value)
    result = []
    carry = 0

    (a_str.length - 1).downto(0) do |i|
      digit_a = a_str[i] == '.' ? nil : a_str[i].to_i
      digit_b = b_str[i] == '.' ? nil : b_str[i].to_i

      if digit_a.nil?
        result.unshift('.')
        next
      end

      sum = digit_a + digit_b + carry
      carry = sum / 10
      result.unshift((sum % 10).to_s)
    end

    result.unshift(carry.to_s) if carry > 0

    # Remove leading zeros (except before decimal or if it's the only digit)
    while result.length > 1 && result.first == '0' && result[1] != '.'
      result.shift
    end

    # Remove trailing zeros after decimal point
    if result.include?('.')
      while result.last == '0'
        result.pop
      end
      # Remove decimal point if no fractional part remains
      result.pop if result.last == '.'
    end

    result_str = result.join('')
    DecimalArithmetic.new(result_str)
  end

  def to_s
    @value
  end

  protected

  attr_reader :value

  private

  def normalize(a, b)
    a_parts = a.split('.')
    b_parts = b.split('.')

    a_int, a_dec = a_parts[0], a_parts[1] || ''
    b_int, b_dec = b_parts[0], b_parts[1] || ''

    max_int = [a_int.length, b_int.length].max
    max_dec = [a_dec.length, b_dec.length].max

    a_int = a_int.rjust(max_int, '0')
    b_int = b_int.rjust(max_int, '0')
    a_dec = a_dec.ljust(max_dec, '0')
    b_dec = b_dec.ljust(max_dec, '0')

    a_normalized = max_dec > 0 ? "#{a_int}.#{a_dec}" : a_int
    b_normalized = max_dec > 0 ? "#{b_int}.#{b_dec}" : b_int

    [a_normalized, b_normalized]
  end
end

Note that for demonstration purposes, this only implements the + operator - the code for the others is, as you no doubt imagine, more complex. (We also skipped handling negative numbers, validation, and a few other things, but this is already long enough as it is!)

Anyway, the first thing our code for the + operator does is call the normalize method. This ensures that the two strings are the same length by padding the beginning and end of the integer and decimal part of the shorter string; this is vital, because the rest of our code assumes that the Nth digit of string A is the same scale as the Nth digit of string B.

After that, our code proceeds straightforwardly, at least more or less. It loops through each digit, starting with the rightmost (i.e. smallest) digit. Much like you may have done as a child, it adds them together; it then divides the result by ten and carries that over to the next digit. You’ll note that, similar to our code earlier, this is / 10, not / 10.0; this means integer, not float, division; we could also do something like /10.0).floor, which does float division and then just rounds down, but that’s a bit more complex and not necessary.

The remainder is then used as the digit for this position.

As we go through the loop, when we encounter a ., we skip to the next digit. Since we have lined up the numbers using normalize, the . should occur on both numbers simultaneously.

Once we’ve looped through all the digits, we’re almost done. If we have a carry digit left, that means we need to add a leading 1; after that, we drop any unnecessary leading zeros or trailing zeros, join the digits together, and we’re good to go.

(As an aside, a particularly pedantic reader might note I have in this text and the code uses “digit” to refer to both numbers and the ’.’ marker, which is, technically, not a digit; hopefully you’ll forgive my lack of interest and repeatedly writing “digit and decimal place” everywhere.)

Lets take this class for a quick spin:

DecimalArithmetic.new('3.5') + 2
=> #<DecimalArithmetic:0x00007f4a780ba198 @value="5.5">

Not terribly exciting, but it does work; you can try a variety of positive values and you should get the expected results.

One might wonder - what’s the performance look like?

Let’s find out:

require 'benchmark'
require 'bigdecimal'
require_relative 'decimalarthimetic'

values = [
  ["0.1", "0.2"],
  ["123.456", "789.012"],
  ["0.0001", "0.9999"]
]

iterations = 4096
puts "Decimal Arithmetic Benchmark - #{iterations} iterations"
puts "=" * 40

values.each do |a_str, b_str|
  puts "#{a_str} + #{b_str}"

  decimal_a = DecimalArithmetic.new(a_str)
  decimal_b = DecimalArithmetic.new(b_str)
  bigdecimal_a = BigDecimal(a_str)
  bigdecimal_b = BigDecimal(b_str)
  float_a = a_str.to_f
  float_b = b_str.to_f

  Benchmark.bm(15) do |x|
    x.report("DecimalArithmetic") { iterations.times { decimal_a + decimal_b } }
    x.report("BigDecimal") { iterations.times { bigdecimal_a + bigdecimal_b } }
    x.report("Float") { iterations.times { float_a + float_b } }
  end
  puts
end

Running our script, we get this result:

Decimal Arithmetic Benchmark - 4096 iterations
========================================
0.1 + 0.2
                       user     system      total        real
DecimalArithmetic  0.019192   0.000888   0.020080 (  0.020264)
BigDecimal         0.000839   0.000000   0.000839 (  0.000843)
Float              0.000101   0.000000   0.000101 (  0.000101)

123.456 + 789.012
                       user     system      total        real
DecimalArithmetic  0.028990   0.000010   0.029000 (  0.029080)
BigDecimal         0.000831   0.000000   0.000831 (  0.000834)
Float              0.000098   0.000000   0.000098 (  0.000097)

0.0001 + 0.9999
                       user     system      total        real
DecimalArithmetic  0.028436   0.000000   0.028436 (  0.028531)
BigDecimal         0.001098   0.000000   0.001098 (  0.001101)
Float              0.000102   0.000000   0.000102 (  0.000102)

Your exact numbers will vary, of course, but we can observe a few things here; firstly, that our DecimalArithmetic approach is very, very slow - in the first test, 24 times slower than BigDecimal and 200 times slower than Float. Of course, BigDecimal is still slower than Float - 8.3x slower or so - but dramatically faster than our String-based implementation.

The reason for that, of course, is that this is not, in fact, how BigDecimal works - as you likely already surmised.

Biggish Decimals

BigDecimal doesn’t work with strings - thats not a particularly fast algorithm - it works with arrays of numbers.

Note, though, that for performance reasons, BigDecimal doesn’t work with arrays of base 10 numbers - instead, it works with arrays of base 10000 or base 1000000000 numbers, depending on whether you are on a 32-bit or 64-bit system. Both of these numbers, though, are powers of 10 - and they fit neatly within a 32 bit or a 64 bit integer.

This means we can use CPU native integer arithmetic on each “chunk” of digits - 10000 or 1000000000 - and get significant performance increases.

Now, the actual source code of BigDecimal is largely in C and is quite optimized - which is great for performance, of course, but a somewhat tough read. For the sake of discussion, lets “re-implement” a simplified version of BigDecimal - call it BiggishDecimal.

As with the DecimalArithmetic class before, we will make some adjustments to keep it short: we’ll only implement addition, we’ll stick with base-10000, we’ll ignore sign, and so on.

Lets take a look at the class:


class BiggishDecimal
  DECIMAL_PLACES = 50
  BASE = 10000
  BASE_DIGITS = 4

  def initialize(value)
    @value = value.to_s
    @mantissa, @exponent = parse_number(@value)
  end

  def +(other)
    other = BiggishDecimal.new(other) unless other.is_a?(BiggishDecimal)

    result_mantissa, result_exponent = add_magnitudes(@mantissa, @exponent, other.mantissa, other.exponent)
    BiggishDecimal.from_parts(result_mantissa, result_exponent)
  end

  def to_s
    return "0" if @mantissa.all?(&:zero?)

    # Convert mantissa array to individual digit array
    digits = []
    @mantissa.reverse.each do |segment|
      # Extract digits from segment using integer arithmetic
      temp_digits = []
      s = segment
      BASE_DIGITS.times do
        temp_digits.unshift(s % 10)
        s /= 10
      end
      digits.concat(temp_digits)
    end

    # Remove leading zeros
    while digits.length > 1 && digits.first == 0
      digits.shift
    end

    # Calculate decimal position
    decimal_pos = digits.length + @exponent * BASE_DIGITS

    # Build result string based on decimal position
    if decimal_pos <= 0
      # Number is 0.00...digits
      result_parts = ["0.", "0" * (-decimal_pos)]
      digits.each { |d| result_parts << d.to_s }
    elsif decimal_pos >= digits.length
      # Number is digits followed by zeros
      digits.each { |d| result_parts = (result_parts || []) << d.to_s }
      result_parts << "0" * (decimal_pos - digits.length)
    else
      # Number has decimal point in the middle
      result_parts = []
      digits[0...decimal_pos].each { |d| result_parts << d.to_s }
      result_parts << "."
      digits[decimal_pos..-1].each { |d| result_parts << d.to_s }
    end

    result = result_parts.join

    # Clean up trailing zeros and decimal point
    if result.include?('.')
      result = result.sub(/0+$/, '')
      result = result.sub(/\.$/, '')
    end

    result.empty? ? "0" : result
  end

  protected

  attr_reader :mantissa, :exponent

  def self.from_parts(mantissa, exponent)
    instance = allocate
    instance.instance_variable_set(:@mantissa, mantissa)
    instance.instance_variable_set(:@exponent, exponent)
    instance
  end

  private

  def parse_number(str)
    # Split on decimal point
    if str.include?('.')
      parts = str.split('.')
      integer_str = parts[0] || "0"
      fractional_str = parts[1] || ""
    else
      integer_str = str
      fractional_str = ""
    end

    # Convert strings to digit arrays immediately
    integer_digits = integer_str.chars.map(&:to_i)
    fractional_digits = fractional_str.chars.map(&:to_i)

    # Remove leading zeros from integer part
    while integer_digits.length > 1 && integer_digits.first == 0
      integer_digits.shift
    end

    # Pad fractional digits on the right to make divisible by BASE_DIGITS
    if fractional_digits.length > 0
      frac_padding = (BASE_DIGITS - (fractional_digits.length % BASE_DIGITS)) % BASE_DIGITS
      fractional_digits += [0] * frac_padding
    end

    # Combine digit arrays
    all_digits = integer_digits + fractional_digits

    # Pad on the left to make divisible by BASE_DIGITS
    padding = (BASE_DIGITS - (all_digits.length % BASE_DIGITS)) % BASE_DIGITS
    all_digits = [0] * padding + all_digits

    # Build mantissa from right to left by grouping digits into BASE segments
    mantissa = []
    i = all_digits.length - BASE_DIGITS
    while i >= 0
      segment = 0
      BASE_DIGITS.times do |j|
        segment = segment * 10 + all_digits[i + j]
      end
      mantissa << segment
      i -= BASE_DIGITS
    end

    # Calculate exponent based on number of fractional segments
    fractional_segments = fractional_digits.length / BASE_DIGITS
    exponent = -fractional_segments

    # Remove trailing zeros from mantissa (high-order zeros)
    while mantissa.length > 1 && mantissa.last == 0
      mantissa.pop
    end

    [mantissa, exponent]
  end

  def add_magnitudes(left_mantissa, left_exponent, right_mantissa, right_exponent)
    aligned_left, aligned_right, common_exponent = align_operands(
      left_mantissa, left_exponent,
      right_mantissa, right_exponent
    )

    result_mantissa = []
    carry = 0
    max_length = [aligned_left.length, aligned_right.length].max

    max_length.times do |i|
      left_digit = aligned_left[i] || 0
      right_digit = aligned_right[i] || 0

      sum = left_digit + right_digit + carry
      result_mantissa << (sum % BASE)
      carry = sum / BASE
    end

    if carry > 0
      result_mantissa << carry
    end

    [result_mantissa, common_exponent]
  end

  def align_operands(left_mantissa, left_exponent, right_mantissa, right_exponent)
    if left_exponent < right_exponent
      padding = [0] * (right_exponent - left_exponent)
      [padding + left_mantissa, right_mantissa, right_exponent]
    elsif right_exponent < left_exponent
      padding = [0] * (left_exponent - right_exponent)
      [left_mantissa, padding + right_mantissa, left_exponent]
    else
      [left_mantissa, right_mantissa, left_exponent]
    end
  end
end

Lets take it for a quick spin:

(BiggishDecimal.new('3.0') + BiggishDecimal.new('0.5')).to_s
=> "3.5"

Fundamentally, this actually works similar to our DecimalArithmetic script - pad the two numbers until all the digits line up, loop through them, add and carry, and then reassemble.

There’s a slight wrinkle with the “align_operands” function: we’re not storing the whole number in the array. We’re also storing number as exponent and mantissa - in other words, the exponent is the amount of columns to shift our entire number left or right by, and the mantissa is what remains. Therefore, our alignment function needs to take into account both the mantissa and the exponent.

It does this by first comparing the exponents - if one side is 10000 bigger, it creates an array with the smaller size padded with zeros to fit the larger one. If they have the same exponent, it just passes them through.

After that, our loop is pretty similar to the DecimalArithmetic:

    max_length.times do |i|
      left_digit = aligned_left[i] || 0
      right_digit = aligned_right[i] || 0

      sum = left_digit + right_digit + carry
      result_mantissa << (sum % BASE)
      carry = sum / BASE
    end

Take a look at the following code from BigDecimal.c:

    while (a_pos > 0) {
	c->frac[--c_pos] = a->frac[--a_pos] + carry;
	if (c->frac[c_pos] >= BASE) {
	    c->frac[c_pos] -= BASE;
	    carry = 1;
	}
	else {
	    carry = 0;
	}
    }
    if (c_pos) c->frac[c_pos - 1] += carry;
    goto Exit;

Look familar? The syntax is different - Ruby doesn’t even have a pre-decrement operator, which frankly I count as a feature - but the idea of looping, adding, carrying any leftovers, remains.

Of course, BigDecimal has a lot more features and special cases then we’ve shown here; it supports variable precision and a lot more operators than we’ve shown - but it’s also not mystical or impossible to understand.