Why SAS and R Round Differently (and How to Make Them Agree)
clinical
sas-to-r
validation
SAS rounds half away from zero; R’s round() rounds half to even and works on binary floating point. Here is exactly where the differences appear in clinical tables, how to reproduce SAS rounding in R, and which rule to write into your tolerance document.
Author
Rverse Analytics
Published
October 8, 2026
SAS ROUND(x, 0.1) rounds half away from zero. R’s round(x, 1) follows IEC 60559 and rounds half to even, so round(2.5) is 2 and round(0.125, 2) depends on the binary representation.
In a typical TLF package the mismatch shows up in a handful of cells per table: percentages that end in exactly 5 at the next digit, means of integer data, and derived values such as BMI.
A six-line round_sas() helper reproduces SAS behaviour in R. Put it in your migration package, test it, and reference it in the numerical tolerance rules.
The right policy is usually “match SAS during parallel runs, then adopt one convention site-wide”, not “argue each cell”.
If you have ever compared an R table to its SAS original and found a 12.5 % that became 12.4 %, you have met the single most common source of SAS-to-R “discrepancies”. It is not a bug on either side. The two systems implement different, equally defensible rounding rules, and they sit on top of binary floating-point arithmetic that makes some decimals impossible to represent exactly.
The two rules
SASROUND() rounds half away from zero: 2.5 becomes 3, −2.5 becomes −3, 0.125 to two decimals becomes 0.13. Internally SAS also applies a “fuzz” so that values that are almost a half, due to floating-point noise, are treated as a half.
Rround() documents that it follows the IEC 60559 standard: “round half to even”. 2.5 becomes 2, 3.5 becomes 4. And because the function operates on the binary double, round(0.125, 2) is not guaranteed to be 0.12 or 0.13 by the rule alone; it depends on whether 0.125 is represented exactly (it is) and how the algorithm handles the scaled value.
Notice that sprintf() does not rescue you: it uses the C library’s formatting, which also rounds the binary value, and 2.675 prints as 2.67 because the stored double is slightly below 2.675.
Reproducing SAS rounding in R
The standard approach is to add a tiny tolerance and use floor() on the shifted absolute value, then restore the sign:
The fuzz argument mirrors SAS’s behaviour of treating values within a tiny distance of a half as a half. Keep it small (1e-9 is conventional) and document it. Note that janitor::round_half_up() implements the same idea if you prefer a maintained dependency.
Where it bites in real tables
Percentages in frequency tables.n / N * 100 with N = 8, 16, 40 or 80 produces exact halves at the first decimal. With N = 40, every odd count gives a .5.
Means of integer scores. The mean of an even number of integers is often an exact half.
Derived quantities such as BMI or change from baseline, where a two-stage rounding (derive, round, then summarise and round again) multiplies the opportunities for disagreement.
Displayed vs stored values. SAS PUT() with a format rounds the displayed value; the dataset keeps full precision. If your R pipeline rounds before summarising, you will see differences that are not about rounding rules at all.
set.seed(1)n <-c(3, 5, 7, 9, 11, 13, 15, 17)N <-40pct <- n / N *100data.frame(n, pct, r =round(pct, 1), sas =round_sas(pct, 1), differ =round(pct, 1) !=round_sas(pct, 1))
Half of the odd counts disagree. In a 20-table package that is dozens of cells.
What to write in the tolerance document
Our migration tolerance rules usually contain three statements on rounding:
Rule used. “Displayed values are rounded half away from zero using round_sas() (package xyzmigr v1.2.0), matching SAS ROUND().” Or, after adoption, “rounded half to even using base R round()”; the point is that one rule is named.
Order of operations. Rounding happens once, at the display layer. Intermediate derivations keep full precision. Two-stage rounding is a documented exception.
Acceptance criterion. Numeric comparison of displayed values after applying the same rule on both sides must show zero differences; differences in the final displayed digit that are traceable to representation (e.g. 2.675) are logged as explained, not as deviations.
With those three lines, the reviewer sees a decision, not a surprise.
Should you keep SAS rounding forever?
During the parallel-run phase, yes: matching SAS is what makes the equivalence report clean. After adoption, many teams switch to R’s default and note the change in the SOP, because half-to-even is the IEEE standard and statistically unbiased. Either is acceptable; mixing both inside one submission is not.