Currency representation in modern systems

Node.js · Go · PostgreSQL · MongoDB · JSON REST · gRPC · Crypto

Node.jsGolangPostgreSQL MongoDBREST / JSONgRPC / ProtobufCrypto

Contents

  1. Why currency is hard — the math
  2. Representation options
  3. Performance penalties & trade-offs
  4. The JS JSON parsing problem
  5. Per-layer recommendations
  6. Decision matrix

1 — Why currency is hard

Binary floating-point cannot exactly represent most decimal fractions. This is the root of every currency bug.

IEEE 754 double precision

Format64-bit: 1 sign + 11 exponent + 52 mantissa bits
Max safe integer2⁵³ − 1 = 9,007,199,254,740,991~9 quadrillion
0.1 + 0.2= 0.30000000000000004binary can't represent 1/10
9007199254740992 + 1= 9007199254740992silently wrong

Crypto makes it worse

Bitcoin (satoshis)21M BTC × 10⁸ = 2,100,000,000,000,00015 digits — near JS limit
Ethereum (wei)1 ETH = 1,000,000,000,000,000,00019 digits — exceeds JS safe int
INT64 max9,223,372,036,854,775,807ETH wei overflows at ~9.2 ETH

Integer scaling

Store as integer in the smallest unit; divide only for display.

$12.34   → 1234          // scale: 10²
$12.345  → 12345         // scale: 10³
1 BTC    → 100000000     // scale: 10⁸ (satoshis)
1 ETH    → 10^18 wei     // ⚠ exceeds INT64 → use NUMERIC or string
Warning

For ETH-scale crypto you need 128-bit integers, NUMERIC, or decimal strings. INT64 overflows at ~9.2 ETH stored in wei.


2 — Representation options

StrategyDB storageWire (JSON)PrecisionCrypto-safe
Integer (minor units)INT64stringExactIf scaled
Decimal stringNUMERIC(p,s)stringExactYes
FloatFLOAT8numberLossyNo
BigDecimal (scaled int)INT64 + scalestringExactYes
Never do this

Never use FLOAT8, double, or native JS number for currency storage or arithmetic. Errors are silent and accumulate at scale.


3 — Performance penalties & trade-offs

Postgres stores NUMERIC in base-10000 chunks. NUMERIC(19,4) is ~12 bytes vs 8 for INT8, and arithmetic is 10–50× slower for bulk aggregations.

float64 add
1 CPU instruction
int64 add
1 CPU instruction
NUMERIC add
O(n digits) loop
NUMERIC multiply
O(n²) digit multiply

INT64 fixed scale

Pros

  • Single CPU instruction math
  • 8 bytes fixed — cache friendly
  • Fast DB index, btree optimal
  • gRPC native, no serialization overhead

Cons

  • Scale must be fixed per currency upfront
  • Overflow risk at ETH wei scale
  • Cross-currency math needs scale normalization
  • App must always know scale to interpret value

NUMERIC / Decimal128 / shopspring/decimal

Pros

  • Arbitrary precision — no overflow
  • Scale can vary per row
  • Self-describing — no external scale metadata
  • Crypto-safe at any scale

Cons

  • 10–50× slower DB arithmetic
  • Larger storage at 100M+ rows
  • shopspring/decimal allocates heap — GC pressure in Go
  • Always string on wire anyway

String (decimal string in DB)

Pros

  • Universally safe across all layers
  • Zero precision loss
  • Works for any scale including 18-decimal crypto

Cons

  • Cannot aggregate in DB without casting
  • Sorting is lexicographic without padding
  • Index unusable for range queries

When does it actually matter?

ScaleImpact
< 1M rows, OLTPNegligible
10M+ rows, reportingAggregations noticeably slower
High-frequency / streamingINT64 only
Crypto (ETH wei) at volumeINT64 overflows — NUMERIC or 128-bit required

4 — The JS JSON parsing problem

Corruption happens before your code runs — inside JSON.parse itself.

