CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/golang-github-com-shopspring--decimal

Arbitrary-precision fixed-point decimal numbers for Go, avoiding floating-point precision issues with support for arithmetic, rounding, serialization, and database integration.

Overview
Eval results
Files

gotchas-and-best-practices.mddocs/guides/

Gotchas and Best Practices

Common pitfalls and how to avoid them when working with shopspring/decimal.

Critical Gotchas

1. NEVER Use == or != on Decimal Values

❌ WRONG:

a := decimal.NewFromFloat(1.0)
b := decimal.NewFromFloat(1.0)

if a == b {  // DON'T DO THIS!
    // May not work as expected
}

✅ CORRECT:

a := decimal.NewFromFloat(1.0)
b := decimal.NewFromFloat(1.0)

if a.Equal(b) {  // Use .Equal()
    // Correct comparison
}

// Or use .Cmp()
if a.Cmp(b) == 0 {
    // Also correct
}

Why:

  • Decimal contains a pointer (*big.Int)
  • == compares struct fields, including pointer addresses
  • Even equal values may have different pointer addresses
  • Always use .Equal() or .Cmp()

2. Division by Zero Panics

❌ WRONG:

result := a.Div(b)  // Panics if b is zero!

✅ CORRECT:

if b.IsZero() {
    return decimal.Zero, errors.New("division by zero")
}
result := a.Div(b)

Also check:

// QuoRem also panics on zero
q, r := a.QuoRem(b, 0)  // Panics if b is zero

// Mod also panics on zero
remainder := a.Mod(b)   // Panics if b is zero

3. Float Constructors Panic on NaN/Inf

❌ WRONG:

d := decimal.NewFromFloat(userFloat)  // May panic!

✅ CORRECT:

if math.IsNaN(userFloat) || math.IsInf(userFloat, 0) {
    return decimal.Zero, errors.New("invalid float value")
}
d := decimal.NewFromFloat(userFloat)

Applies to:

  • NewFromFloat()
  • NewFromFloat32()
  • NewFromFloatWithExponent()

4. Prefer NewFromString Over NewFromFloat

⚠ CAUTION:

// May lose precision
d := decimal.NewFromFloat(0.1)
fmt.Println(d.String())  // May be "0.1000000000000000055..."

✓ RECOMMENDED:

// Exact precision
d, err := decimal.NewFromString("0.1")
fmt.Println(d.String())  // "0.1"

When to use each:

  • NewFromString - Financial values, user input, exact values
  • NewFromFloat - Converting existing float64, approximate values OK

5. Decimal is Immutable

❌ WRONG ASSUMPTION:

a := decimal.NewFromInt(10)
a.Add(decimal.NewFromInt(5))  // Returns new value, doesn't modify a
fmt.Println(a)  // Still 10!

✅ CORRECT:

a := decimal.NewFromInt(10)
a = a.Add(decimal.NewFromInt(5))  // Assign result
fmt.Println(a)  // 15

All operations return new values:

a := decimal.NewFromInt(10)
b := a.Add(decimal.NewFromInt(5))  // a is still 10, b is 15
c := a.Mul(decimal.NewFromInt(2))  // a is still 10, c is 20

6. Zero Value is Valid

✓ SAFE:

var total decimal.Decimal
// total is 0, safe to use

total.IsZero()              // true
total.Equal(decimal.Zero)   // true
total = total.Add(someValue)  // Works fine

No need to initialize:

type Order struct {
    Total decimal.Decimal  // Zero value is 0, not nil
}

var order Order
order.Total.Add(price)  // Safe, no nil panic

7. Global Variables Are Not Thread-Safe

⚠ NOT THREAD-SAFE:

// Bad: Race condition in concurrent code
func goroutine1() {
    decimal.DivisionPrecision = 4
    result := a.Div(b)  // May use wrong precision!
}

func goroutine2() {
    decimal.DivisionPrecision = 16
    result := a.Div(b)
}

✓ SAFE:

// Set once at startup
func init() {
    decimal.DivisionPrecision = 4
    decimal.MarshalJSONWithoutQuotes = false
}

// Or use DivRound for per-operation precision
result := a.DivRound(b, 4)  // Thread-safe

8. JSON Precision Loss with Unquoted Numbers

⚠ CAUTION:

decimal.MarshalJSONWithoutQuotes = true

large := decimal.NewFromString("99999999999999999")
data, _ := json.Marshal(large)
// JSON: 99999999999999999

// JavaScript receives: 100000000000000000 (precision lost!)

✓ SAFE (default):

decimal.MarshalJSONWithoutQuotes = false  // Default

large := decimal.NewFromString("99999999999999999")
data, _ := json.Marshal(large)
// JSON: "99999999999999999" (quoted, precision preserved)

Best Practices

Creating Decimals

✓ DO:

// Exact values from strings
price, err := decimal.NewFromString("19.99")

// Integer values
quantity := decimal.NewFromInt(10)

// Constants (panic is OK)
const TAX_RATE = "0.08"
taxRate := decimal.RequireFromString(TAX_RATE)

