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

json-handling.mddocs/guides/

JSON Handling Guide

Complete guide to JSON serialization and deserialization with Decimal values.

Quick Start

Basic JSON Marshaling

import (
    "encoding/json"
    "github.com/shopspring/decimal"
)

type Product struct {
    Name  string          `json:"name"`
    Price decimal.Decimal `json:"price"`
}

// Marshal (default: quoted string)
p := Product{
    Name:  "Widget",
    Price: decimal.NewFromString("19.99"),
}
data, _ := json.Marshal(p)
// {"name":"Widget","price":"19.99"}

// Unmarshal (accepts both quoted and unquoted)
var p2 Product
json.Unmarshal([]byte(`{"name":"Widget","price":"19.99"}`), &p2)
json.Unmarshal([]byte(`{"name":"Widget","price":19.99}`), &p2)  // Also works

Default Behavior

Quoted Strings (Default)

By default, Decimal marshals to JSON as a quoted string to preserve precision.

type Price struct {
    Amount decimal.Decimal `json:"amount"`
}

p := Price{Amount: decimal.NewFromString("19.99")}
data, _ := json.Marshal(p)

// Output: {"amount":"19.99"}
//         ^^^^^^^^^^^^^^^^^ Quoted string

Why quoted strings?

  • Preserves exact precision
  • Avoids JavaScript/JSON parser float issues
  • Safe for all decimal values
  • Recommended for financial applications

Accepting Both Formats on Unmarshal

var p Price

// Accepts quoted string
json.Unmarshal([]byte(`{"amount":"19.99"}`), &p)  // ✓

// Also accepts unquoted number
json.Unmarshal([]byte(`{"amount":19.99}`), &p)    // ✓

// Both produce same result
fmt.Println(p.Amount.String())  // "19.99"

Unquoted Numbers (Optional)

Enabling Number Format

// Enable globally (affects all Decimal values)
decimal.MarshalJSONWithoutQuotes = true

p := Price{Amount: decimal.NewFromString("19.99")}
data, _ := json.Marshal(p)

// Output: {"amount":19.99}
//         ^^^^^^^^^^^^^^ Unquoted number

⚠ WARNING: Precision Loss Risk

  • JavaScript uses IEEE 754 double-precision floats
  • Can lose precision for large or precise decimals
  • Example: 99999999999999999 becomes 100000000000000000 in JS
  • Recommended: Keep MarshalJSONWithoutQuotes = false

When to Use Unquoted

Use unquoted numbers ONLY if:

  • Interfacing with systems that require numbers
  • Values are small and don't need high precision
  • You control both client and server
  • You understand and accept the precision risk

Example use case:

// API for simple percentages (low precision need)
type Config struct {
    Rate decimal.Decimal `json:"rate"`  // 0.08
}

decimal.MarshalJSONWithoutQuotes = true

Complete Examples

REST API Response

type ProductResponse struct {
    ID          int             `json:"id"`
    Name        string          `json:"name"`
    Price       decimal.Decimal `json:"price"`
    Discount    decimal.Decimal `json:"discount,omitempty"`
    TaxRate     decimal.Decimal `json:"tax_rate"`
}

func productHandler(w http.ResponseWriter, r *http.Request) {
    product := ProductResponse{
        ID:       1,
        Name:     "Widget",
        Price:    decimal.NewFromString("99.99"),
        Discount: decimal.NewFromString("10.00"),
        TaxRate:  decimal.NewFromFloat(0.08),
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(product)
}

// Response:
// {
//   "id": 1,
//   "name": "Widget",
//   "price": "99.99",
//   "discount": "10.00",
//   "tax_rate": "0.08"
// }

REST API Request

type CreateProductRequest struct {
    Name     string          `json:"name"`
    Price    decimal.Decimal `json:"price"`
    Discount decimal.Decimal `json:"discount"`
}

func createProductHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateProductRequest

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Validate
    if req.Price.LessThanOrEqual(decimal.Zero) {
        http.Error(w, "Price must be positive", http.StatusBadRequest)
        return
    }

    // Use req.Price and req.Discount
    // ...
}

// Accepts:
// {"name":"Widget","price":"99.99","discount":"10.00"}
// OR
// {"name":"Widget","price":99.99,"discount":10.00}

Nested Structures

type Order struct {
    ID        int             `json:"id"`
    Items     []OrderItem     `json:"items"`
    Subtotal  decimal.Decimal `json:"subtotal"`
    Tax       decimal.Decimal `json:"tax"`
    Total     decimal.Decimal `json:"total"`
}

type OrderItem struct {
    ProductID int             `json:"product_id"`
    Name      string          `json:"name"`
    Quantity  int             `json:"quantity"`
    Price     decimal.Decimal `json:"price"`
    ItemTotal decimal.Decimal `json:"item_total"`
}

