PetToys.BigDecimal.Core 1.0.1

Prefix Reserved
dotnet add package PetToys.BigDecimal.Core --version 1.0.1
                    
NuGet\Install-Package PetToys.BigDecimal.Core -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="PetToys.BigDecimal.Core" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PetToys.BigDecimal.Core" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="PetToys.BigDecimal.Core" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add PetToys.BigDecimal.Core --version 1.0.1
                    
#r "nuget: PetToys.BigDecimal.Core, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package PetToys.BigDecimal.Core@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=PetToys.BigDecimal.Core&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=PetToys.BigDecimal.Core&version=1.0.1
                    
Install as a Cake Tool

PetToys.BigDecimal.Core

NuGet Version NuGet Downloads Unit Test Target frameworks License

An allocation-free decimal value type: a 256-bit magnitude, a sign, and a scale of 0 to 255. Every value of at most 77 significant digits is representable, the largest representable magnitude has 78 digits, and the range runs from 1e-255 to roughly 1.157e77. The whole state lives in the struct and the working buffers on the stack, so an operation allocates only what it hands back.

It exists because PostgreSQL numeric and ClickHouse Decimal* columns hold values that decimal cannot represent - a large integer part, a long fraction, sometimes both in the same column. Inside decimal's own domain the semantics deliberately mirror decimal: trailing zeros survive arithmetic and formatting, equality is numeric (1.0 == 1.00), and excess fractional digits are rounded half-to-even rather than throwing. The 256-bit magnitude is the only hard limit: when a value's significant digits do not fit, the scale is reduced - the fraction rounded away - as far as needed, and OverflowException is reserved for an integer part that still does not fit.

The type implements INumber<T>, ISignedNumber<T>, IMinMaxValue<T>, the IParsable/ISpanParsable/IUtf8SpanParsable and IFormattable/ISpanFormattable/IUtf8SpanFormattable families, and ships a System.Text.Json converter.

Pow(value, exponent) raises a value to an integer power, and it is exact whenever the exact power is representable: 61 significant digits of 1.05 to the 30th come back digit for digit, where a hand-written multiplication loop would have rounded at every step. A power too wide to represent gives up fractional digits, rounded half to even once from a 154-digit working value against the 77 a result keeps - room enough that the digit the rounding reads is the exact power's, short of the near-tie the method's own remarks describe. A negative exponent is the reciprocal, to the same precision a division without an explicit scale gives, and it answers wherever its own result fits even when the power it inverts does not.

Formatting matches decimal string for string: the C, E, F, G, N, P and R specifiers with an optional precision, custom numeric format strings, and the culture's own group sizes and negative patterns - so a culture that writes (1,234.5) gets that rather than a leading sign. Both the char and the UTF-8 overload write the same text, bounded only by the destination the caller passes.

No runtime dependencies. Database helpers live in separate packages: PetToys.BigDecimal.Npgsql and PetToys.BigDecimal.ClickHouse.

Range and database coverage

The magnitude spans 0 to 2^256-1 and the scale 0 to 255. Quote those two bounds rather than a single digit count: 77 significant digits always fit, a 78-digit value fits only up to 2^256-1, and the scale decides where those digits sit - from 1e-255 to roughly 1.157e77.

Column type Coverage
ClickHouse Decimal32(S), Decimal64(S), Decimal128(S), Decimal256(S) Lossless, for every precision and scale ClickHouse allows. The widest of them carries 76 significant digits, one fewer than always fit here.
ClickHouse Decimal(P, S), P from 1 to 76 Lossless.
PostgreSQL numeric(p, s), p up to 77 Lossless. This covers numeric(38, 18), the common money and blockchain precision, with room to spare.
PostgreSQL numeric unconstrained, integer part within the magnitude Accepted; fractional digits beyond what the magnitude leaves are rounded half to even. PostgreSQL allows 16383 of them, so a value read from such a column can lose digits silently.
PostgreSQL numeric unconstrained, integer part beyond the magnitude OverflowException. PostgreSQL allows 131072 integer digits.
PostgreSQL NaN, Infinity, -Infinity Lossless, as BigDecimal.NaN, BigDecimal.PositiveInfinity and BigDecimal.NegativeInfinity. No ClickHouse decimal has a counterpart, so writing one to a ClickHouse column is refused rather than approximated. PostgreSQL sorts NaN above every other numeric value where this type sorts it below every other value; both make NaN equal to itself.