✗ AVOID:

// Floats for financial values
price := decimal.NewFromFloat(19.99)  // May be imprecise

// Ignoring errors
price, _ := decimal.NewFromString(userInput)  // Check error!

Arithmetic

✓ DO:

// Check divisor
if divisor.IsZero() {
    return handleDivisionByZero()
}
result := dividend.Div(divisor)

// Round financial results
taxAmount := subtotal.Mul(taxRate).Round(2)

// Chain operations for readability
total := subtotal.
    Mul(quantity).
    Add(shipping).
    Sub(discount).
    Round(2)

✗ AVOID:

// Unchecked division
result := a.Div(b)  // Panics if b is zero

// Forgetting to round financial values
taxAmount := subtotal.Mul(taxRate)  // Should round to 2 places

// Complex one-liners
result := a.Add(b).Mul(c).Div(d).Sub(e).Add(f)  // Hard to debug

Comparison

✓ DO:

if price.GreaterThan(decimal.Zero) {
    // Positive price
}

if discount.Equal(decimal.Zero) {
    // No discount
}

switch amount.Cmp(limit) {
case -1:
    // amount < limit
case 0:
    // amount == limit
case 1:
    // amount > limit
}

✗ AVOID:

if price == decimal.Zero {  // WRONG!
    // Don't use ==
}

if price > limit {  // WRONG!
    // Can't use >
}

Database Storage

✓ DO:

// Use NUMERIC/DECIMAL types
CREATE TABLE products (
    price NUMERIC(10, 2) NOT NULL
);

// Use NullDecimal for nullable columns
type Product struct {
    Price    decimal.Decimal
    Discount decimal.NullDecimal  // Can be NULL
}

// Check Valid field
if product.Discount.Valid {
    applyDiscount(product.Discount.Decimal)
}

✗ AVOID:

// Don't use FLOAT/REAL for financial data
CREATE TABLE products (
    price REAL  -- Loses precision!
);

// Don't ignore Valid field
amount := product.Discount.Decimal  // May be zero when actually NULL

JSON

✓ DO:

// Keep default (quoted strings)
decimal.MarshalJSONWithoutQuotes = false

// Validate after unmarshal
var req CreateProductRequest
json.Unmarshal(data, &req)
if req.Price.LessThanOrEqual(decimal.Zero) {
    return errors.New("invalid price")
}

// Use NullDecimal for optional values
type Product struct {
    Price    decimal.Decimal     `json:"price"`
    Discount decimal.NullDecimal `json:"discount"`
}

✗ AVOID:

// Enabling unquoted numbers without understanding risk
decimal.MarshalJSONWithoutQuotes = true  // Precision loss risk!

// Not validating unmarshaled values
json.Unmarshal(data, &req)
processPayment(req.Amount)  // Could be negative, zero, or invalid!

Rounding

✓ DO:

// Use appropriate rounding for use case
price := calculated.Round(2)              // Financial (half-up)
fairRounded := calculated.RoundBank(2)    // Statistical (banker's)
cash := calculated.RoundCash(5)           // Point of sale

// Round after calculations
result := a.Mul(b).Add(c).Div(d).Round(2)

// Document rounding strategy
// Always rounds using banker's rounding to 2 decimal places
amount := calculated.RoundBank(2)

✗ AVOID:

// Inconsistent rounding
amount1 := calc1.Round(2)
amount2 := calc2.RoundBank(2)  // Different strategies!

// Rounding too early
rounded := a.Round(2)
result := rounded.Mul(b).Div(c)  // Accumulated rounding errors

Error Handling

✓ DO:

// Check constructor errors
price, err := decimal.NewFromString(input)
if err != nil {
    return fmt.Errorf("invalid price: %w", err)
}

// Check before division
if quantity.IsZero() {
    return decimal.Zero, errors.New("cannot divide by zero quantity")
}
unitPrice := totalPrice.Div(quantity)

// Validate float inputs
if math.IsNaN(f) || math.IsInf(f, 0) {
    return decimal.Zero, errors.New("invalid float")
}
d := decimal.NewFromFloat(f)

// Check method errors
result, err := d.PowInt32(exp)
if err != nil {
    return decimal.Zero, fmt.Errorf("power calculation failed: %w", err)
}

✗ AVOID:

// Ignoring errors
price, _ := decimal.NewFromString(userInput)

// No division by zero check
result := a.Div(b)  // Panics!

// Unchecked float conversion
d := decimal.NewFromFloat(userFloat)  // May panic on NaN/Inf

Performance Tips

1. Avoid Repeated NewFrom Calls

✓ FASTER:

hundred := decimal.NewFromInt(100)

// Reuse hundred
percent1 := value1.Div(hundred)
percent2 := value2.Div(hundred)
percent3 := value3.Div(hundred)

✗ SLOWER:

percent1 := value1.Div(decimal.NewFromInt(100))
percent2 := value2.Div(decimal.NewFromInt(100))
percent3 := value3.Div(decimal.NewFromInt(100))

2. Use Shift for Powers of 10