func calculateOrder(items []OrderItem) Order {
    subtotal := decimal.Zero
    for i := range items {
        items[i].ItemTotal = items[i].Price.Mul(
            decimal.NewFromInt(int64(items[i].Quantity)),
        )
        subtotal = subtotal.Add(items[i].ItemTotal)
    }

    taxRate := decimal.NewFromFloat(0.08)
    tax := subtotal.Mul(taxRate).Round(2)
    total := subtotal.Add(tax)

    return Order{
        Items:    items,
        Subtotal: subtotal,
        Tax:      tax,
        Total:    total,
    }
}

NULL Values with NullDecimal

Basic Usage

type Product struct {
    Name     string               `json:"name"`
    Price    decimal.Decimal      `json:"price"`
    Discount decimal.NullDecimal  `json:"discount"`  // May be null
}

// With discount
p1 := Product{
    Name:     "Widget",
    Price:    decimal.NewFromString("99.99"),
    Discount: decimal.NewNullDecimal(decimal.NewFromString("10.00")),
}
json.Marshal(p1)
// {"name":"Widget","price":"99.99","discount":"10.00"}

// Without discount (null)
p2 := Product{
    Name:     "Widget",
    Price:    decimal.NewFromString("99.99"),
    Discount: decimal.NullDecimal{Valid: false},
}
json.Marshal(p2)
// {"name":"Widget","price":"99.99","discount":null}

Unmarshaling NULL

var p Product

// With value
json.Unmarshal([]byte(`{"name":"Widget","price":"99.99","discount":"10.00"}`), &p)
// p.Discount.Valid = true, p.Discount.Decimal = 10.00

// With null
json.Unmarshal([]byte(`{"name":"Widget","price":"99.99","discount":null}`), &p)
// p.Discount.Valid = false

// With missing field (treated as null)
json.Unmarshal([]byte(`{"name":"Widget","price":"99.99"}`), &p)
// p.Discount.Valid = false

Optional Fields

type UpdateProductRequest struct {
    Name     *string              `json:"name,omitempty"`
    Price    *decimal.Decimal     `json:"price,omitempty"`
    Discount *decimal.NullDecimal `json:"discount,omitempty"`
}

func updateProduct(productID int, updates UpdateProductRequest) error {
    if updates.Name != nil {
        // Update name
    }
    if updates.Price != nil {
        // Update price
    }
    if updates.Discount != nil {
        if updates.Discount.Valid {
            // Set discount
        } else {
            // Clear discount (set to NULL)
        }
    }
    return nil
}

Custom JSON Tags

omitempty

type Product struct {
    Price    decimal.Decimal     `json:"price"`
    Discount decimal.Decimal     `json:"discount,omitempty"`  // Omit if zero
}

p := Product{Price: decimal.NewFromString("99.99")}
json.Marshal(p)
// {"price":"99.99"}  (discount omitted because it's zero)

Note: omitempty omits zero values, not NULL values. For true NULL, use NullDecimal.

Custom Field Names

type Product struct {
    UnitPrice decimal.Decimal `json:"unit_price"`
    ListPrice decimal.Decimal `json:"list_price"`
    SalePrice decimal.Decimal `json:"sale_price,omitempty"`
}

Ignoring Fields

type Product struct {
    Price         decimal.Decimal `json:"price"`
    InternalCost  decimal.Decimal `json:"-"`  // Never marshaled
}

Error Handling

Unmarshal Errors

var p Product

data := []byte(`{"name":"Widget","price":"invalid"}`)
err := json.Unmarshal(data, &p)

if err != nil {
    // Handle invalid decimal in JSON
    fmt.Println("Invalid JSON:", err)
}

Validation After Unmarshal

type CreateProductRequest struct {
    Price decimal.Decimal `json:"price"`
}

func handleCreate(data []byte) error {
    var req CreateProductRequest

    if err := json.Unmarshal(data, &req); err != nil {
        return fmt.Errorf("invalid JSON: %w", err)
    }

    // Validate
    if req.Price.LessThanOrEqual(decimal.Zero) {
        return errors.New("price must be positive")
    }
    if req.Price.GreaterThan(decimal.NewFromInt(1000000)) {
        return errors.New("price exceeds maximum")
    }

    return nil
}

Common Patterns

API Response with Calculations

type PriceBreakdown struct {
    Subtotal decimal.Decimal `json:"subtotal"`
    Tax      decimal.Decimal `json:"tax"`
    Shipping decimal.Decimal `json:"shipping"`
    Discount decimal.Decimal `json:"discount"`
    Total    decimal.Decimal `json:"total"`
}