Presenting a value at a column's declared scale is what WithScale is for: it pads as well as rounds, where Round only ever narrows.

var price = BigDecimal.Parse("1.5", CultureInfo.InvariantCulture);
price.WithScale(18);                 // 1.500000000000000000, for numeric(38,18)
BigDecimal.Round(price, 18);         // 1.5 - Round never pads

What it costs

Allocation-free is not the same as cheap. A value is 40 bytes against decimal's 16 - four 64-bit magnitude words and a packed 32-bit field - and every binary operator takes both operands by value, so an operation copies 80 bytes before it does any work. INumber<T> declares its operators by value, so an in overload cannot be added without leaving the interface.

Against System.Decimal, on the operand shapes and the machine recorded in BASELINE.md and with zero allocations on every row: Add and Subtract at 2.4x and 2.6x, Multiply 3.2x, Divide 5.3x, Remainder 2.8x, Parse 1.1x, TryFormat 2.8x, the UTF-8 overloads within 0.2x of the char ones either way. One machine, one shape per row, taken to grade a budget rather than to publish a benchmark - an order of magnitude, not a specification. Division is the worst case and the one to measure yourself.

GetHashCode carries no budget and is a larger multiple than anything above. Agreeing with numeric equality sends every hash through the value's shortest form: a copy of the magnitude, a test for trailing zeros, and a division pass over it only when there are zeros to remove. A value carrying none skips that pass, as decimal does for the same reason, and measures 12.7x to 16.1x decimal's hash - the wider mantissa at the top of the range, against a baseline of a few instructions under a nanosecond. One widened to a database column's scale pays the pass and costs about 2x as much, which is a reason to hold dictionary keys at their shortest scale.

The working buffers are on the stack: counted across the whole call rather than one frame, a division, a parse and a ToString each take between one and one and a half kilobytes. Ordinary for a call from application code, worth knowing before a deeply recursive path or an async state machine whose stack is already hot.

Trimming and Native AOT

The assembly is marked IsAotCompatible, which implies IsTrimmable. That is a gate rather than a claim: the trim, single-file and AOT analyzers run over this project and warnings are errors, so a reflective path could not be added without failing the build. It is also verified by publishing a probe application over the package and running it - trimmed on every supported framework and Native AOT on the newest, on Windows and on Linux - because a clean analyzer pass and a binary that throws on first use look identical from inside the build.

One thing changes for you, and it is System.Text.Json rather than this type. Both PublishTrimmed and PublishAot turn off reflection-based serialization, so JsonSerializer.Serialize(value) throws InvalidOperationException there for any type at all. Reach the converter through a source-generated context instead: the [JsonConverter] attribute BigDecimal carries is honoured on that path, and the text is identical to what a jitted build produces.

