PetToys.BigDecimal.Npgsql.Dapper 1.0.1

Prefix Reserved
dotnet add package PetToys.BigDecimal.Npgsql.Dapper --version 1.0.1
                    
NuGet\Install-Package PetToys.BigDecimal.Npgsql.Dapper -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.Npgsql.Dapper" 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.Npgsql.Dapper" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="PetToys.BigDecimal.Npgsql.Dapper" />
                    
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.Npgsql.Dapper --version 1.0.1
                    
#r "nuget: PetToys.BigDecimal.Npgsql.Dapper, 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.Npgsql.Dapper@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.Npgsql.Dapper&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=PetToys.BigDecimal.Npgsql.Dapper&version=1.0.1
                    
Install as a Cake Tool

PetToys.BigDecimal.Npgsql.Dapper

NuGet Version NuGet Downloads Unit Test Target frameworks License

Dapper mapping for PetToys.BigDecimal.Core: a PostgreSQL numeric column reads into a BigDecimal and a BigDecimal parameter writes back, at any width the type holds and with System.Decimal nowhere in the path.

Without it, Query<BigDecimal> over a numeric column returns a default-constructed value and throws nothing, because Dapper falls through to property-by-name mapping and the struct's own properties are not column names. That silent answer is what this package replaces.

Bring your own connection and SQL; this package only deals with the value mapping.

Installation

dotnet add package PetToys.BigDecimal.Npgsql.Dapper

PetToys.BigDecimal.Npgsql and PetToys.BigDecimal.Core come along as dependencies.

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.

Usage

There are two registrations and neither works alone. The adapter installs the codec on the data source; this package registers the type handler with Dapper.

using Dapper;
using Npgsql;
using PetToys.BigDecimal.Numerics;

// Once, at start-up: Dapper's registry is static and process-wide.
SqlMapperBigDecimalExtensions.UseBigDecimal();

await using var source = new NpgsqlDataSourceBuilder(connectionString)
    .UseBigDecimal()
    .Build();

Writing needs nothing else - a parameter carries the value and no database type is named at the call site:

await using var connection = await source.OpenConnectionAsync();

await connection.ExecuteAsync(
    "insert into invoices (id, total) values (@id, @total)",
    new { id = 1, total = BigDecimal.Parse("123456789012345678901234567890.123456789") });

Reading exactly is a different call from Query<T>, and the reason is in the next section:

public sealed record Invoice(int Id, BigDecimal Total);

var invoices = await connection.QueryBigDecimalAsync<Invoice>(
    "select id, total from invoices where id = @id",
    new { id = 1 });

Every numeric column of that query reads as BigDecimal - including numeric(p, s), an aggregate over one, a column of a domain over one, and numeric[] into BigDecimal[]. A member of the same result still typed System.Decimal keeps working: it converts where the value fits and throws OverflowException where it does not, so nothing narrows quietly.

For anything QueryBigDecimal does not cover - an unbuffered read, QueryMultiple, multi-mapping with splitOn - wrap the reader and hand it to Dapper's own Parse<T>:

await using var reader = connection
    .ExecuteReader("select id, total from invoices", new { })
    .AsBigDecimalReader();

foreach (var invoice in reader.Parse<Invoice>())
{
}

Why reading is a different call

Dapper's extension point for a custom type is SqlMapper.TypeHandler<T>, whose Parse is handed whatever GetValue already produced. Over a numeric column that is a System.Decimal, and a value wider than one throws inside the driver before the handler is reached. There is no way to ask Dapper for the exact read in an ordinary Query<T>.

The three ways around that all cost something. Making BigDecimal the driver's default for numeric would change what every untyped read in your application already produces. Casting the column to text in the SQL makes every query this package's business. Mapping only the write side leaves reading where it was.

So the exact read is its own call, over a reader that answers numeric columns with the driver's own typed accessor. Nothing global changes, and the widening is scoped to the query you chose it for.

Registering it does not change anything else

