Decimal Numbers#
Status: decided (2026-10-12), built in four slices (the last, #336, on 2026-10-28). — Decimal is declared in
std/number (native type Decimal with Signed, Hash), both back ends give it a value, and section 11 is built: JSON,
YAML and TOML keep every digit. This record fixes what the type is, so that the three slices of section 14 build one specification and not three
guesses. It is a numeric design choice inside the language: the type has never run, so no published contract changes.
A Decimal is an integer with the decimal point written beside it, and the arithmetic that cannot lose a digit never
does. 0.1 + 0.2 is 0.3, 19.90 * 3 is 59.70, and the one operation that cannot be exact - a division with an
endless expansion - rounds in a way the program can read and can override. That is what money, tax, measures in a
database column and the numeric of every SQL driver need, and what no binary floating-point type can promise.
19.90 -0.0050 1000 written 1e3
┌────────────┬─────┐ ┌────────────┬─────┐ ┌────────────┬─────┐
│ unscaled │scale│ │ unscaled │scale│ │ unscaled │scale│
│ 1990 │ 2 │ │ -50 │ 4 │ │ 1 │ -3 │
└────────────┴─────┘ └────────────┴─────┘ └────────────┴─────┘
value = unscaled / 10^scale: 1990 / 100 -50 / 10000 1 / 10^-3- 1. What is decided
- 2. The value and its range
- 3. Equality, order and hash
- 4. Text:
ShowandTryFrom<String> - 5. Addition, subtraction, multiplication
- 6. Division and remainder
- 7. Rounding
- 8. Conversions
- 9. Literals
- 10. Runtime representation and natives
- 11. Encoding and the database drivers
- 12. Worked examples — the table the conformance programs copy
- 13. Where this comes from
- 14. Slices
1. What is decided#
- The value is an unscaled integer of any size and an
Int32scale (section 2), as Java'sBigDecimaland Python'sdecimalhave it. Postgresnumeric, MySQLDECIMALand BSONDecimal128all fit without loss. +,-and*are exact. The scale of a sum is the larger scale, of a product the sum of the scales (section 5). Only the scale leavingInt32, or memory, panics./rounds to 34 significant digits, half to even, when the exact quotient has more; it panics on a zero divisor likeInt.%is the truncated remainder with the sign of the dividend, exact (section 6).- Rounding is explicit where the program chooses it:
rounded(scale:, rounding:)anddivided(by:, scale:, rounding:)take one of six modes (section 7). - Equality is by value:
1.0 == 1.00.compareandhashagree with it,Showkeeps the scale (section 3 and 4). - A literal keeps every digit it was written with and never passes through
Float64(section 9). Conversions from and toFloatare explicit, and the one fromFloatcan fail (section 8). - Seed: none expected. The compiler does not use
Decimal, and its natives are declared already (section 10).
2. The value and its range#
A Decimal is a pair: an unscaled integer (a sign and a magnitude of any size) and a scale (an Int32). Its
value is unscaled / 10^scale.
- The scale may be negative.
1e3is1with scale-3and1000is1000with scale0: equal values, two forms. A database column has a scale of zero or more, andDecimal128has a negative exponent as well as a positive one, so the model has to hold both without loss. - The scale is part of the form, not of the value.
19.9and19.90are equal and are differentDecimals toShow(section 3), toscale()and to a database that stores the scale. Arithmetic never drops a scale it did not have to (section 5). - Zero has no sign.
-0.00is0.00, andDecimal.zero - Decimal.zerois not "negative zero" in any form. There is nonanand no infinity, which is the reason aDecimalisHashand aFloatis not. - There is no limit on the magnitude and none on the number of digits, except memory: a result that does not fit
panics like every out-of-memory panic does. The scale is the one bounded part: an operation whose result scale is
below
Int32.minimumor aboveInt32.maximumpanics withdecimal scale out of range in `*`(the operator's own name). Text and literals whose scale does not fit are refused instead (section 4 and 9). - There is no context. Python's global precision and rounding, and Java's
MathContext, are not here: the one precision is the 34 digits of/(section 6), and every other rounding is an argument of the call that rounds.
3. Equality, order and hash#
==,<andcompareare by numeric value.1.0 == 1.00,compare(1.10, 1.1)is equal, and-0.0(which reads as0.0) equals0.00.hashis consistent with equality: equal values hash equal whatever their scale. It is the hash of the normalised form - the unscaled integer with its trailing zeros removed and the scale lowered to match, so19.90,19.9and19.900are199with scale1- astorb_hash_bytes(FNV-1a-64, a fixed seed) of that form'sShowtext. Zero hashes as the text0.- A
Map<Decimal, V>therefore has one key for1.0and1.00, and the key it keeps is the one that was inserted first, with its own scale. Showis the one place the scale is visible:19.90stays19.90and prints with its zero. A program that wants the form without the zeros callsnormalized()first (section 10).
4. Text: Show and TryFrom<String>#
show() writes the value in plain notation, never with an exponent:
show ::= "-"? integer ("." fraction)?integeris the digits of the unscaled integer left of the point, at least one (0.05has a0).- With a scale above zero, the point and exactly
scaledigits follow, zeros included:19.90,0.05,-0.5000. - With a scale of zero, no point:
42. With a negative scale, the unscaled digits followed by-scalezeros:1with scale-3is1000. - Zero is
0,0.00, never-0.00. ADecimalinside a generatedShow(Price(amount: 19.90)) and in an interpolation ("{price}") is this text, unquoted.
Decimal.tryFrom(text) reads this grammar, and show() output always reads back to an equal value with the same
scale (for a scale of zero or more):
text ::= sign? digits ("." digits)? exponent?
sign ::= "+" | "-"
digits ::= digit+
exponent ::= ("e" | "E") sign? digits- The scale is the number of digits after the point minus the exponent:
"12.5E-3"is125with scale4. Leading zeros are read and dropped ("007"is7). - It refuses with
NumberParseError(the text, unchanged): the empty text, whitespace anywhere, a missing digit on either side of the point (".5","5."), an exponent without digits,nan,inf, a digit separator ("1_000"), a hexadecimal or binary prefix, a thousands separator or a decimal comma, and a scale that does not fitInt32("1e2147483648","1.5e-2147483647"). - The grammar is the grammar of a number literal, without the digit separator (section 9). It is deliberately
narrower than
Float64.tryFrom, which isstrtod: a money amount must not read"inf".
5. Addition, subtraction, multiplication#
a + banda - bare exact. The unscaled integers are brought to the larger of the two scales (19.90 + 0.1is20.00, the scale of the first), then added. The result keeps that scale even where it ends in zeros.a * bis exact. The unscaled integers multiply and the scales add:1.5 * 2.00is3.000,19.99 * 3is59.97.-aanda.absolute()keep the scale, and negating zero is zero.- The scale of a result outside
Int32panics (section 2); so does a result that does not fit memory, which a scale difference of a few billion digits (1e-2000000000 + 1) reaches. +,-and*never round and never lose a digit. A program that wants fewer digits callsrounded(section 7).
6. Division and remainder#
An exact division has no shape that always works: 1 / 3 has no end. Three ways out exist (Java throws, Python rounds
to a global precision, Go's shopspring to a global 16 digits); this language does the Python thing with a fixed,
visible precision and no global.
a / bpanics onb == 0withdivision by zero in `/`(PANICS.md), asIntdoes.- Otherwise the exact quotient is looked at. Let
p = 34(the precision ofDecimal128) ands0 = scale(a) - scale(b), the preferred scale.- If the exact quotient has at most 34 significant digits, the result is exact. Its scale is the larger of
s0and the smallest scale that holds the quotient (its scale after trailing zeros are removed; zero counts as scale0), as long as the result keeps at most 34 significant digits. So10 / 4is2.5,6.0 / 3is2.0,1.00 / 4is0.25,1.00000 / 4is0.25000,100 / 4.0is25. - Otherwise it is rounded to 34 significant digits, half to even, and not stripped:
1 / 3is0.3333333333333333333333333333333333,2 / 3ends in67.
- If the exact quotient has at most 34 significant digits, the result is exact. Its scale is the larger of
a % bis the truncated remainder, exact:a - b * trunc(a / b), with the sign of the dividend, as forInt(-7 % 2is-1). Its scale is the larger of the two scales (-5.50 % 0.4is-0.30); a zero remainder is zero.x % 0panics withdivision by zero in `%`. The quotient of the definition is a whole number and is not limited to 34 digits, so the remainder is always exact.- For a result of a chosen shape,
divided(by:, scale:, rounding:)(section 7) takes the scale and the mode and rounds the exact quotient once./is for "the quotient, as exact as is useful";dividedis for "cents" and "basis points".
7. Rounding#
/** How a value is cut where a digit has to go. The six modes are final: no case is added within 1.x. */
public type Rounding {
/** To the nearest, a tie to the neighbour with an even last digit. The mode of `/`, banker's rounding. */
case HalfEven
/** To the nearest, a tie away from zero: `2.5` is `3` and `-2.5` is `-3`. What a person at a till does. */
case HalfUp
/** Towards zero: the digits are cut off. */
case Down
/** Away from zero: any dropped non-zero digit raises the magnitude. */
case Up
/** Towards negative infinity. */
case Floor
/** Towards positive infinity. */
case Ceiling
}rounded(scale: Int32, rounding: Rounding): Decimalreturns the value with exactly that scale. A scale above the current one adds zeros and rounds nothing (2.5at3is2.500); a scale below drops digits by the mode; a negative scale rounds to tens, hundreds (1250at-2,HalfEven, is1200).divided(by: Decimal, scale: Int32, rounding: Rounding): Decimalreturns the exact quotient rounded once to that scale. It panics on a zero divisor like/. It never rounds twice:1 / 3at scale2withUpis0.34, not the0.33of the 34-digit quotient rounded again.HalfDownand the other tie-breaking modes are not offered. The six cover the ones that databases, accounting rules andFloat.round(halves away from zero, hereHalfUp) name; a seventh can be added as a method onRoundingwithout a new case.- There is no
round(),floor()orceiling()onDecimal: those names are theRealtrait's, and aDecimalis notReal(no square root, no angle).rounded(scale: 0, rounding: .Floor)is the floor.
8. Conversions#
| From → to | Spelled | Rule |
|---|---|---|
Int64, UInt64 → Decimal | Decimal.from(42) | infallible, scale 0 |
Decimal → Int64, UInt64 | Int64.tryFrom(price) | NumberRangeError unless the value is a whole number in range: 19.00 converts, 19.5 does not. Truncation is a decision, so it is spelled: price.rounded(scale: 0, rounding: .Down) first |
Float64 → Decimal | Decimal.tryFrom(0.1) | NumberRangeError for nan and the infinities; otherwise the shortest decimal that reads back as that Float64, i.e. Decimal.tryFrom(x.show()) for a finite x: 0.1 is 0.1 and not the binary expansion 0.1000000000000000055… |
Decimal → Float64 | Float.from(price) | infallible and rounded: the nearest Float64, half to even, as reading the decimal's text as a Float64 gives; beyond the range inf or -inf, as a Float literal would, and no panic |
- No implicit conversion exists, as everywhere (
CONCEPT.md).price * 2types because the literal2adapts (section 9);price * quantitywith anIntvariable isprice * Decimal.from(quantity). Float32has no direct conversion; it goes throughFloat64. The conversions are instd/number, beside the type.- The unscaled integer is reached as text, because the language has no big integer:
unscaledText(): String(sign and decimal digits) withscale(): Int32, andDecimal.of(unscaled: String, scale: Int32)as the way back, aResult<Decimal, NumberParseError>that accepts a sign and digits only. A binary driver builds the wire form (Postgres sends base-10000 digits and a weight) from these two.
9. Literals#
- A literal that adapts to
Decimalkeeps every digit it was written with.19.99is1999with scale2,19.90keeps its zero,1_000.50is1000.50,1e3is1with scale-3. It is read with the grammar of section 4 plus the digit separator_, and it never passes throughFloat64:const price: Decimal = 0.1is exactly one tenth. A written scale that does not fitInt32is a compile error. - An integer literal adapts as well, in any base:
const n: Decimal = 255and0xFFare255with scale0, asconst z: Float = 1is allowed (CONCEPT.md). A literal that has no expected type stays what it was:19.99aFloat64,5anInt64. -19.99is19.99negated, scale2.Decimal.zeroandDecimal.onebecome0and1(scale0), not the0.0and1.0they are written as instd/numbertoday, so thatDecimal.zero + 5keeps the scale of5.- A literal is a constant. Its value is built by the compiler's constant evaluator and emitted by both back ends as
data (section 10), so a
Decimalconstant allocates nothing at run time.
10. Runtime representation and natives#
The IR already has the kind: RuntimeKind.DecimalNumber, laid out as pointerSize + 8 bytes in
compiler/src/ir/layout.trb. That layout is kept:
Decimal (inline, pointerSize + 8 bytes)
┌─────────────────────┬────────────┬───────────────────┐
│ block pointer │ Int32 scale│ Int32 limb count │ count < 0: negative; 0: the value is zero
└──────────┬──────────┴────────────┴───────────────────┘
▼ counted, immutable block (the header every counted block has)
uint32 limbs, base 10^9, least significant first, the top limb non-zero- A value is the inline triple; the digits are a counted, immutable heap block that copying a
Decimalretains and dropping releases, like the storage of a string. Zero has no block (a null pointer and a count of zero). Because the block is immutable,negateandabsoluteshare the input's block, androundedto a larger scale builds a new one. - Base 10^9 is chosen for the text, not for the speed of multiplication: the digits of
show()andparseare the limbs, so a number is never converted between bases, and the rounding of a scale is a shift of limbs and one division of a single limb. Schoolbook multiplication and division on 32-bit limbs are enough (no big-integer library; the runtime has none and takes none). - An immortal static block (
TORB_IMMORTAL_COUNT,runtime/include/torb.h) is what a literal is, which is why a constant needs no allocation (section 9). - A small value is not special-cased. An inline form for a unscaled value in 63 bits is an optimisation that no program can see; the layout would change, so it is not part of this record.
The natives, as torb_decimal_<member> in a new runtime/decimal.c, with their manifest entries in
compiler/src/backend/c/natives.trb changing from Planned to Ready:
Planned today (12, decimalEntries) | add subtract multiply divide remainder negate absolute equals compare show hash, and parse for tryFrom(String) |
|---|---|
| New in this record | rounded, divided, scale, normalized, unscaledText, of(unscaled:scale:), Decimal.from(Int64), Decimal.from(UInt64), Int64.tryFrom(Decimal), UInt64.tryFrom(Decimal), Float64.from(Decimal) |
| Written in TorbScript, no native | Decimal.tryFrom(Float64) (over Float64.show and Decimal.tryFrom(String)) |
Float64.from(Decimal)is a native becauseFloat64.tryFrom(String)copies into a 512-byte buffer and refuses a longer text. The native keeps at most 800 significant digits and one sticky digit for the rest, which is enough to round correctly.normalized()removes the trailing zeros of the unscaled integer and lowers the scale to match (it may become negative); it is whathashis defined on (section 3).- Seed: none expected. The compiler does not compute with
Decimal, and a native the compiler does not reach does not need the seed to know it. Slice #334 re-checks that withir --statistics .: aDecimalnative that the compiler's own program reaches would put the slice under the two-commit rule ofCLAUDE.md("Seed and breaking changes"). - The VM reaches the same C functions through the table of thunks that
torb nativesgenerates from the manifest (VM.mdsection 6), so the arithmetic is written once.
11. Encoding and the database drivers#
EncodedValuegetscase Exact(value: Decimal), whichENCODING.mdsection 7 declares and section 16 (item 3) leaves out until the runtime has aDecimal, where the exact number is held as its text today.Values.decimal,ValueDecoder.decimalandJsonEncoder.decimalstop being the three findingsir --statistics .counts for encoding.- JSON writes a
Decimalas a number token with theshow()text (19.90stays19.90) and reads a number into aDecimalfrom the token's text, never throughFloat64;1e3is read as1000. ADecimalfield of a derivedDecodethat meets a string refuses it, as every number field does.DescribesaysExact, which a schema format renders as its number type (NUMERICin SQL). - As built (#336).
ValueDecoder.decimalreads anExactas it is, anInt,UnsignedorFloatthrough its text, and refuses a string unless the decoder islenient, was built withexactAsText(TOML: no decimal type, so theDecimalis a string of itsshow()text) or the string is a map key. In a document without a type, JSON and YAML read a number token that aFloat64prints back (1.5) as aFloatand any other (19.90,1e3, 40 digits) as anExact. A negative scale is written in plain notation, so1e3comes back as the same value at the scale 0. AFloat64or anInt64field reads anExactthroughFloat64.fromandInt64.tryFrom.std/sandboxwrites anExactasx19.90;. The conformance program isdecimal-encoding. - The drivers: Postgres
numericand MySQLDECIMALmap one to one (the scale of the column isscale()). BSONDecimal128holds 34 digits and an exponent from-6176to6111: aDecimalwith more digits or a scale beyond that is refused by the driver with its own error, not rounded.
12. Worked examples#
The conformance programs of slices #334 to #336 copy these rows. Every result text was computed with Python's decimal
module at 400 digits and a 34-digit context for /, and then corrected by the rules above where the two differ (zero
has no sign here). a → b reads "the text of the result is b".
Reading text (Decimal.tryFrom(text), shown with show()):
| Text | Result | Why |
|---|---|---|
"19.90" | 19.90 | the scale is the digits after the point |
"19.9" | 19.9 | |
"0019.90" | 19.90 | leading zeros dropped |
"-0.00" | 0.00 | zero has no sign |
"+5" | 5 | an explicit plus |
"1.50e2" | 150 | scale 2 - 2 |
"12.5E-3" | 0.0125 | scale 1 + 3 |
"1e3" | 1000 | scale -3 |
"", " 1", "1 ", "1_000", ".5", "5.", "1e", "1e+", "nan", "inf", "0x10", "1,5", "1e2147483648" | NumberParseError | section 4 |
Equality, order, hash:
| Operation | Result |
|---|---|
1.0 == 1.00 | true |
compare(1.10, 1.1) | equal |
compare(-0.5, 0.25) | less |
hash(19.90) == hash(19.9) | true |
show(19.90), show(19.9) | 19.90, 19.9 |
+, -, *:
| Expression | Result | Expression | Result |
|---|---|---|---|
19.90 + 0.1 | 20.00 | 1.5 * 2.00 | 3.000 |
1.5 + 2 | 3.5 | 19.99 * 3 | 59.97 |
0.1 + 0.2 | 0.3 | -0.5 * 0.5 | -0.25 |
1.00 - 1.5 | -0.50 | 0.00 * -3 | 0.00 |
0.10 - 0.10 | 0.00 | 5 - 7.25 | -2.25 |
/ (34 significant digits, half to even, preferred scale scale(a) - scale(b)):
| Expression | Result |
|---|---|
1 / 3 | 0.3333333333333333333333333333333333 |
2 / 3 | 0.6666666666666666666666666666666667 |
1 / 7 | 0.1428571428571428571428571428571429 |
1 / 1.5 | 0.6666666666666666666666666666666667 |
100 / 3 | 33.33333333333333333333333333333333 |
0.1 / 3 | 0.03333333333333333333333333333333333 |
10 / 4 | 2.5 |
6.0 / 3 | 2.0 |
1.00 / 4 | 0.25 |
1.00000 / 4 | 0.25000 |
100 / 4.0 | 25 |
1 / 8 | 0.125 |
-7 / 2 | -3.5 |
19.90 / 0.5 | 39.8 |
0 / 4.00 | 0 |
1 / 0, 0 / 0.00 | panic division by zero in `/` |
% (truncated, sign of the dividend, larger scale):
| Expression | Result | Expression | Result |
|---|---|---|---|
7 % 2 | 1 | 5.5 % 2 | 1.5 |
-7 % 2 | -1 | -5.50 % 0.4 | -0.30 |
7 % -2 | 1 | 6.0 % 3 | 0.0 |
1 % 3 | 1 | 1 % 0 | panic division by zero in `%` |
rounded(scale:, rounding:): the columns are HalfEven, HalfUp, Down, Up, Floor, Ceiling.
| Value, scale | HalfEven | HalfUp | Down | Up | Floor | Ceiling |
|---|---|---|---|---|---|---|
2.5, 0 | 2 | 3 | 2 | 3 | 2 | 3 |
3.5, 0 | 4 | 4 | 3 | 4 | 3 | 4 |
-2.5, 0 | -2 | -3 | -2 | -3 | -3 | -2 |
-3.5, 0 | -4 | -4 | -3 | -4 | -4 | -3 |
2.51, 0 | 3 | 3 | 2 | 3 | 2 | 3 |
2.0, 0 | 2 | 2 | 2 | 2 | 2 | 2 |
1.005, 2 | 1.00 | 1.01 | 1.00 | 1.01 | 1.00 | 1.01 |
0.125, 2 | 0.12 | 0.13 | 0.12 | 0.13 | 0.12 | 0.13 |
-0.125, 2 | -0.12 | -0.13 | -0.12 | -0.13 | -0.13 | -0.12 |
2.5, 3 | 2.500 | 2.500 | 2.500 | 2.500 | 2.500 | 2.500 |
1250, -2 | 1200 | 1300 | 1200 | 1300 | 1200 | 1300 |
divided(by:, scale:, rounding:), same columns:
| Expression | HalfEven | HalfUp | Down | Up | Floor | Ceiling |
|---|---|---|---|---|---|---|
1.divided(by: 3, scale: 2) | 0.33 | 0.33 | 0.33 | 0.34 | 0.33 | 0.34 |
2.divided(by: 3, scale: 2) | 0.67 | 0.67 | 0.66 | 0.67 | 0.66 | 0.67 |
10.divided(by: 4, scale: 0) | 2 | 3 | 2 | 3 | 2 | 3 |
1.divided(by: 8, scale: 2) | 0.12 | 0.13 | 0.12 | 0.13 | 0.12 | 0.13 |
-1.divided(by: 8, scale: 2) | -0.12 | -0.13 | -0.12 | -0.13 | -0.13 | -0.12 |
1.divided(by: 3, scale: 0) | 0 | 0 | 0 | 1 | 0 | 1 |
Conversions and literals:
| Expression | Result |
|---|---|
Decimal.from(42), Decimal.from(-7) | 42, -7 |
Int64.tryFrom(19.00) | 19 |
Int64.tryFrom(19.5) | NumberRangeError |
Decimal.tryFrom(0.1) (a Float) | 0.1 |
Decimal.tryFrom(2.0) | 2.0 |
Decimal.tryFrom(0.1 + 0.2) | 0.30000000000000004 |
Decimal.tryFrom(Float.nan) | NumberRangeError |
Float.from(19.99) (a Decimal) | 19.99 |
const a: Decimal = 19.90 | 19.90 |
const b: Decimal = 5 | 5 |
const c: Decimal = 1_000.50 | 1000.50 |
const d: Decimal = 0xFF | 255 |
const e: Decimal = -19.99 | -19.99 |
const f: Decimal = 0.1 ; f + 0.2 == 0.3 | true |
13. Where this comes from#
| System | Value | Division | Taken or left |
|---|---|---|---|
Java BigDecimal | BigInteger and an int scale | divide throws on an endless expansion, MathContext otherwise | the model, and the preferred scale; no exception, no context |
Python decimal | coefficient and exponent, context precision 28 | rounds to the context | the rule for / with a fixed 34 and no global |
C# decimal | 96-bit integer, scale 0 to 28 | 28 digits | left: a range limit loses what a database column holds |
Go shopspring | big.Int and an int32 exponent | global DivisionPrecision 16 | left: a mutable global |
IEEE 754 Decimal128 | 34 digits, exponent -6176 to 6111, with nan and infinities | rounded to 34 digits | the 34 digits and the half-even default; no nan, which a money type must not hold |
14. Slices#
One pull request each, in this order, because each stands on the one before it. #34 stays the umbrella and closes with the last.
| Slice | Issue | Content |
|---|---|---|
| 1 | #332 | this record and the links to it |
| 2 | #334 | runtime/decimal.c and its C tests; every native of section 10 declared in std/number and Ready in the manifest; Rounding; the VM reaches them. Programs build Decimal from text and compute |
| 3 | #335 | literals and constants in the IR and both back ends (section 9): the checker's adaptation of integer and decimal literals, the constant evaluator, the static block; Decimal.zero and one |
| 4 | #336 | the rest of the std surface (section 8), Encode/Decode and Describe with Exact (section 11), the conformance programs of section 12 in both back ends, the language page language/values-and-types/decimal.md from planned to stable |
Not decided here and not needed for 1.0: Power and roots of a Decimal, a Decimal with a fixed width or a currency
(a type Money over Decimal is a library), locale formatting (thousands separators, a decimal comma) and an inline
form of small values (section 10). Each adds methods and changes no rule above.