Skip to content

1757 - Decimal precision support - #2377

Open
ValeryVerkhoturov wants to merge 1 commit into
qax-os:masterfrom
ValeryVerkhoturov:decimal-support
Open

1757 - Decimal precision support#2377
ValeryVerkhoturov wants to merge 1 commit into
qax-os:masterfrom
ValeryVerkhoturov:decimal-support

Conversation

@ValeryVerkhoturov

@ValeryVerkhoturov ValeryVerkhoturov commented Aug 15, 2026

Copy link
Copy Markdown

PR Details

Description

SetCellValue now recognizes arbitrary precision decimal types and stores them
as numbers rather than falling through to fmt.Sprint and becoming a string.

Detection is structural, so no dependency is added. Both of the widely used
decimal packages expose the same two methods on a value receiver, which is
enough to match on:

type decimalValue interface {
	Float64() (float64, bool)
	String() string
}

github.com/shopspring/decimal has Float64() (f float64, exact bool) and
github.com/govalues/decimal has Float64() (f float64, ok bool), so both
satisfy it as-is, as do pointers to them.

The cell is written from String() rather than from Float64(), so the digits
never pass through a float64 and nothing is rounded on the way out:

<c r="A1"><v>1234567890.12345678</v></c>

That is the same shape excelize already emits for a float64: no t
attribute, which is the implicit number type.

Covered paths:

  • SetCellValue, and therefore SetSheetRow / SetSheetCol
  • StreamWriter.SetRow, via setCellValFunc

Fallbacks are conservative, so nothing that works today changes:

  • A nil pointer is rejected before any method call, so it cannot panic and
    still renders through the existing fmt.Sprint path.
  • If String() does not yield something isNumeric accepts, the value falls
    through to the current string path. This matters because *math/big.Rat also
    satisfies the interface, but its String() returns "3/2", so it stays a
    string exactly as it does now.

One behavior change worth calling out explicitly: a type that implements that
method set and was previously stored as a string will now be stored as a
number. That is the point of the change, but it is a visible difference for
anyone who was relying on the old stringly behavior.

Related Issue

#1757

Motivation and Context

Issue #1757 asks for storing decimal precision values natively, for financial
data where a float64 is not an acceptable carrier.

Today the only two options both lose something. Passing
decimal.InexactFloat64() produces a number cell but silently rounds once an
amount needs more than about 15 significant digits. Passing the decimal object
itself hits the default branch of SetCellValue, which stringifies it, so the
cell becomes text: Excel will not sum it, SUM skips it, and the column is not
a number at all.

This change gives the third option, a number cell holding the exact digits, with
no dependency on either decimal package.

On precision, and where it does and does not survive

This is worth being precise about, because the two audiences differ.

In the Excel GUI, precision beyond ~15 significant digits is still lost.
Excel stores numbers as IEEE-754 doubles and displays at most 15 significant
digits under the General format, so 1234567890.12345678 shows as
1234567890.12346, and a workbook opened and re-saved in Excel comes back
rounded. No library-side change can avoid that; it is a property of the file
format's consumer.

Read back programmatically, nothing is lost. The exact digits are what land
in the XML, so a reader that asks for the raw value gets the original decimal
back:

raw, err := f.GetCellValue("Sheet1", "A1", excelize.Options{RawCellValue: true})
amount, err := decimal.NewFromString(raw) // == the value written

GetRows takes the same option. Note the qualifier: without RawCellValue the
General number format is applied on read and the value is rounded to what Excel
would display, so the raw option is required on the read side of a round trip.

That second case is the one that matters most in practice, and it is the
motivating workload here: pipelines that write a workbook, put it in S3, read it
back elsewhere, and load it into a database column typed DECIMAL. Nothing in
that path opens Excel, so the bytes are never re-rounded, and with this change
the amount that reaches the database is the amount that was written. Before it,
the same pipeline had to choose between a lossy float64 and a text column it
then had to reparse.

How Has This Been Tested

Environment: go1.25.11, darwin/arm64.

  • TestSetCellValueDecimal added in cell_test.go, covering the value form,
    the pointer form, a nil pointer, a *big.Rat non-regression to confirm a
    non-numeric String() still stores as a string, the StreamWriter path, and
    the inline-string overwrite fix.
  • Full suite green: go test . passes.
  • gofmt and go vet clean.

Verified against the real packages, not only against a mock implementing the
interface. In a separate module depending on shopspring/decimal v1.4.0 and
govalues/decimal v0.1.36 with a replace onto this branch, both produce
<c r="A1"><v>...</v></c> with no t attribute, identical to the float64
path, and round-trip tests write a workbook, save it, reopen it with OpenFile
and read it back:

source amount float64 shopspring govalues
19.99 ok ok ok
1234567890.12345678 1234567890.1234567 ok ok
9007199254740993.01 9007199254740994 ok ok

The same module pins the read-side behavior described above: raw reads return
the exact digits after a full save and reopen, including through the stream
writer, while a formatted read returns the rounded General-format value.

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@ValeryVerkhoturov

Copy link
Copy Markdown
Author

Also you may check my tests of writing and reading Excel file with decimal lib dependencies https://github.com/ValeryVerkhoturov/excelize-decimal-test

@xuri xuri added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants