pandas · difficulty ◆
pandas round() — round numbers without breaking the total
round() is not arithmetic, it is a display decision — round once at the edge of the report and never inside the pipeline.
Six invoice lines printed to two decimals added up to 262.23 while pandas said the total was 262.21 — two cents a customer finds with a calculator, and both numbers are correct.
$ df.round()What it does
round(decimals) rounds every numeric column to a number of decimal places, and decimals is the only real parameter. An int rounds all of them the same way; a dict rounds the named columns and leaves every other column byte-for-byte as it was (unknown keys are ignored, not an error); a Series does exactly the same, aligned on its index. Negative values round to the left of the decimal point — round(-3) is the nearest thousand — which is how I put a dashboard into 'k' without touching the data. Halfway values go to the nearest even value: 0.5 rounds to 0, 1.5 to 2, 2.5 to 2, 3.5 to 4. Three things surprise people, all verified on 3.0.3. An int64 column is a silent no-op: round(2) on a column of ints returns the same ints and the same dtype, because the block-level implementation only touches float and complex blocks. A str column passes through unchanged too — and so does a datetime column, except that dataframes now emit a UserWarning telling you to use .dt.round('15min') instead (GH 57781, new in 3.0). And the argument is checked with an integer test, so round(2.0) raises TypeError while round(np.int64(2)) is fine.
Why it matters
Rounding is the moment a report stops agreeing with the ledger, and the disagreement is arithmetic, not carelessness: the sum of six rounded lines is not the rounded sum of six lines, because each half-cent goes its own way. If you round per row and then add, you have quietly defined a different total than the database has, and the person reconciling the invoice will find the difference before you do. So the interesting question is never 'how do I round' — it is 'where does rounding belong in the pipeline'. My rule is at the edge: keep full precision through joins, groupbys and VAT, and round exactly once, in the function that produces the printable table. When only the display should change, do not round the data at all: df.to_string(float_format='%.2f') formats on the way out and leaves the frame untouched, which is the honest way to make a number look shorter. And when a total really has to match the sum of its printed parts — invoices, allocations, split payments — round the lines, compute the total from the unrounded values, and post the residual cents onto one designated line so the arithmetic the reader does by hand comes out right.
Example
$ import pandas as pd
sales = pd.DataFrame({
"region": ["Yanbu", "Jubail", "Riyadh", "Dammam"],
"orders": [128, 94, 211, 57],
"revenue": [1234567.8912, 842110.4567, 2100333.0105, 456789.9876],
"margin_pct": [18.4567, 12.0032, 21.9999, 9.4444],
})
report = sales.round({"revenue": 2, "margin_pct": 1})
print(report.to_string(index=False))
print("dtypes:", report.dtypes.astype(str).to_dict())
print("orders with decimals=2:", sales.round({"orders": 2})["orders"].tolist())region orders revenue margin_pct
Yanbu 128 1234567.89 18.5
Jubail 94 842110.46 12.0
Riyadh 211 2100333.01 22.0
Dammam 57 456789.99 9.4
dtypes: {'region': 'str', 'orders': 'int64', 'revenue': 'float64', 'margin_pct': 'float64'}
orders with decimals=2: [128, 94, 211, 57]This is the form that goes into every report function I write: one dict, one call, and a spec a reviewer can read. Money to 2, a percentage to 1, the region label and the order count untouched — and untouched means untouched: the labels are still 'Yanbu', not a rounded copy, and the int64 order counts keep their dtype. The last line is the trap that bites in code review. Asking for decimals=2 on an integer column changes nothing at all, and pandas does not warn: [128, 94, 211, 57] comes back as int64, so if you expected 128.0 you were about to do arithmetic on ints pretending to be rounded floats. If you genuinely need a float column, say so with astype — round() will not convert it for you.
$ import pandas as pd
lines = pd.DataFrame({
"sku": ["VLV-A12", "VLV-B07", "FLT-C31", "GTX-D44", "PMP-E19", "GTX-D45"],
"qty": [4, 9, 3, 7, 2, 5],
"unit_price": [19.995, 4.445, 8.885, 2.225, 33.335, 6.665],
})
lines["amount"] = lines["qty"] * lines["unit_price"]
lines["printed"] = lines["amount"].round(2)
print(lines[["sku", "unit_price", "amount", "printed"]].to_string(index=False))
print("printed lines add up to:", round(lines["printed"].sum(), 2))
print("raw sum, rounded once :", round(lines["amount"].sum(), 2))
print("difference :", round(lines["printed"].sum() - lines["amount"].sum(), 2))
total = round(lines["amount"].sum(), 2)
lines.loc[lines["amount"].abs().idxmax(), "printed"] += round(total - lines["printed"].sum(), 2)
print("printed lines now add up to:", round(lines["printed"].sum(), 2))
print(lines[["sku", "printed"]].to_string(index=False)) sku unit_price amount printed
VLV-A12 19.995 79.980 79.98
VLV-B07 4.445 40.005 40.01
FLT-C31 8.885 26.655 26.66
GTX-D44 2.225 15.575 15.58
PMP-E19 33.335 66.670 66.67
GTX-D45 6.665 33.325 33.33
printed lines add up to: 262.23
raw sum, rounded once : 262.21
difference : 0.02
printed lines now add up to: 262.21
sku printed
VLV-A12 79.96
VLV-B07 40.01
FLT-C31 26.66
GTX-D44 15.58
PMP-E19 66.67
GTX-D45 33.33The two-cent invoice, reproduced with three-decimal unit prices — the kind you get from a per-unit price times a quantity. Six rows, both numbers correct, and a 0.02 disagreement between the column the customer adds up (262.23) and the total pandas reports (262.21). Nothing here is a bug: round() decides each line on its own merits, and the sum of the raw floats carries its own drift. The four-line fix at the bottom is the pattern I use for anything that has to balance on paper — take the total from the unrounded values, then post the residual cents onto one designated line (here the largest, picked with idxmax) so the printed column adds up to the printed total. The residual is computed with round(total - sum, 2) rather than by assigning a constant, because that is what makes the code survivable when the data changes.
$ import numpy as np
import pandas as pd
from decimal import Decimal, ROUND_HALF_UP
pallets = pd.Series([0.5, 1.5, 2.5, 3.5, 4.5])
print("input :", pallets.tolist())
print("pandas round() :", pallets.round().tolist())
print("total :", pallets.round().sum(), "vs", pallets.sum())
print("np.floor(x + .5):", np.floor(pallets.to_numpy() + 0.5).tolist())
print("Decimal HALF_UP :", [int(Decimal(str(x)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) for x in pallets])
print("Decimal default :", pd.Series([Decimal("2.5"), Decimal("3.5")]).round().tolist())input : [0.5, 1.5, 2.5, 3.5, 4.5]
pandas round() : [0.0, 2.0, 2.0, 4.0, 4.0]
total : 12.0 vs 12.5
np.floor(x + .5): [1.0, 2.0, 3.0, 4.0, 5.0]
Decimal HALF_UP : [1, 2, 3, 4, 5]
Decimal default : [Decimal('2'), Decimal('4')]Five half-pallets, and pandas books 12 instead of 12.5 — half of the ties went down, and they went down because the digit before them was even, not because anything is broken. This is banker's rounding (round-half-to-even), it is documented in the docstring, and it is what IEEE 754 calls roundTiesToEven; the idea is that a long column of ties does not drift upward the way always-round-half-up does. When the business rule really is 'half a pallet is a pallet' — half-up, the way a human was taught — you have to say so: np.floor(x + 0.5) is the arithmetic shortcut, and it is fine for a column of small positive counts, though the addition itself is a float operation and will not save you at the edges of float64. The Decimal version with ROUND_HALF_UP is the one I would sign my name under for money. The last line is the sting in the tail: Decimals do not fix it, because the default decimal context is also half-even — Decimal('2.5').round() gives 2 if you do not pass a rounding mode.
$ import numpy as np
import pandas as pd
prices = pd.Series([2.675, 1.005, 0.125])
as_object = prices.astype(object)
print("float64 column:", prices.round(2).tolist(), "|", prices.round(2).dtype)
print("object column:", as_object.round(2).tolist(), "|", as_object.round(2).dtype)
print("python round() :", [round(x, 2) for x in prices])
print("the tie inside :", repr(2.675), "* 100 =", repr(2.675 * 100), "=> rint:", np.rint(2.675 * 100) / 100)float64 column: [2.68, 1.0, 0.12] | float64
object column: [2.67, 1.0, 0.12] | object
python round() : [2.67, 1.0, 0.12]
the tie inside : 2.675 * 100 = 267.5 => rint: 2.68Same three numbers, same call, two different answers — and the dtype you happen to have decides which one you get. On a float64 column round(2) hands off to numpy, which scales by 100, rounds the scaled value and divides back; for 2.675 that product is exactly 267.5, a perfect tie, and half-to-even pushes it up to 268, so you print 2.68. An object column takes the other branch — a per-element call to Python's round() — which looks at the true stored value 2.67499999999999982 and correctly returns 2.67. This is the kind of thing that makes a price list disagree with a billing system. The last line is the mechanism in one sentence, and it is worth keeping in your head: the scaling by a power of ten is where the half-ness comes from, not the rounding itself. If a cent of accuracy is contractual, do not let a float64 column near the calculation — parse to Decimal at the boundary and round with an explicit mode.
$ import pandas as pd
meters = pd.DataFrame({
"meter": ["M-0412", "M-0413", "M-0414", "M-0415"],
"site": ["Yanbu", "Yanbu", "Jubail", "Jubail"],
"flow_m3": [10481.7742, 9982.3361, 12150.9183, 10883.5507],
})
avg = meters.groupby("site")["flow_m3"].mean()
print("site mean, 1 decimal :", avg.round(1).to_dict())
print("site mean, thousands :", avg.round(-3).to_dict())
print("per meter, hundreds :", meters["flow_m3"].round(-2).tolist())
print("dtype after round(-2):", meters["flow_m3"].round(-2).dtype)site mean, 1 decimal : {'Jubail': 11517.2, 'Yanbu': 10232.1}
site mean, thousands : {'Jubail': 12000.0, 'Yanbu': 10000.0}
per meter, hundreds : [10500.0, 10000.0, 12200.0, 10900.0]
dtype after round(-2): float64Negative decimals are the part of round() almost nobody uses, and they replace a whole family of divide-then-multiply hacks. round(-3) on a groupby result puts a flow total into thousands for an executive dashboard; round(-2) on the raw column snaps 10481.77 m3 to 10500 m3, which is how you plot a trend line without a legend full of decimals. Two practical notes. First, the call is a Series operation here — groupby(...).mean() returns a Series, and round() on it is Series.round, which takes an int only; if you want different places per site you are back to the dict form on a frame. Second, negative decimals round to the left of the point but they do not change the dtype: this stays float64, and it stays exactly as imprecise as it was. 12000.0 is not 12000 — for a report it does not matter, for a KPI store that is fed back into a calculation it does.
Common flags
- decimals=0
- The default, and the only real parameter. DataFrame.round and Series.round both take (decimals=0, *args, **kwargs) on 3.0.3; the trailing args exist only for numpy compatibility and are checked by nv.validate_round, so a stray keyword is rejected with TypeError: round() got an unexpected keyword argument 'axis' (verified) while a numpy-style out=None is silently swallowed.
- round({"revenue": 2, "margin_pct": 1})
- Dict form: named columns get their own number of places, every other column is returned untouched (verified: labels stay 'str', counts stay int64), and keys that are not columns are ignored instead of raising. Values must be integers — a string value raises TypeError: Values in decimals must be integers.
- round(pd.Series([0, 1], index=["cats", "dogs"]))
- Series form: decimals are aligned on the index, so the column names go in the index. A duplicated index raises ValueError: Index of decimals must be unique — which is exactly what a sloppy groupby(...).size() result looks like.
- round(-3)
- Negative decimals round to the left of the decimal point: the nearest thousand. 10481.7742 → 10500.0 for round(-2). Same call on a groupby mean gives 12000.0. Dtype is unchanged — float64 in, float64 out.
- round(2.0)
- Raises TypeError: decimals must be an integer, a dict-like or a Series. The check is is_integer, not 'is a whole number', so the float 2.0 is rejected while np.int64(2) is accepted — which matters when the number comes out of a config table or a CSV column that pandas typed as float.
- decimals on an int column
- A silent no-op. pandas/core/internals/blocks.py rounds only numeric non-bool blocks, so an int64 column comes back as the same ints with the same dtype — round({"orders": 2}) on [128, 94, 211, 57] returns [128, 94, 211, 57]. No warning, no conversion; astype is the tool for changing the type.
- datetime / timedelta / period columns
- Unchanged, plus a UserWarning since 3.0: 'obj.round has no effect with datetime, timedelta, or period dtypes. Use obj.dt.round(...) instead.' (GH 57781). The datetimelike .round/.floor/.ceil methods take a frequency, not decimals — s.dt.round('15min'), not s.round(15).
History
25 November 2011 — Series.round ships with numpy's docstring
The v0.5.0 tag has no round method on Series at all; v0.6.0 (2011-11-25) has a three-line one that delegates straight to the underlying array — result = self.values.round(decimals, out=out) — and then assigns its documentation from numpy: round.__doc__ = np.ndarray.round.__doc__. That is the whole first version, and it explains the vocabulary of the method to this day: decimals, tie-breaking to the nearest even value, and the numpy-flavoured *args/**kwargs all come from there rather than from pandas. The out= parameter was inherited too, and stayed until 0.18.0 (March 13, 2016) removed it in favour of the pandas convention of returning a new object, tracked as GH 11763 — the same issue that made the builtin round(DataFrame) work under Python 3, added a simple Panel.round, and made round on non-numeric columns a no-op instead of an error.
9 October 2015 — DataFrame.round arrives, per-column from the first commit
DataFrame.round is younger than most people assume: the 0.17.0 release notes say plain as day, 'Added a DataFrame.round method to round the values to a variable number of decimal places' (GH 10568), and the source carries versionadded:: 0.17.0. The first implementation did not touch the block manager at all — it was a Python loop over the columns, new_cols = [np.round(self[col], decimals) for col in self] for the int case and the same list comprehension with a per-column dict lookup for the dict case, concatenated back together at the end. The modern version dispatches one call to the BlockManager when decimals is an int and only falls back to a per-column path for dicts and Series, which is also why the per-column path is the one that emits the datetimelike warning (GH 57781, new in 3.0.0): it is the legacy branch, still visible in pandas/core/frame.py. Two footnotes from that era are worth keeping. 0.18.0 made DataFrame.round leave non-numeric columns unchanged instead of raising (GH 11885) and fixed round dropping the column index name (GH 11986). And the datetimelike .round/.floor/.ceil trio — the ones the 3.0 warning points you at — landed in the same 0.18.0 release (GH 4314, GH 11963), reachable as Series.dt.round('D').
Fun facts
Pros & cons
pros
- + One call, three shapes of the same job: an int for the whole frame, a dict for a per-column spec, a Series for values that come from a config table — and columns you did not name come back untouched, so it drops into an existing report function without repairing anything downstream
- + Negative decimals round to tens, hundreds and thousands, which retires a family of divide-multiply hacks when a number only has to be right to the nearest 1,000 on a dashboard
- + Dtype is preserved on every path (int64 stays int64, float32 stays float32, a nullable Float64 stays nullable and still rounds half-to-even) — verified on 3.0.3 — so nothing silently changes type behind your back
cons
- − Half-to-even is correct and unintuitive: 0.5 → 0, 2.5 → 2, and a column of halves books 12 pallets for 12.5 of them. If the business rule is half-up, round() cannot express it — you need a Decimal quantize with ROUND_HALF_UP or a floor(x + 0.5) shortcut
- − The float path is inexact by design, and pandas says so in the docstring. 2.675 → 2.68 on float64 and 2.67 as an object column, from the same call on the same numbers. Anything that has to be right to the cent belongs in Decimal, applied at the boundary
- − Non-numeric columns fail quietly: an int column is a no-op, a str column is a no-op, and a datetime column is a no-op with a warning you will not read in a log full of them. Nothing tells you that the column you thought you were rounding was never touched
Takeaways
- 1Round once, at the edge. Keep full precision through joins, groupbys and VAT, and put round() in the function that builds the printable table — a rounded column fed back into a calculation is a data-quality incident waiting for a quiet month.
- 2Use the dict form as documentation: df.round({"revenue": 2, "margin_pct": 1}) is a spec a reviewer can read, and the columns you leave out are guaranteed to come back untouched (int64 stays int64, labels stay labels).
- 3Do not expect round() to change a type. An int64 column survives round(2) unchanged, a str column passes through, and a datetime column passes through with a UserWarning in 3.0 — use astype for the type and .dt.round('15min') for the timestamps.
- 4Never let a printout's total be the sum of its rounded lines by accident. Take the total from the unrounded values, then post the residual onto one designated line with idxmax so the arithmetic a customer does by hand comes out right.
- 5If a cent is contractual, leave float64 out of it: parse to Decimal at the boundary, quantize with an explicit ROUND_HALF_UP, and use to_string(float_format='%.2f') — or df.style.format('{:.2f}') — when only the display should look rounded.