BigDecimal is for the columns that need it, not a replacement for System.Decimal across an application. After both registrations, Query<decimal> over 1.50 still answers 1.50, Query<dynamic> still answers a System.Decimal, and a reader still reports System.Decimal as the field type of a numeric column. Code written before this package was referenced reads exactly what it read before, which the test suite asserts rather than the README promising it.

What an ordinary Query<T> does after the registration

It gets better, but it is still bounded by System.Decimal, because that is what the driver produced before the handler saw anything:

Value in the column Query<T> QueryBigDecimal<T>
Inside System.Decimal Exact, scale included Exact
Wider than System.Decimal OverflowException from the driver Exact
NaN, Infinity, -Infinity InvalidCastException or OverflowException from the driver Exact

Neither column of that table narrows a value. Where an ordinary read cannot answer, it fails.

The handler also reads a column you cast yourself - select total::text - which is exact at any width, if you would rather change the SQL than the call.

What round-trips, and what does not

Column Coverage
numeric(p, s), p up to 77 Lossless. This covers numeric(38, 18), the common money and blockchain precision, with room to spare.
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.
numeric unconstrained, integer part beyond the magnitude OverflowException. PostgreSQL allows 131072 integer digits.
NaN, Infinity, -Infinity Lossless through QueryBigDecimal, as BigDecimal.NaN, BigDecimal.PositiveInfinity and BigDecimal.NegativeInfinity. PostgreSQL sorts NaN above every other numeric value where this type sorts it below every other value; both make NaN equal to itself.

The type's own bounds are 77 significant digits, a largest magnitude of 2^256-1, and a range of 1e-255 to approximately 1.157e77.

The scale you read back is the column's, and the server rounds the other way

A value goes out at its own scale and PostgreSQL applies the column's. 1.5 written into a numeric(12,4) is stored and read back as 1.5000; written into an unconstrained numeric it stays 1.5.

Where the column's scale is shorter than the value's, the server rounds half away from zero, and this type rounds half to even: 1.00025 written into a numeric(12,4) is stored as 1.0003, where the type's own rescaling would give 1.0002. This package does not pre-empt that - the handler is handed a parameter and never learns which column it is bound for, so it has no schema to rescale against. If you need this type's rule, rescale before writing.

A value beyond the column's declared precision is refused by PostgreSQL as 22003 numeric field overflow, raised as a PostgresException. The server does not name the column and neither can this package.

Requirements

  • Dapper 2.1.79 up to but not including 3.

  • PetToys.BigDecimal.Npgsql on the data source. Without it the read fails with Reading as 'BigDecimal' is not supported for fields having DataTypeName 'numeric' and a write is refused naming the type. Neither narrows a value.

  • PostgreSQL 14 or later for the infinities, which is where numeric gained the sign codes that carry them.

  • Trimming and Native AOT: Dapper does not work under either, and this package's own marking does not change that. Measured by publishing and running rather than read off a warning:

    • PublishTrimmed: the registration and a single-column read still work, and materialising an object throws InvalidOperationException about a missing constructor, because the trimmer removed the members Dapper reflects for. An anonymous-object parameter stops binding for the same reason.
    • PublishAot: SqlMapper.AddTypeHandler itself throws NotSupportedException over SqlMapper.TypeHandlerCache<T>, before any query runs, because Dapper instantiates that generic reflectively.

    IsAotCompatible is set on this project and this assembly produces no trim, single-file or AOT diagnostic; Dapper 2.1.79 carries no IsTrimmable metadata and produces IL2104 and IL3053. If you publish trimmed or Native AOT, reach numeric through PetToys.BigDecimal.Npgsql directly, which is marked and whose driver is too.

Two things about Dapper's registry

It is static and process-wide - it takes no scope argument - so UseBigDecimal is a start-up call rather than a per-connection one. It is idempotent.

It also replaces any handler already registered for BigDecimal, silently, and Dapper publishes no way to read the registry back, so a handler of your own has to be registered after this one.

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.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 80 9/13/2026
1.0.0 78 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.