R’s doubles are binary fractions. They’re fast and almost always good enough, but they can’t represent most decimal numbers exactly — which is why this happens:
0.1 + 0.2 == 0.3
#> [1] FALSEMost of the time you can shrug this off. But if you’re adding up
invoices, reconciling accounts, or storing prices, “almost 0.3” doesn’t
cut it. The decimal package gives you vectors that hold decimal numbers
exactly and compute with them exactly, following the same General Decimal
Arithmetic standard as Python’s decimal module.
This vignette shows you how to create decimal vectors and work with
them day-to-day. A companion vignette,
vignette("contexts-and-signals"), covers the arithmetic
context: precision, rounding modes, and how conditions like overflow are
handled.
Creating decimal vectors
The best way to create a decimal is from a string, because a string can say exactly what you mean:
Integers work too, and special values are written the way the standard spells them:
decimal(c(1L, 2L, NA_integer_))
#> <decimal[3]>
#> [1] 1 2 <NA>
decimal(c("1.20", "-0", "Infinity", "NaN"))
#> <decimal[4]>
#> [1] 1.20 -0.00 Infinity NaNValues are immutable and stored as text internally, so converting
back with format() or as.character() always
reproduces them exactly — nothing is lost round-tripping through a CSV
file or a database column:
as.character(x)
#> [1] "1.20" "2.30" "3.40"Scale: how many decimal places?
Every decimal vector has a single shared scale: the number of fractional digits stored for every element. If you don’t specify it, decimal infers the largest number of fractional digits present, and pads (never rounds!) the other elements to match:
Because scale belongs to the vector rather than to each element,
decimal("1.2") and decimal("1.20") are the
same value:
What about doubles?
You might expect decimal(0.1) to work. It doesn’t, and
that’s deliberate: the double 0.1 is not actually 0.1 — its
exact binary value needs 55 fractional digits to write out in decimal!
So converting a double is explicit, via as_decimal() or
decimal_from_double(), and you have to say how many digits
you want to keep:
decimal_from_double(0.1, scale = 25)
#> <decimal[1]>
#> [1] 0.1000000000000000055511151If what you want is decimal 0.1, write
decimal("0.1"):
decimal("0.1")
#> <decimal[1]>
#> [1] 0.1If your workflow consistently uses one scale — say, you always want 7
digits — set the opt-in default so you don’t have to repeat yourself. An
explicit scale argument still wins:
withr::with_options(
list(decimal.default_scale = 7L),
as_decimal(0.002)
)
#> <decimal[1]>
#> [1] 0.0020000Set it globally with
options(decimal.default_scale = 7L), or use
withr::local_options() when the default should apply only
within a function or a test.
Decimals are well-behaved vectors
Decimal vectors are built on vctrs, so they behave the way you’d hope inside data frames and tibbles, and with sorting, matching, and friends:
tibble::tibble(
item = c("coffee", "bagel", "juice"),
price = decimal(c("2.50", "1.25", "3.95"))
)
#> # A tibble: 3 × 2
#> item price
#> <chr> <dec>
#> 1 coffee 2.50
#> 2 bagel 1.25
#> 3 juice 3.95They combine freely with integers, promoting to the common (largest) scale:
But they refuse to implicitly combine with doubles or character strings:
vctrs::vec_c(decimal("1.20"), 0.5)
#> Error in `vctrs::vec_c()`:
#> ! Can't combine `..1` <decimal> and `..2` <double>.That’s the same design decision as above, applied consistently: a
double needs a scale decided for it, and a string needs to be parsed
(which can fail), so neither is a lossless, always-safe promotion the
way integer is. When you mean it, say it — call decimal()
or as_decimal() explicitly.
In a data.table
A data.table holds and prints decimal columns, and
whenever it evaluates an ordinary R expression it uses the decimal
methods: filtering, arithmetic in j or :=, and
joining or grouping on a decimal key all work. For speed, though, some
data.table operations skip R’s methods and work on the stored text
directly, which for a decimal is the wrong thing:
-
Sorting.
dt[order(x)],setorder()andsetkey()sort the text, so10.00comes before2.50. Sort byxtfrm(x), which ranks the values. -
Grouped summaries. With
by, data.table swapsmin(),max(),sum()andmean()for its own versions:min()andmax()then compare text and pick the wrong element, andsum()andmean()stop with an error. Writebase::max(x)and so on, or setoptions(datatable.optimize = 1). -
Mixed scales.
rbindlist(),:=on some of the rows,fifelse()andmelt()combine the text without reconciling scales, so1.5and2.25end up in one vector with different numbers of decimal places. Joins compare the text too, so2.5does not match2.50. Give decimal columns one scale first, for example withas_decimal(x, scale = 2).
Arithmetic
Arithmetic is vectorized, context-controlled, and keeps track of significance:
decimal("1.20") + decimal("2.3")
#> <decimal[1]>
#> [1] 3.50
sum(decimal(c("1.20", "2.30", "3.40")))
#> <decimal[1]>
#> [1] 6.90
mean(decimal(c("1", "2", "3")))
#> <decimal[1]>
#> [1] 2One difference from base R worth knowing about: %% and
%/%. Base R floors integer division toward negative
infinity, while General Decimal Arithmetic truncates toward zero, so
results differ for negative operands:
Special values
decimal supports the full menagerie: NA, NaNs,
infinities, and signed zero — and it keeps R’s missing value distinct
from decimal’s not-a-number:
x <- decimal(c(NA_character_, "NaN", "sNaN", "Infinity", "-0"))
x
#> <decimal[5]>
#> [1] <NA> NaN sNaN Infinity -0
is.na(x)
#> [1] TRUE TRUE TRUE FALSE FALSE
is.nan(x)
#> [1] FALSE TRUE TRUE FALSE FALSE
number_class(x)
#> [1] NA "NaN" "sNaN" "+Infinity" "-Zero"A few things to note:
NAis an R missing value — it’s absent from the computation entirely.NaN(quiet NaN) andsNaN(signaling NaN) are decimal not-a-number values that participate in arithmetic. A quiet NaN propagates silently; an sNaN raises theinvalid_operationsignal, which is an error under the default context — seevignette("contexts-and-signals").is.na()returnsTRUEfor bothNAand decimal NaNs, matching base R’s ownis.na(NaN). Useis.nan()to tell a decimal NaN apart from a missing value.Signed zero survives formatting:
-0prints as-0, not0.
Rounding and other decimal tools
quantize() is the workhorse for rounding: it rescales
x to the scale declared by quantum, using the
active context’s rounding mode. It’s the operation behind
round() and signif(), and it reads naturally
for the most common case — rounding to cents:
normalize() strips shared trailing zeros down to the
finest scale the vector actually needs, without discarding any element’s
significance:
fma(a, b, c) computes a * b + c as one
fused operation with a single rounding at the end, instead of rounding
after the multiplication and again after the addition:
same_quantum() reports whether two vectors share the
same declared scale (remember, scale is a per-vector property):
same_quantum(decimal(c("1.20", "2.0")), decimal(c("2.30", "3.00")))
#> [1] TRUE TRUEAnd adjusted() returns each value’s adjusted exponent —
the exponent it would have in scientific notation with a single digit
before the point:
Where to next
Everything above used the default arithmetic settings: 28 digits of
precision, round-half-even, and errors on division by zero, invalid
operations, and overflow. All of that is configurable through the
decimal context — read on in
vignette("contexts-and-signals").