TATECHATLAS
◎ English
Programming

Serialize datetime and Decimal in JSON with an explicit Python contract

Use ISO timestamp strings and decimal strings, then reconstruct the fields explicitly. A complete standard-library example shows timezone checks, precision and predictable failures.

On this page

Use a custom json.dumps default function to encode aware datetimes as ISO strings and finite Decimal values as decimal strings. Restore the named fields explicitly after json.loads. This preserves the sample timestamp offset and Decimal("12.30") representation without treating arbitrary strings as typed objects. The example validates the two fields, not a complete business schema.

Define the interchange contract

JSON has no dedicated datetime or Decimal type. Define how each extra Python type will cross the boundary before choosing an encoder. This guide uses a specific contract: timestamp is an ISO string containing a usable UTC offset, and amount is a finite decimal string. A consumer reconstructs the two fields explicitly. Neither a date-looking string nor a numeric-looking string is automatically a typed Python object.

The example uses the Python standard library and targets Python 3.11 or newer. Its constructed input is a UTC datetime for September 29, 2026, at 12:00 and Decimal("12.30"). The purpose is to preserve this representation, not to serialize arbitrary objects or define a complete payment schema.

Serialize timezone-aware dates

A datetime is aware only when tzinfo is not None and utcoffset() also returns a value. The encoder checks both conditions before calling isoformat(). Rejecting naive values avoids silently assigning the machine's local timezone or confusing a local wall-clock reading with a specific instant.

The sample UTC timestamp becomes 2026-09-29T12:00:00+00:00. An offset string identifies the offset at that moment, but does not preserve an IANA zone name or its future daylight-saving rules. Normalize to UTC only when that matches your contract; this example does not reinterpret a naive timestamp as UTC.

Preserve Decimal as a string

Decimal("12.30") retains its decimal digits and trailing zero. Converting it to a binary float before serialization changes the representation and may introduce rounding. The encoder instead returns str(obj), making the amount a JSON string. The receiver can reconstruct it with Decimal without first passing through a float.

The example rejects non-finite Decimal values. A decimal string still does not specify currency, a maximum amount or allowed fractional digits. Those are separate business constraints. JSON also supports native numeric values; choosing a decimal string here is an explicit interoperability contract, not a claim that JSON cannot contain numbers.

Build an explicit encoder

The complete example includes imports, encoding, reconstruction and sample input. json.dumps calls default only for objects that its normal encoder cannot handle. The custom function handles datetime and Decimal; other unsupported custom objects raise TypeError. Ordinary strings, integers, lists and dictionaries remain the built-in encoder's responsibility.

allow_nan=False rejects native float NaN and Infinity during serialization. The finite Decimal check is separate because Decimal is handled by the custom function. No third-party dependency is needed. The example below is illustrative; its expected output is explained rather than presented as an executed test.

import json
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation

def contract_encoder(obj):
    if isinstance(obj, datetime):
        if obj.tzinfo is None or obj.utcoffset() is None:
            raise TypeError("Timezone-aware datetime required")
        return obj.isoformat()
    if isinstance(obj, Decimal):
        if not obj.is_finite():
            raise TypeError("Finite Decimal required")
        return str(obj)
    raise TypeError("Unsupported custom value")

def reconstruct_data(data):
    if not isinstance(data, dict):
        raise ValueError("Object required")
    if not all(isinstance(data.get(key), str) for key in ("timestamp", "amount")):
        raise ValueError("timestamp and amount must be strings")
    timestamp = datetime.fromisoformat(data["timestamp"])
    if timestamp.tzinfo is None or timestamp.utcoffset() is None:
        raise ValueError("Timezone-aware timestamp required")
    amount = Decimal(data["amount"])
    if not amount.is_finite():
        raise ValueError("Finite amount required")
    return {"timestamp": timestamp, "amount": amount}

data = {"timestamp": datetime(2026, 9, 29, 12, 0, tzinfo=timezone.utc),
        "amount": Decimal("12.30")}
encoded = json.dumps(data, default=contract_encoder, allow_nan=False)
print(encoded)
restored = reconstruct_data(json.loads(encoded))
print(restored["timestamp"].isoformat(), str(restored["amount"]))

Read the fields back

json.loads restores dictionaries, lists and basic values. It leaves timestamp and amount as strings. reconstruct_data checks that the top-level value is an object and both required fields are strings, then calls datetime.fromisoformat and Decimal explicitly. It also requires a usable timestamp offset and a finite amount.

Do not convert every string that resembles a date or number. Field meaning comes from the agreed schema. These checks cover two fields; they do not reject every extra property or enforce application size, currency, authorization and range policies. Applications should add the constraints their boundary actually requires.

Inspect illustrative output

For the constructed input, the first printed line is {"timestamp": "2026-09-29T12:00:00+00:00", "amount": "12.30"}. The second is 2026-09-29T12:00:00+00:00 12.30. These are illustrative expected lines derived from the code, not a report of an executed test. The amount is quoted in JSON and still ends in a zero after reconstruction.

Compare the typed reconstructed values with the intended contract. A decoded string alone does not prove that reconstruction took place. Changing the output contract to a tagged object or a numeric JSON amount would require corresponding changes in consumers; do not mix formats accidentally.

Handle invalid values predictably

Encoding a naive timestamp, a non-finite Decimal or another unsupported custom object raises the explicit TypeError in the example. Malformed JSON can raise json.JSONDecodeError. Invalid ISO timestamp text raises ValueError. Invalid decimal text normally raises decimal.InvalidOperation under the default decimal context, not ValueError.

The reconstruction checks raise ValueError for a wrong top-level type, missing or non-string fields, a timestamp without an offset and a non-finite amount. Decimal traps are configurable, so exception behavior can depend on context. Handle the relevant failures at the application boundary without promising universal traceback wording or logging sensitive payloads.

Know what JSON cannot preserve

JSON does not remember whether an array originated as a tuple or list, or whether a string originated as a datetime or Decimal. Explicit reconstruction is therefore part of this contract. Returning a string from default is sufficient here; custom JSONEncoder and JSONDecoder subclasses are optional alternatives, not mandatory requirements.

object_hook is called for decoded JSON objects, not for every primitive string. It can support an explicitly designed tagged-object format, but that would be a different contract from the two named string fields used here. Preserving representation is useful, yet it does not establish business validity, named timezone rules or safe resource consumption.

Things to check

  • Use Python 3.11 or newer for this example.
  • Check tzinfo and utcoffset() before accepting a datetime.
  • Keep the Decimal amount as a JSON string rather than converting it to float.
  • Require an object with string timestamp and amount fields during reconstruction.
  • Reject non-finite amounts and offset-free timestamps.
  • Distinguish ValueError, json.JSONDecodeError and decimal.InvalidOperation at the input boundary.

This is a constructed standard-library illustration, not an executed test. Offset strings do not preserve named timezone rules. Decimal strings preserve representation, not currency or business validity. The example validates two fields and finiteness; applications still need range, size and authorization policies.

Sources

  1. Python: JSON encoders and decoders ↗
  2. Python: datetime ISO serialization ↗
  3. Python: Decimal arithmetic ↗
Back to top ↑