Three lines that catch nearly everybody. Read the first and third together before you decide they are typos.
print(round(0.5)) # -> 0 not 1 print(round(1.5)) # -> 2 print(round(2.5)) # -> 2 not 3 # Ties go to the *even* number - 'banker's rounding'. Over many values it # cancels out instead of drifting upward, which is why finance uses it. import math print(math.floor(0.5 + 0.5), math.floor(2.5 + 0.5)) # -> 1 3 (half-up)
- Ties break to even, on purpose
Always rounding .5 upward biases a long column of numbers slightly high. Breaking ties to the nearest even value cancels that out across many roundings, which is why it is the default in Python and in the IEEE standard. It is the right choice for statistics and the wrong one for a price list somebody will check by hand.
- Adding a half then flooring is explicit half-up
math.floor(x + 0.5) rounds ties upward, always, with no cleverness — the rule is visible in the code. When you need behaviour that differs from a built-in’s, writing it out beats hoping nobody notices the difference.
- Scale down, round, scale back
Rounding to a step of five is rounding value / 5 to a whole number and then multiplying by five again. Dividing by the step turns 'nearest multiple' into 'nearest integer', which is a problem you already know how to solve.
Rounding to the nearest multiple of five is the same problem as rounding to the nearest whole number, once you have divided by five. What do you do to get back afterwards?
value / step turns the question into 'nearest integer'. You cannot use round() for that here, because the contract says halves go up and round() sends them to the even number instead.
Guard the step. Then divide by the step, add a half, take math.floor of that — which is half-up rounding written out — and multiply by the step again. One expression, no branching for negatives.
import math def round_to_step(value, step): if step <= ____: raise ValueError(...) return step * math.____(value / step + ____)
import math
def round_to_step(value, step):
if step <= 0:
raise ValueError(f"step must be greater than zero, got {step}")
return step * math.floor(value / step + 0.5)
int(-1.1) truncates towards zero and gives -1; math.floor(-1.1) gives -2. For positive numbers they agree, which is exactly why this bug ships: every test a developer types by hand passes. Two of the cases here are negative for that reason alone.
There is no branch for negatives, no abs(), and no sign juggling — adding a half and flooring is symmetric about each boundary. When a formula covers the awkward cases without special handling, that is usually a sign it is the right formula rather than a lucky one.
Multiplying an integer step by an integer count returns an integer, so round_to_step(7, 5) is 5 and not 5.0. Doing the arithmetic the other way round, or dividing at the end, would quietly turn every answer into a float and surprise a caller who indexes with it.
Do not use this for money. Binary floats cannot hold 0.05 exactly, so a step like 0.05 drifts — round_to_step(0.075, 0.05) gives 0.05 rather than 0.1, because 0.075 / 0.05 is 1.4999999999999998 in floating point rather than 1.5. Work in integer minor units, as the bill-splitting exercise does, or use decimal.Decimal with an explicit rounding mode such as ROUND_HALF_UP. This function is right for pixels, grid snapping and chart axes, where a unit of error is invisible.
- Add a mode parameter offering up, down and nearest, so the same function can floor a value to a grid as well as round it.
- Write the decimal.Decimal version with ROUND_HALF_UP and compare it against this one on 0.075, 0.125 and 0.135 — then decide which you would trust with a price.
- Round to the nearest quarter hour given a datetime, and work out where the step lives when the value is not a plain number.