Rounding numbers in Python 2 and Python 3

There are two changes to rounding between Python 2 and Python 3 that programmers should watch out for when migrating. I realize that Python 3 has been out for ten years, but I also know there are still people using Python 2 so there’s a chance this info is still useful to someone.

Ties away from zero vs. ties to nearest even

Python 2’s rounding behavior is “round to nearest, ties away from zero.” Python 3 uses “round to nearest, ties to nearest even,” sometimes called “banker’s rounding.” This change manifests in both the built-in round() function and in decimal.Decimal.quantize().

I think this change is great. Ties-to-nearest-even is a great choice. But if you want the Python 2 behavior in Python 3 you’ll have to do some work. For float it’s easiest to make your own function. See this example (starting after “if you need the Python 2 behavior”). For Decimal you can specify decimal.ROUND_HALF_UP when calling quantize().

I wish Python 3 round() took a rounding scheme parameter because the alternatives are cumbersome. Also it’s hard to mimic round()‘s behavior exactly, especially for positive/negative numbers and positive/negative values of ndigits.

round() does or does not cast Decimals to floats

This change is more subtle. Python 2 round() always does floating-point arithmetic, even if you pass in a Decimal. But Python 3 round() delegates to the class of the value being passed in. This means Decimals rounded with round() are susceptible to floating-point imprecision in Python 2 but not in Python 3.

I think this change is great, too. Let me give you an example from Python 2 to explain why. First let’s round the float 1.006 to 2 decimal places:

>>> round(1.006, 2)

Makes sense. Now let’s try rounding 1.005 to 2 decimal places. We expect the same result because Python 2 rounds ties away from zero (“up” in this case, since the number is positive).

>>> round(1.005, 2)

That’s weird. What happened? It turns out 1.005 can’t be represented exactly as a floating-point number. You can see this if you convert 1.005 from float to Decimal:

>>> decimal.Decimal(1.005)

Oof. But we’re using floating-point numbers and they’re notorious for this, so we only have ourselves to blame. Conscientious programmers would avoid this problem by using decimal.Decimal. Let’s try it:

>>> round(decimal.Decimal('1.005'), 2)

Oof—still wrong. This time because round() converts to float. That’s an easy mistake to make. Honestly I think it would have been better if Python 2 round() raised an exception if called with a Decimal, because implicitly converting to float with the possibility of incorrect results due to floating-point imprecision is likely not what the programmer wanted.

So what’s the right way to round a Decimal in Python 2? This awkward incantation:

>>> decimal.Decimal('1.005').quantize(decimal.Decimal('0.01'), decimal.ROUND_HALF_UP)

Yeah. Not great. It’s definitely an improvement being able to use round() in Python 3 (provided you’re ok with ties-to-nearest-even, anyway).

Guidelines for working with numbers with fractions

  • Use decimal.Decimal unless you need performance and know that imprecision is acceptable, in which case use float.
  • Decide what rounding scheme is appropriate for your application (ties away from zero, ties to nearest even, etc) and make sure your code is using it.
  • Don’t use round() with decimal.Decimal in Python 2.

An unrelated complaint

This isn’t Python-specific, but I hate that floating-point numbers are a default. In the world we live in, where programmers may not have a computer science background and may not be familiar with the pitfalls of floating-point arithmetic, I think there is no reasonable default. It’s too easy for programmers to get behavior they don’t want. I like strict languages and I think we’d be better off if languages forced programmers to specify either floating-point or decimal.Decimal/BigDecimal/etc for every number that has a fraction.

This entry was posted in Computers. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *