Decimal number of programming

Try this in your interpreter such as javascript or python:

Input: .1 + .2
Output: 0.30000000000000004

Input: .1 + .2 === .3
Output: false

Rationale: we (humans) use decimal representation for numbers (the number we write in the console, the number printed out), while internally the interpreters use binary representation which causes rounding issues.

To fill this gap, IEEE 754-2008 (The IEEE Standard for Floating-Point Arithmetic), in Augst 2008 added support for 32-bit, 64-bit and 128-bit decimal format. Programming languages also design their own Decimal support. The comparison is provided here.

Decimal representation is critical in applications which require exact calculation such as monetary-related applications.

Besides basic operations (+-*/), a decimal implementation should support advanced mathematical functions: trigonometric functions (normal, inverse/arc, hyperbolic combinations), natural exponentiation, logarithm, power, ...

Rational numbers (fractions) support is a convenient feature, related to decimal support.

Precision setting: an implementation can choose fixed-precision, dynamic precision, or, arbitrary precision. One should note that for an exact multiplication of two decimals, the precision of the result may be up to the sum of the operands. Through multiple multiplication, the precision will increase, thus, increasing the size of the result, when arbitrary precision is used.

The rest of the post provides details about implementation and support in Javascript, Python, and how they handle data from/to external systems, such as JSON, PostgresQL, DynamoDB.

Number type in external systems

JSON

The specs writes:

JSON is agnostic about the semantics of numbers. In any programming language, there can be a variety of number types of various capacities and complements, fixed or floating, binary or decimal. That can make interchange between different programming languages difficult. JSON instead offers only the representation of numbers that humans use: a sequence of digits. All programming languages know how to make sense of digit sequences even if they disagree on internal representations. That is enough to allow interchange.

JSON does not impose any semantics on the parsed value including number, and leaves the definition up to implementations.

PostgreSQL

Numerics types in PostgreSQL mentions all available numeric types used in PostgreSQL

  • Integer: smallint, integer, bigint, smallserial, serial, bigserial.
  • Inexact float: real, double precision.
  • Exact float: decimal, numeric.

DynamoDB

DynamoDB provides only number type and does not differentiate between integer or float type.

Javascript

The Decimal proposal

Decimal proposal is in stage 1, provided here.

The most stable implementation is decimal package.

Historically, decimal support has been discussed for a very long time, from ES3.1. The latest effort is the decimal proposal mentioned above.

Other related documents:

  • Relationship of Decimal to other TC39 proposals: comparison.md
  • BigDecimal literal syntax like 123.456m is less likely happen: issue link.
  • JSBI: pure Javascript BigInts to support legacy engine. Probably, a similar implementation will be available for Decimal.
  • Javascript does not yet support operation overloading: proposal, status: withdrawn.

JSON in Javascript

Note that JSON type does not support undefined, it only supports null value.

JSON.stringify() // undefined
JSON.stringify(null) // 'null'
JSON.stringify({bar: undefined, foo: null}) // '{"foo":null}'

JSON.parse() // Uncaught SyntaxError: "undefined" is not valid JSON
JSON.parse(null) // null
JSON.parse('{"bar":undefined}') // Uncaught SyntaxError: Unexpected token 'u', "{"bar":undefined}" is not valid JSON
JSON.parse('{"bar":null}') // {bar: null}

For numbers, because Javascript shares the same literal type for int and float, it parses the number as declared literally.

JSON.parse('1.1') === 1.1 // true

Be careful with exact precision-required application development, you might need a custom decimal class to handle float in the frontend, encode it as string, serialize it to JSON format, and parse it back to the decimal class in the backend. Using literal number type will cause inexactness.

PostgreSQL driver for nodejs

The pg package is quite flexible when handling numeric type.

For reading: if the column type in db is exact type (numeric, decimal), the corresponding type parsed in nodejs is string. If the column type in db is inexact type (float, double precision), the corresponding type parsed in nodejs is number.

For writing: When inserting from nodejs to the db, one can place literal or string type regardless of the column type in the db being exact or inexact types.

You can define a custom parser using the pg-types package, the example described here.

List of type id and its parser are defined here.

DynamoDB driver for nodejs

