Skip to content
By Jonathan Miles

Diving into the Ruby source code: BigDecimal rounding options

If you're a developer and I ask you about numeric rounding, you'd probably think of something like this:

  1. Jump to the decimal place that we are rounding to
  2. Look at the digit to the right of it
  3. Based on that digit and our rounding rule, make a decision

We can round up if we see non-zero in that spot (also known as "ceil"), or we can round down regardless of the digit (aka "floor" or truncate), or we can round >= 5 up and truncate if < 5 (banker's rounding).

All pretty straightforward stuff, but Ruby's BigDecimal class, often used for currencies, has a few other rounding options and if your expectation of how rounding works is what I just described above (as mine was) then the behaviour might be a bit surprising to you.

The journey begins

BigDecimal has a ROUND_HALF_DOWN option for BigDecimal#round. Let's see what the documentation says:

 ROUND_HALF_DOWN, :half_down

    round towards the nearest neighbor, unless both neighbors are equidistant, in which case round towards zero.

Hmm, yeah, okay. But what does that really mean? The actual behaviour of the method may be a bit counter-intuitive. Let's start with the other rounding types and see how things stack up.

# require 'bigdecimal'
=> true
# BigDecimal.new(1.551, 10).round(1, :up).to_f
=> 1.6
# BigDecimal.new(1.551, 10).round(1, :down).to_f
=> 1.5

So far so good. We're creating a BigDecimal here. I've given it a float precision of 10 digits but only so we don't have to worry about it being an issue. Then we call #round. We're rounding to one decimal place. Finally, I'm casting back to a float as I find the default 0.15e1 format less readable.

The plot thickens

Rounding up and down to one decimal place behave as one might expect. What about their half up/down siblings?

BigDecimal.new(1.551, 10).round(1, :half_up).to_f
=> 1.6
BigDecimal.new(1.551, 10).round(1, :half_down).to_f
=> 1.6

Hmm, not quite what I was expecting. But what if we change the number we're using?

BigDecimal.new(1.55, 10).round(1, :half_up).to_f
=> 1.6
BigDecimal.new(1.55, 10).round(1, :half_down).to_f
=> 1.5

That's more like it. But why is a rounding method called "half down" rounding 1.55 down, but 1.551 up? To find out, we can check out the actual source code for the #round method.

The part we care about is down around line 5200 (I just searched "half_down" to find it):

...
    /* now fracf = does any positive digit exist under the rounding position?
       now fracf_1further = does any positive digit exist under one further than the
       rounding position?
       now v = the first digit under the rounding position */

    /* drop digits after pointed digit */
    memset(y->frac + ix + 1, 0, (y->Prec - (ix + 1)) * sizeof(BDIGIT));

    switch (f) {
          case VP_ROUND_DOWN: /* Truncate */
      break;
          case VP_ROUND_UP:   /* Roundup */
      if (fracf) ++div;
      break;
          case VP_ROUND_HALF_UP:
      if (v>=5) ++div;
      break;
          case VP_ROUND_HALF_DOWN:
      if (v > 5 || (v == 5 && fracf_1further)) ++div;
      break;
          case VP_ROUND_CEIL:
      if (fracf && BIGDECIMAL_POSITIVE_P(y)) ++div;
      break;
          case VP_ROUND_FLOOR:
      if (fracf && BIGDECIMAL_NEGATIVE_P(y)) ++div;
      break;
    case VP_ROUND_HALF_EVEN: /* Banker's rounding */
...

If you're not used to C this might be a bit jarring (in fact, even if you are used to C the Ruby codebase has its own style).

We have some comments at the top to help us understand what the variables represent. And we can ignore the memset line (the comment above indicates what it's doing).

The meat of what we want to look at is in the switch-case statement. From the VP_ROUND_DOWN and VP_ROUND_UP cases we can deduce that doing nothing results in truncation, while ++div results in rounding-up.

In these simpler cases the rules are pretty clear - :down will always truncate no matter what. :up will round-up if there is a positive digit under the rounding position (i.e. not 0).

Armed with this knowledge, we can take a look at the :half_up and :half_down variants.

In the :half_up case we see that it rounds up if v (the digit one spot right of where we are rounding to) is >= 5. Our 'rounding position' is the first decimal place, so v is the second decimal digit. Indeed this matches what we see:

# BigDecimal.new(1.55, 10).round(1, :half_up).to_f
=> 1.6
# BigDecimal.new(1.54, 10).round(1, :half_up).to_f
=> 1.5

If the second digit is >= 5 then :half_up will round it up, otherwise it will truncate.

So what about :half_down?

We can see that if the digit is > 5 then it behaves the same as :half_up, but there is a special case if the digit is 5. If the digit is 5 then we will only round up if fracf_1further is true. This is true whenever there are any positive digits after the rounding position. Hopefully the behaviour we've seen is now a bit clearer:

# BigDecimal.new(1.56, 10).round(1, :half_down).to_f
=> 1.6
# BigDecimal.new(1.55, 10).round(1, :half_down).to_f
=> 1.5
# BigDecimal.new(1.55000001, 10).round(1, :half_down).to_f
=> 1.6

Our "rounding position" is 1, so the digit we look at is the second after the decimal place. When it is greater than 5 we round up (same as :half_up). When it's 5 or less we truncate. But if it is 5 and there's another digit further down (no matter how far down), then we round up.

If your thought about rounding was single-digit focused like I described at the start, then this behaviour is unexpected. For this :half_down rounding you have to think of the number, not just a series of digits. "Half down" means it will round down half (0.5) or less. 0.500001 is greater than 0.5, so it gets rounded up. Once I thought of it this way the behaviour made sense.

Summary

This was a fairly trivial example, but the general idea of jumping into the source code can save a lot of headaches, particularly if documentation is lacking. This is one of the many benefits of open source software, which we get to enjoy both as Ruby and Rails developers. And don't be put off by being unfamiliar with the source language - I don't understand most of the BigDecimal C code (or the Ruby codebase at all for that matter), but a keyword search and a little deduction may be all you need.

Latest Articles by Our Team

Our expert team of designers and developers love what the do and enjoy sharing their knowledge with the world.

We Hire Only the Best

reinteractive is Australia’s largest dedicated Ruby on Rails development company. We don’t cut corners and we know what we are doing.

We are an organisation made up of amazing individuals and we take pride in our team. We are 100% remote work enabling us to choose the best talent no matter which part of the country they live in. reinteractive is dedicated to making it a great place for any developer to work.

Free Community Workshops

We created the Ruby on Rails InstallFest and Ruby on Rails Development Hub to help introduce new people to software development and to help existing developers hone their skills. These workshops provide invaluable mentorship to train developers, addressing key skills shortages in the industry. Software development is a great career choice for all ages and these events help you get started and skilled up.

  • Webinars

    Webinars

    Webinars are our online portal for tips, tricks and lessons learned in everything we do. Make the most of this free resource to help you become a better developer.

    Learn more about webinars

  • Installfest

    Installfest

    The Ruby on Rails Installfest includes a full setup of your development environment and step-by-step instructions on how to build your first app hosted on Heroku. Over 1,800 attendees to date and counting.

    Learn more about Installfest

  • Development Hub

    Development Hub

    The Ruby on Rails Development Hub is a monthly event where you will get the chance to spend time with our team and others in the community to improve and hone your Ruby on Rails skills.

    Learn more about Development Hub

Get the “reinteractive Review” Monthly Email