✓ FASTER:

// Multiply by 100
result := value.Shift(2)

// Divide by 1000
result := value.Shift(-3)

✗ SLOWER:

result := value.Mul(decimal.NewFromInt(100))
result := value.Div(decimal.NewFromInt(1000))

3. Prefer NewFromString for Exact Values

✓ MOST ACCURATE:

price, err := decimal.NewFromString("19.99")  // Exact

✗ LESS ACCURATE:

price := decimal.NewFromFloat(19.99)  // May be imprecise
price := decimal.New(1999, -2)        // Requires calculation

Performance vs Accuracy:

  • New() - Fastest, requires understanding exponents
  • NewFromInt() - Fast, safe for integers
  • NewFromFloat() - Fast, may lose precision
  • NewFromString() - Slower, most accurate

4. Use DivRound Instead of Div + Round

✓ BETTER:

result := a.DivRound(b, 2)

✗ LESS EFFICIENT:

result := a.Div(b).Round(2)

Testing Best Practices

Test with String Representation

func TestCalculation(t *testing.T) {
    result := calculateTotal(items)

    want := decimal.NewFromString("99.99")
    if !result.Equal(want) {
        t.Errorf("got %s, want %s", result, want)
    }
}

Test Edge Cases

func TestDivision(t *testing.T) {
    tests := []struct {
        name    string
        a, b    decimal.Decimal
        want    decimal.Decimal
        wantErr bool
    }{
        {
            name: "normal division",
            a:    decimal.NewFromInt(10),
            b:    decimal.NewFromInt(2),
            want: decimal.NewFromInt(5),
        },
        {
            name:    "division by zero",
            a:       decimal.NewFromInt(10),
            b:       decimal.Zero,
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if tt.b.IsZero() {
                // Expect panic
                defer func() {
                    if r := recover(); r == nil {
                        t.Error("expected panic")
                    }
                }()
            }

            got := tt.a.Div(tt.b)

            if !tt.wantErr && !got.Equal(tt.want) {
                t.Errorf("got %s, want %s", got, tt.want)
            }
        })
    }
}

Test Precision

func TestPrecision(t *testing.T) {
    // Test that precision is maintained
    price := decimal.NewFromString("19.99")
    tax := price.Mul(decimal.NewFromString("0.08")).Round(2)

    // Verify exact value
    want := decimal.NewFromString("1.60")
    if !tax.Equal(want) {
        t.Errorf("tax calculation: got %s, want %s", tax, want)
    }

    // Also verify string representation
    if tax.StringFixed(2) != "1.60" {
        t.Errorf("tax string: got %s, want 1.60", tax.StringFixed(2))
    }
}

Common Error Messages

"invalid decimal"

_, err := decimal.NewFromString("invalid")
// Error: can't convert invalid to decimal

Solution: Validate input before parsing

Panic: "division by zero"

result := a.Div(decimal.Zero)
// Panic: division by zero

Solution: Check IsZero() before dividing

Panic: "Invalid argument to NewFromFloat"

d := decimal.NewFromFloat(math.NaN())
// Panic: Invalid argument to NewFromFloat

Solution: Check for NaN/Inf before calling

"json: cannot unmarshal number into Go struct field"

// If MarshalJSONWithoutQuotes = true
// But unmarshaling into string field

Solution: Keep consistent JSON format

Migration Guide

From float64

Before:

var price float64 = 19.99
total := price * quantity

After:

price := decimal.NewFromString("19.99")
total := price.Mul(decimal.NewFromInt(quantity))

From int64 (cents)

Before:

priceCents := int64(1999)  // $19.99
dollars := float64(priceCents) / 100

After:

priceCents := decimal.NewFromInt(1999)
dollars := priceCents.Shift(-2)  // 19.99

From big.Float

Before:

a := big.NewFloat(19.99)
b := big.NewFloat(1.08)
c := new(big.Float).Mul(a, b)

After:

a := decimal.NewFromString("19.99")
b := decimal.NewFromString("1.08")
c := a.Mul(b)

Checklist for Production Code

  • Never use == or != on Decimal values
  • Check for division by zero before Div/Mod/QuoRem
  • Validate floats before NewFromFloat (check NaN/Inf)
  • Use NewFromString for financial values
  • Set global config once at startup (not in concurrent code)
  • Keep MarshalJSONWithoutQuotes = false (default)
  • Use NullDecimal for nullable database columns
  • Round financial calculations to 2 decimal places
  • Handle NewFromString errors properly
  • Use appropriate rounding strategy (Round vs RoundBank)
  • Test edge cases (zero, negative, very large/small)
  • Document precision requirements
  • Consider thread safety for global config
  • Validate unmarshaled JSON values

Additional Resources

  • Creating Decimals Guide
  • Basic Arithmetic Guide
  • Formatting Guide
  • Database Integration Guide
  • JSON Handling Guide
  • Complete API Reference

Install with Tessl CLI

npx tessl i tessl/golang-github-com-shopspring--decimal

docs

index.md

README.md

tile.json