JSON.parse('{"amount": 9007199254740993}')
// → { amount: 9007199254740992 }  ← already corrupted, silent
Critical

Letting express.json() run globally silently corrupts large numbers before your route handler sees anything.

Option 1 — demand strings in the contract

Push back on the third party to send "amount": "9007199254740993". Best option if you have influence.

Option 2 — pre-process raw buffer

const raw = await getRawBody(req);
const safe = raw.toString().replace(
  /(?<!["\w])(\d{16,})(?!["\w])/g,
  '"$1"'   // wrap large numbers as strings before parse
);
const body = JSON.parse(safe);
// ⚠ regex on JSON is fragile — use with good test coverage

Option 3 — safe JSON parser library

// lossless-json — surgical per-field control
import { parse, LosslessNumber } from 'lossless-json';
const result = parse('{"amount": 9007199254740993, "count": 3}');
result.amount.toString()  // "9007199254740993" ← exact
Number(result.count)      // 3 ← safe, small number

// json-bigint — converts to native BigInt
import JSONBig from 'json-bigint';
JSONBig({ useNativeBigInt: true }).parse('{"amount": 9007199254740993}');
// → { amount: 9007199254740993n }

Option 4 — raw body middleware

app.use('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const body = JSONBig.parse(req.body.toString());
});

Go has no problem — if used correctly

// Typed struct — safe, no float involved
type Payment struct {
    Amount int64 `json:"amount"`
}

// Decoding into interface{} uses float64 — same bug as JS
// Fix: use json.Number
d := json.NewDecoder(r.Body)
d.UseNumber()
ScenarioSolution
You control the third partyDemand strings in the contract
Numbers guaranteed < 15 digitsNative JSON.parse is fine
No control, numbers can be largelossless-json or json-bigint
High throughput, perf sensitiveRaw buffer regex (benchmark first)
Fiat only, minor units (cents)Likely safe — need $922T to overflow

5 — Per-layer recommendations

Use a layered strategy — not one-size-fits-all.

PostgreSQL

  • Fiat → NUMERIC(19, 4)
  • Crypto → NUMERIC(38, 18)
  • Never FLOAT8

MongoDB

  • Use Decimal128 type
  • 34 significant digits
  • Never native JS Number

Go

  • Fiat hot path → int64 with scale
  • Crypto → shopspring/decimal
  • Always json.Number on decode

Node.js

  • decimal.js or big.js
  • Never native number for math
  • lossless-json for ingestion

JSON REST

  • Always serialize as string
  • "amount": "12.34"
  • Document scale in API contract

gRPC / Protobuf

  • int64 wire-safe for fiat
  • Google Money pattern for fiat
  • string for ETH wei scale

gRPC money message pattern

// Fiat — Google's approach
message Money {
  string currency_code = 1;  // "USD", "BTC"
  int64  units         = 2;  // whole units
  int32  nanos         = 3;  // 10⁻⁹ fractional part
}

// Crypto at ETH wei scale — nanos insufficient
message CryptoMoney {
  string currency = 1;
  string value    = 2;  // "1000000000000000000" (1 ETH in wei)
}

6 — Decision matrix

Need sub-satoshi crypto precision?Use string / NUMERIC everywhere, skip INT64
Performance-critical OLTP path?INT64 minor units, define scale per currency upfront
Multi-currency including crypto?Store (amount, currency, scale) as a triplet — never assume scale from currency
Third party sending large JSON numbers?lossless-json or raw body middleware; never let express.json() run globally on those routes
ETH / wei scale amounts?NUMERIC(38,18) in Postgres, Decimal128 in Mongo, string on wire — never INT64
Bulk aggregations on large tables?Pre-aggregate into INT64 shadow columns, or accept NUMERIC perf cost
High-RPS Go service?Avoid shopspring/decimal on hot paths (GC pressure); prefer INT64
Key principle

Float is only ever acceptable for approximate display — never for storage or arithmetic. Store the scale explicitly alongside the amount as part of your data contract.