Rounding in Python

Wednesday, September 28, 2022

In software engineering, there are two principles that often come into conflict. The first one is the principal of least surprise. The second one is doing the right thing. These come into conflict when the usual thing that people do is in fact the wrong thing. A particular example of this is the behavior of rounding.

In school we were taught that rounding is always done in one particular way. When you round a number it goes toward the nearest hole number, but if it ends in 5, than it goes toward the higher one. For example, 1.3 rounds to 1, and 1.7 rounds to 2. And we were taught that 1.5 rounds to 2, and 2.5 goes to 3.

Because this is the way that we were taught rounding works, it can be quite surprising when rounding works differently. In fact, there are a number of different ways to round numbers. The Wikipedia article on rounding gives no fewer than 14 different methods of rounding. Fortunately, with computers, we expect fewer: The IEEE 754 standard for floating point numbers defines five rounding rules.

Those five rules, along with their Python equivalents, are:

  • round toward infinity (math.ceil)
  • round toward negative infinity (math.floor)
  • round toward zero (math.trunc)
  • round half-to-even (round)
  • round half-away-from-0 (no built-in equivalent that I found)

Sneaking in there is round, defined as rounding half-to-even. A lot of people are surprised by this the first time they call round with Python! It definitely is surprising if you are expecting the "round half toward higher numbers" behavior.

>>> round(1.5)
2
>>> round(2.5)
2

So that we can see that Python's rounding behavior the principal least surprise. Why is this the default behavior?

There really two good reasons have rounding half-to-even as the default:

  1. It's more likely what you actually want. When you always round up, you introduce bias across a lot of rounding operations. When you sum up the rounded values, you'll have a little bit less bias in the final sum.

    In fact, some of the Python docs mention that floating point math guarantees rely on the half-even rounding in some cases:

    The algorithm’s accuracy depends on IEEE-754 arithmetic guarantees and the typical case where the rounding mode is half-even.

  2. Having it as the default is... the standard. The IEEE 754 standard for floating point numbers requires this as the default.

    From the standard:

    The roundTiesToEven rounding-direction attribute shall be the default rounding-direction attribute for results in binary formats.

Of course, the standard also requires that five different rounding mechanisms are available to users. Python does make those available, but only on the decimal type. The other expected behavior can typically be implemented using floor, ceil, and trunc. Of course, that's extra work and room to get things wrong.

At the end of the day, if your application depends on specific rounding behavior than you should probably verify what behavior your libraries give you before you use them. And, of course, Python does give you the functionality you need in the decimal package. To quote the docs:

The decimal module provides support for fast correctly rounded decimal floating point arithmetic.

It gives you all the rounding modes you want, more exact representations, and less error introduced into arithmetic. When you care about the details a lot and your application depends on them, you can get the rounding you want! And when you don't care about it, but just want the thing that probably works, Python gives you a reasonable default.

Ultimately, I think that the Python and choice here the break ties toward even numbers is a sensible choice, made stronger by the presence of the decimal package. Managing these tradeoffs is difficult, and the Python developer who chose this rounding behavior made the right call. I, for one, would rather have people accidentally do the right thing and be surprised, rather than avoid surprise so that people can do the wrong thing.


Extra content time! I did some sleuthing to see where and when this behavior came from. This is all "as far as I can tell"—if there are errors, please let me know nicely.

When was the round function added to Python?

It was added in commit 9e51f9bec85 by Guido van Rossum himself. The intial implementation:

static object *
builtin_round(self, args)
	object *self;
	object *args;
{
	extern double floor PROTO((double));
	extern double ceil PROTO((double));
	double x;
	double f;
	int ndigits = 0;
	int sign = 1;
	int i;
	if (!getargs(args, "d", &x)) {
		err_clear();
		if (!getargs(args, "(di)", &x, &ndigits))
			return NULL;
	}
	f = 1.0;
	for (i = ndigits; --i >= 0; )
		f = f*10.0;
	for (i = ndigits; ++i <= 0; )
		f = f*0.1;
	if (x >= 0.0)
		return newfloatobject(floor(x*f + 0.5) / f);
	else
		return newfloatobject(ceil(x*f - 0.5) / f);
}

It looks like it was initially rounding half-away-from-zero! And it's pretty easy to read.

This was changed in 2007 by Guido van Rossum, Alex Martelli, and Keir Mierle in commit 2fa33db12b8cb6ec1dd1b87df6911e311d98457b. Here you can see the now-more-complex implementation:

static PyObject *
float_round(PyObject *v, PyObject *args)
{
#define UNDEF_NDIGITS (-0x7fffffff) /* Unlikely ndigits value */
	double x;
	double f;
	double flr, cil;
	double rounded;
	int i;
	int ndigits = UNDEF_NDIGITS;

	if (!PyArg_ParseTuple(args, "|i", &ndigits))
		return NULL;

	x = PyFloat_AsDouble(v);

	if (ndigits != UNDEF_NDIGITS) {
		f = 1.0;
		i = abs(ndigits);
		while  (--i >= 0)
			f = f*10.0;
		if (ndigits < 0)
			x /= f;
		else
			x *= f;
	}

	flr = floor(x);
	cil = ceil(x);

	if (x-flr > 0.5)
		rounded = cil;
	else if (x-flr == 0.5)
		rounded = fmod(flr, 2) == 0 ? flr : cil;
	else
		rounded = flr;

	if (ndigits != UNDEF_NDIGITS) {
		if (ndigits < 0)
			rounded *= f;
		else
			rounded /= f;
		return PyFloat_FromDouble(rounded);
	}

	return PyLong_FromDouble(rounded);
#undef UNDEF_NDIGITS
}

Notably, we can see from the tags on GitHub that this was present in Python 2.7 and in Python 3.0. So, this behavior has been around for quite a while. There was quite some discussion about it in the Python bug tracker at the time.

Well, our little historical escapade is over! I still agree with the folks in that discussion that round half-to-even is the right behavior.

Later! 👋


There's a companion post to this one over on my friend John's blog! You can read his post for another take on Python's rounding behavior.


If you have comments, questions, or feedback, please email my public inbox. To get new posts, please use my RSS feed.