func calculateBreakdown(subtotal decimal.Decimal) PriceBreakdown {
    taxRate := decimal.NewFromFloat(0.08)
    tax := subtotal.Mul(taxRate).Round(2)

    shipping := decimal.NewFromString("5.99")

    discountRate := decimal.NewFromFloat(0.10)
    discount := subtotal.Mul(discountRate).Round(2)

    total := subtotal.Add(tax).Add(shipping).Sub(discount)

    return PriceBreakdown{
        Subtotal: subtotal,
        Tax:      tax,
        Shipping: shipping,
        Discount: discount,
        Total:    total,
    }
}

// Marshal for API response
breakdown := calculateBreakdown(decimal.NewFromString("100.00"))
json.Marshal(breakdown)

Configuration Files

type AppConfig struct {
    TaxRates map[string]decimal.Decimal `json:"tax_rates"`
    Limits   struct {
        MinOrder decimal.Decimal `json:"min_order"`
        MaxOrder decimal.Decimal `json:"max_order"`
    } `json:"limits"`
}

// Load from JSON file
func loadConfig(filename string) (*AppConfig, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }

    var config AppConfig
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, err
    }

    return &config, nil
}

// config.json:
// {
//   "tax_rates": {
//     "CA": "0.0825",
//     "NY": "0.08875"
//   },
//   "limits": {
//     "min_order": "10.00",
//     "max_order": "10000.00"
//   }
// }

GraphQL Responses

type GraphQLResponse struct {
    Data struct {
        Product struct {
            ID    int             `json:"id"`
            Price decimal.Decimal `json:"price"`
        } `json:"product"`
    } `json:"data"`
}

func queryProduct(id int) (*GraphQLResponse, error) {
    query := `{"query": "{ product(id: %d) { id price } }"}`
    // ... make GraphQL request
    // ... unmarshal response

    var response GraphQLResponse
    json.Unmarshal(data, &response)
    return &response, nil
}

Performance Considerations

Large Arrays

// Streaming for large datasets
type Product struct {
    Price decimal.Decimal `json:"price"`
}

// Stream encoding
func encodeProducts(w io.Writer, products []Product) error {
    encoder := json.NewEncoder(w)
    for _, p := range products {
        if err := encoder.Encode(p); err != nil {
            return err
        }
    }
    return nil
}

// Stream decoding
func decodeProducts(r io.Reader) ([]Product, error) {
    var products []Product
    decoder := json.NewDecoder(r)

    for decoder.More() {
        var p Product
        if err := decoder.Decode(&p); err != nil {
            return nil, err
        }
        products = append(products, p)
    }

    return products, nil
}

Reusing Encoder/Decoder

// Reuse encoder for multiple writes
encoder := json.NewEncoder(w)

for _, product := range products {
    if err := encoder.Encode(product); err != nil {
        return err
    }
}

Testing JSON Serialization

Round-Trip Tests

func TestProductJSON(t *testing.T) {
    original := Product{
        Name:  "Widget",
        Price: decimal.NewFromString("19.99"),
    }

    // Marshal
    data, err := json.Marshal(original)
    if err != nil {
        t.Fatalf("marshal error: %v", err)
    }

    // Unmarshal
    var decoded Product
    if err := json.Unmarshal(data, &decoded); err != nil {
        t.Fatalf("unmarshal error: %v", err)
    }

    // Compare
    if !decoded.Price.Equal(original.Price) {
        t.Errorf("price mismatch: got %s, want %s",
            decoded.Price, original.Price)
    }
}

Testing with Fixtures

func TestUnmarshalProduct(t *testing.T) {
    tests := []struct {
        name    string
        json    string
        want    decimal.Decimal
        wantErr bool
    }{
        {
            name: "quoted string",
            json: `{"price":"19.99"}`,
            want: decimal.NewFromString("19.99"),
        },
        {
            name: "unquoted number",
            json: `{"price":19.99}`,
            want: decimal.NewFromFloat(19.99),
        },
        {
            name:    "invalid",
            json:    `{"price":"invalid"}`,
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            var p Product
            err := json.Unmarshal([]byte(tt.json), &p)

            if tt.wantErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }

            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }

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

Comparison with Other Formats

JSON (default - quoted)

{"price": "19.99"}

Pros: Precision preserved, safe Cons: Larger payload, needs parsing

JSON (unquoted)

{"price": 19.99}

Pros: Smaller payload, native JSON Cons: Precision loss risk, JavaScript issues

MessagePack / Protocol Buffers

For binary protocols, use MarshalBinary / UnmarshalBinary:

data, _ := price.MarshalBinary()  // []byte
var decoded decimal.Decimal
decoded.UnmarshalBinary(data)

Complete Serialization API → NullDecimal API →

Install with Tessl CLI

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

docs

index.md

README.md

tile.json