[JsonSerializable(typeof(Invoice))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

var json = JsonSerializer.Serialize(invoice, AppJsonContext.Default.Invoice);

Formatting stays culture-aware in a trimmed and Native AOT binary. Nothing here needs InvariantGlobalization, and a value formatted under a culture whose separators differ from the invariant one produces that culture's separators, as it does when jitted.

Reading a value from text that is not code

TypeDescriptor.GetConverter(typeof(BigDecimal)) answers a converter, so configuration binding, model binding and anything else that reaches a type through reflection reads a BigDecimal from a string with no registration of yours:

// appsettings.json: { "Limits": { "Ceiling": "123456789012345678901234.5678" } }
builder.Services.Configure<Limits>(builder.Configuration.GetSection("Limits"));

It converts text and nothing else, in both directions, and it carries no rules of its own - it calls this type's parse and format, so the two cannot disagree. A culture you pass is honoured; no culture means the invariant one, which is what a value out of a configuration file needs.

Numbers still convert to numbers through the cast operators and generic math. The converter deliberately refuses them, so there is one route with compiler checking rather than two with different rules.

This type implements no IConvertible, on purpose

Convert.ToDecimal(value) does not compile against it, and a library that does not recognise the type cannot fall back to converting it either. That is the point. The interface would have to answer for a value of up to 77 significant digits, and the two honest answers are to narrow it - losing the number exactly where nobody is looking - or to throw, which turns a loud failure into one that only appears for values too large to fit, which test data rarely is.

Two packages in this repository depend on the absence: a BigDecimal reaching ClickHouse.Driver without the parameter formatter installed fails before anything is sent, and one reaching Dapper without a handler registered is refused by name. If a library refuses your value, that is this decision working.

Installation

dotnet add package PetToys.BigDecimal.Core

The .Core suffix belongs to the package, not to the API: the type is PetToys.BigDecimal.Numerics.BigDecimal, the same namespace the database packages put their helpers in.

Releases go to nuget.org, so the command above is all that is needed. Prereleases are published to GitHub Packages instead: that feed has to be added to your nuget.config, and it requires a personal access token with read:packages even for a public package.

License

Provided under the Apache License, Version 2.0.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on PetToys.BigDecimal.Core:

Package Downloads
PetToys.BigDecimal.Npgsql

PostgreSQL helpers that map numeric columns to PetToys.BigDecimal.Core when reading and writing through Npgsql.

PetToys.BigDecimal.ClickHouse

ClickHouse helpers that map Decimal32, Decimal64, Decimal128, and Decimal256 columns to PetToys.BigDecimal.Core when reading and writing through ClickHouse.Driver.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 159 9/13/2026
1.0.0 160 9/12/2026

A patch release over 1.0.0. One tag versions every package in the repository, so
all six are published together; what each of them carries is below.

PetToys.BigDecimal.Core

Fixed:
- Parsing a literal whose exponent is far below the scale floor no longer costs
 one division per digit it drops. A scale above 255 is reduced to it, so
 "1e-99999" asked the pack to drop 99744 decimal positions and it divided for
 every one of them, thousands of times over after the magnitude had already
 become zero. The parser caps an exponent at 100000, which is what bounds the
 worst case rather than what creates it. That input took 48 microseconds on
 .NET 10 before the change; after it, the benchmark reads the same literal at
 1.2x a parse that drops nothing, such as "1e-200". Every parsed value, scale
 and sign is unchanged - those literals are zero at scale 255 before the
 change and after it - and rounding, rescaling and the database wire paths
 reach the same helper and stop the same way.

Notes:
- What GetHashCode costs is documented now, in the README and in the method's
 own remarks. A hash has to agree with numeric equality, so it goes through
 the value's shortest form: one carrying no trailing zeros costs 12.7x to 16.1x
 decimal's hash, which strips them for the same reason, and one widened to a
 database column's scale about 2x as much, because that one pays a division
 pass over the magnitude. Nothing in the algorithm changed - the table of costs
 covered arithmetic, parsing and formatting and simply did not reach hashing.
- What Pow guarantees for a power it cannot represent exactly is stated now, in
 the method's own remarks and in the README, and the suite carries the deep
 chains that back it. The power is raised in a working width of 154 digits
 against the 77 a result keeps, so the rounding reads the digit the exact power
 reads unless the exact power sits nearer the midpoint than 1e-66 of a unit in
 the last place, which takes a 5 and then sixty-five zeros past the 77th
 digit, or a 4 and then sixty-five nines. Nothing in the algorithm changed and
 no value moved; what changed is that the documents no longer read as though
 the exact power were carried.