Exact fixed-point decimal numbers for Julia.
Decimal{P,S,T} is an isbits value holding one integer coefficient, with the
precision P and scale S fixed in the type. A vector stores coefficients
contiguously. Arrow buffers of the same width and host byte order can share
this layout. Other wire formats require their own encoding. See the
integration boundaries.
Arithmetic is exact and checked. +, -, and * neither round nor wrap;
they throw OverflowError when a result does not fit. Where rounding is
unavoidable it takes a RoundingMode argument at the call site, rather than
reading a global context.
DataDecimals is a new package with its own UUID. It does not replace or update Decimals.jl. See Migrating from Decimals.jl for the arithmetic differences.
using Pkg
Pkg.add("DataDecimals")Julia 1.10 or later. There are no runtime dependencies outside the standard
library; the 256-bit storage integers (DataDecimals.Int256/UInt256) are
implemented in the package, on LLVM's native wide-integer arithmetic.
using DataDecimals
# Construct: from a literal, from a string, or from a wire coefficient
dec"1.25" # Decimal{3,2,Int32}, the minimal fitting type
DataDecimals.Decimal64{2}("1234.56") # Decimal{18,2,Int64}, a money column
reinterpret(DataDecimals.Decimal64{2}, 123456) # 1234.56, zero-copy from an Arrow buffer
# + and - keep the scale and are checked; * is exact; / rounds
DataDecimals.Decimal64{2}("1.20") + DataDecimals.Decimal64{2}("0.05") # 1.25 :: Decimal{18,2,Int64}
DataDecimals.Decimal64{2}("1.25") * DataDecimals.Decimal64{3}("2.500") # 3.12500 :: Decimal{36,5,Int128}
DataDecimals.Decimal64{2}("1.00") / DataDecimals.Decimal64{2}("3.00") # 0.33 (half-even at scale 2)
sum(fill(DataDecimals.Decimal64{2}("0.10"), 10)) # 1.00 :: Decimal{38,2,Int128}
# Rounding is an argument, not a mode to switch on
DataDecimals.divide(DataDecimals.Decimal64{2}("1.00"), DataDecimals.Decimal64{2}("3.00"), RoundUp) # 0.34
DataDecimals.rescale(DataDecimals.Decimal64{2}, DataDecimals.Decimal64{4}("1.2356"), RoundUp) # 1.24
round(DataDecimals.Decimal64{4}("1.2346"); digits=2) # 1.2300
# DataDecimals.DecimalValue carries its scale per value, like a PostgreSQL numeric dscale
DataDecimals.DecimalValue(12345, 3) # 12.345
DataDecimals.rescale(DataDecimals.DecimalValue(12345, 3), 1) # 12.3
DataDecimals.normalize(dec"1.2000") # 1.2 :: DataDecimals.DecimalValue{Int32}
# Printing and conversion
string(DataDecimals.Decimal64{2}("-12.30")) # "-12.30", always positional
Float64(DataDecimals.Decimal64{2}("1.25")) # 1.25, correctly rounded
Rational(DataDecimals.Decimal64{2}("1.25")) # 5//4, exactParsing lives in the Parsers extension, so parse and tryparse need
using Parsers alongside using DataDecimals:
using DataDecimals, Parsers
parse(DataDecimals.Decimal64{2}, "1234.567") # 1234.57, half-even at the target scale
tryparse(DataDecimals.Decimal64{2}, "nope") # nothingString constructors and dec"..." literals use the package's own exact
scanner and work without any extension.
Overflow, inexactness, and division by zero are errors:
typemax(DataDecimals.Decimal64{2}) + DataDecimals.Decimal64{2}("1.00") # OverflowError
convert(DataDecimals.Decimal64{2}, DataDecimals.Decimal64{4}("1.2345")) # InexactError (use DataDecimals.rescale)
DataDecimals.Decimal64{2}("1.00") / zero(DataDecimals.Decimal64{2}) # DivideErrorAt the REPL a decimal displays as plain digits (1234.56); repr and show
give the round-trippable typed form, Decimal{18,2,Int64}("1234.56").
Only Decimal, Decimal64, and @dec_str are exported. DataDecimals.DecimalValue,
DataDecimals.Decimal32, DataDecimals.Decimal128,
DataDecimals.Decimal256, DataDecimals.rescale, and DataDecimals.divide are public.
DataDecimals.normalize, DataDecimals.unscaled, DataDecimals.scale,
DataDecimals.writedecimal!, DataDecimals.decimallength, and
DataDecimals.AbstractDecimal are public but not exported; normalize stays
qualified so that it does not collide with LinearAlgebra.normalize.
Decimal{P,S,T} holds unscaled::T and means unscaled * 10^-S, with the
invariant |unscaled| < 10^P. P is the precision, S the scale
(0 <= S <= P), and T the storage integer. The four aliases name the
storage tiers at their maximum precision:
| alias | expands to | digits | storage | typical use |
|---|---|---|---|---|
DataDecimals.Decimal32{S} |
Decimal{9,S,Int32} |
9 | 32-bit | narrow Arrow/Parquet columns |
DataDecimals.Decimal64{S} |
Decimal{18,S,Int64} |
18 | 64-bit | money, most SQL DECIMAL |
DataDecimals.Decimal128{S} |
Decimal{38,S,Int128} |
38 | 128-bit | DuckDB/Spark default width |
DataDecimals.Decimal256{S} |
Decimal{76,S,DataDecimals.Int256} |
76 | 256-bit | Arrow DataDecimals.Decimal256 |
Write Decimal{P,S} when a schema pins a precision narrower than the tier
maximum; the storage type is filled in from P. Decimal{7,2} and
DataDecimals.Decimal32{2} both store an Int32 and differ only in the number of digits
they accept before throwing.
DataDecimals.DecimalValue{T} is the runtime-scale sibling: it stores the scale (an
Int32 in 0:16383) alongside the coefficient, for row-oriented values
whose scale travels with the value rather than the column, such as a
PostgreSQL numeric dscale or the result of DataDecimals.normalize. It is
still isbits, but for columnar data Decimal{P,S} is the better fit, since
there the scale is a schema fact and belongs in the type.
Both are <: DataDecimals.AbstractDecimal <: Real. There is no AbstractFloat
subtyping and there are no NaN or Inf values: isfinite is always
true, and an operation that would produce a non-value throws.
| operation | rule |
|---|---|
+, - |
promote both operands value-preservingly, then compute checked: OverflowError, never a silent round or wrap |
* |
exact; result scale is S1 + S2, precision min(P1+P2, 76), checked |
* by an Integer |
scale-preserving, since multiplying by a count should not widen the scale |
/ |
rounds half-even at scale max(S1, S2); DataDecimals.divide(x, y, mode) for any other RoundingMode |
sum |
accumulates in a wider storage tier, where overflow is provably impossible (below 1.7e20 elements for a 64-bit-tier column), so the reduction is both safe and vectorizable |
div, rem, mod, fld, cld, divrem |
real implementations in every rounding mode, not float fallbacks |
round, trunc, floor, ceil |
exact and in-type, with a digits keyword |
convert, numeric constructors |
integer/rational/decimal sources are exact or throw; float sources round half-even |
Decimal{P,S}(::AbstractFloat) |
rounds half-even at scale S, as cross-representation float conversions in Base do; DataDecimals.DecimalValue(x) keeps the exact binary expansion instead |
parse, tryparse |
provided by the Parsers extension; round half-even at the target scale, like parse(Float64, s); tryparse returns nothing |
DataDecimals.rescale, round(D, x, mode) |
the explicit rounding conversions between decimal types |
==, <, hash |
by numeric value across scales, precisions, and number types: dec"1.20" == dec"1.2" == 12//10, and hash agrees with an equal Int, Float64, or Rational |
≈ |
exact equality between decimals, since Base's rtoldefault is 0 for a non-AbstractFloat Real, as for Rational; pass rtol/atol for a tolerance |
string, print, writedecimal! |
always the plain positional form, which is a valid SQL literal, CSV field, and JSON number |
show (text/plain) |
positional too, switching to scientific notation past 44 characters |
| rounding state | there is none; every rounding decision is an argument at the call site |
Seven rounding modes are accepted wherever a mode is taken: RoundNearest
(half-even, the default), RoundNearestTiesAway, RoundNearestTiesUp,
RoundToZero, RoundFromZero, RoundDown, and RoundUp. An eighth,
DataDecimals.RoundExact, requires exactness: it throws InexactError
instead of dropping a digit, and Parsers.tryparse returns nothing.
These load as package extensions when the companion package is present.
-
Parsers.jl provides byte-level
Parsers.parse,Parsers.tryparse, andParsers.parsenextover strings or byte spans, withdecimal=androunding=keywords, and defines theBase.parse/Base.tryparsemethods for decimal types. All string parsing goes through here, which is where a CSV or wire reader already is. It requires Parsers 3; under Parsers 2 the extension loads as a no-op so that such environments still resolve, andparseis then unavailable while string constructors and literals keep working.using DataDecimals, Parsers Parsers.parse(DataDecimals.Decimal64{2}, "1234.56") # 1234.56 Parsers.parse(DataDecimals.Decimal64{2}, "1,25"; decimal=',') # 1.25 Parsers.parse(DataDecimals.Decimal64{2}, "1.005"; rounding=RoundUp) # 1.01 Parsers.tryparse(DataDecimals.Decimal64{2}, "1.005"; rounding=DataDecimals.RoundExact) # nothing buf = codeunits("12.34,56.78") Parsers.parsenext(DataDecimals.Decimal64{2}, buf, 1, length(buf)) # (12.34, 6, Parsers.RC_OK)
-
Printf.jl:
%f,%e, and%ground decimal digits half-even before conversion toBigFloat. Binary precision grows with the requested output precision, so decimal ties and wide values retain the correct digits.%dis exact-or-InexactError, matchingRational. -
JSON.jl (1.x): decimals serialize as JSON numbers with their exact digits, neither as strings nor
Float64-rounded, soJSON.json((price = DataDecimals.Decimal64{2}("1234.56"),))gives{"price":1234.56}. -
LinearAlgebra.jl:
A*B,dot,tr, andA+Bstay exact decimal. Factorizations (lu,qr,svd,eigen,det,inv,\, and the rest) convert toFloat64first, because in-place elimination at a fixed scale either throws mid-factorization or silently corrupts the result. Uselu(Rational.(A))for an exact factorization.
docs/ecosystem-compat.md records how these
types behave under DataFrames, Statistics, StatsBase, LinearAlgebra, Random,
ForwardDiff, OrdinaryDiffEq, JuMP, Unitful, StaticArrays, and JSON, together
with the reasoning behind each operation that has more than one defensible
answer, checked against PostgreSQL, DuckDB, SQL Server, Spark, DataFusion,
Arrow C++, pandas, and Python decimal.
115 of its 117 trials pass. The two that do not, StatsBase.summarystats
and adaptive ODE solvers, require AbstractFloat input and fail identically
for Rational; float.(v) is the answer in both cases. The document also
records which analytics operations stay decimal (sum, mean, median,
var) and which return Float64 (std, norm, sqrt, quantile at a
float p), each following what Rational does in Base.
The 0.x Decimal was a heap-backed arbitrary-precision floating decimal
(<: AbstractFloat, sign plus BigInt coefficient plus exponent) with
Python-style context semantics, rounding every operation to a dynamically
scoped precision. Names that keep working include Decimal itself, ==/<
and hash by numeric value, parse/string round-trips for ordinary
values, round(x; digits), dec"..." literals, and DataDecimals.normalize.
The context API (Context, with_context, CONTEXT) is gone, and the
single untyped Decimal becomes a choice between Decimal{P,S} and
DataDecimals.DecimalValue.
The migration guide
covers the replacements one by one;
docs/comparison-with-0.5.1.md is the full
API comparison.
The following measurements are historical, from the Decimals rewrite before this package was named DataDecimals. They are not a new DataDecimals benchmark.
On an Apple M3 Max under Julia 1.12.6, with money-shaped DataDecimals.Decimal64{2}
values: 6.57 ns to parse a string, 1.57 ns to multiply, and 0.309 ns to add,
the last two measured as folds over a 1000-element corpus. A million-element
column holds about 8 bytes of live heap per element, against about 80 for
the 0.5.1 Decimal. Decimal-only scalar arithmetic and comparison are allocation-free on those
measured paths. Mixing with rationals or arbitrary-precision numbers can allocate.
CI also compiles a juliac --trim=safe workload covering parsing,
arithmetic, formatting, and the wire API, so those paths stay statically
compilable.
bench/PERF.md has the method, the machine and version
table, the comparison against Decimals 0.5.1, DecFP, FixedPointDecimals,
rust_decimal, Python decimal, polars, and duckdb, the cases where another
library is faster, and the charts in bench/charts/.
Issues and pull requests are welcome. Run the test suite with
julia --project=. -e 'using Pkg; Pkg.test()'
The benchmark harness is packed in bench/harness.tar.xz; bench/README.md
explains how to unpack and run it.
MIT. See LICENSE.