DynamoDB driver for nodejs, from the @aws-sdk/client-dynamodb package, read/write data using the raw format in DynamoDB. In other words, each value is an object of one and only one key whose value is a string or set of string, or boolean (note: no number value allowed).

The single key is called data type descriptor: docs, sample data, value. List of data type descriptors:

  • S – String. Value: string.
  • N – Number. Value: string.
  • B – Binary. Value: base-64 encoded string.
  • BOOL – Boolean. Value: boolean.
  • NULL – Null. Value: true.
  • M – Map. Value: map.
  • L – List. Value: array.
  • SS – String Set. Value: array.
  • NS – Number Set. Value: array.
  • BS – Binary Set. Value: array.

Python decimal class

Python has a dedicated Decimal class for decimal number manipulation.

Database library

Python stores numbers (float or int) using binary format, while some external libraries such as boto3's DynamoDB use Decimal for the exact presentation of decimal representation, which causes several long alive issues when using the library:

Inserting literal float to DynamoDB raises TypeError

Because DynamoDB driver does not allow literal float (in binary format), if you insert literal directly, it raises TypeError exception:

dynamodb.Table('my-table').put_item(Item={
	'id': '1',
	'my-key': 1.1
})
    raise TypeError(
TypeError: Float types are not supported. Use Decimal types instead.

Inserting Decimal wrapping a literal float in binary format raises Inexact error

If you try Decimal but wrap around a literal float in binary format, it raises decimal.Inexact exception:

dynamodb.Table('my-table').put_item(Item={
	'id': '1',
	'my-key': Decimal(1.1)
})

Inserting Decimal wrapping a literal string in decimal format is accepted

    number = str(DYNAMODB_CONTEXT.create_decimal(value))
decimal.Inexact: [<class 'decimal.Inexact'>, <class 'decimal.Rounded'>]

The correct way is to convert the float number to Decimal with decimal presentation input, i.e. string input:

dynamodb.Table('my-table').put_item(Item={
	'id': '1',
	'my-key': Decimal('1.1')
})

Inserting literal int or Decimal wrapping an int is accepted

dynamodb.Table('my-table').put_item(Item={
	'id': '1',
	'my-key': Decimal(1),
	'my-key1': 1,
})

Getting items always returns number format (DynamoDB does not differentiate float or integer types) as Decimal instances.

print(dynamodb.Table('my-table').scan()['Items']) # [{'id': '1', 'my-key1': Decimal('1'), 'my-key': Decimal('1')}]

General solution to handle nested float

One promising solution which can handle nested float numbers is to serialize and deserialize with Decimal as float parser:

from decimal import Decimal
each_item = json.loads(json.dumps(json_variable), parse_float=Decimal)

Because json.dumps() outputs decimal presentation, this conversion does not lose precision.

JSON support

JSON native json parser json.loads() parses number type as follows:

  • No dot or e: 123 => int
  • Have dot: 0. => float
  • Have e: 1e2 => float

Outside the specs of JSON, Python supports NaN, Infinity and -Infinity, and output float type.

json.dumps() for serialization

When serializing data to JSON format string, json.dumps does not allow Decimal class instances:

import json

json.dumps(Decimal(1))
  File "/home/transang/python/3.10.8/lib/python3.10/json/encoder.py", line 179, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type Decimal is not JSON serializable

json.dumps()'s default parameter can be used to specify serializer for unknown type:

function encoder(obj):
	if isinstance(obj, Decimal):
		return str(obj)
	raise TypeError(f"Object of type '{obj.__class__.__name__}' is not JSON serializable")
print(json.dumps(Decimal(1), default=encoder))
print(json.dumps(datetime.now()), default=encoder)) # raise exception

Note, if the default parameter function returns None, the output is serialized as null instead of raising an error. The function itself must raise exceptions if needed.

json.loads() for deserialization

When deserializing a string to map, json.loads() accept parse_float parameter to specify the float parser:

import json
from decimal import Decimal

json.loads('1', parse_float=Decimal) # output 1
json.loads('1.1', parse_float=Decimal) # output Decimal('1.1')
Decimal(1.1) # output Decimal('1.100000000000000088817841970012523233890533447265625')

External library

Another option is to use a third-party library like simplejson. Readers can follow the package document site for usage.


Additional materials