# When does a 64-bit identifier lose precision when serialized as JSON? **Date of Record:** 2026 November 2 **Scope:** Applies to 64-bit integer identifiers (snowflake, bigint, integer-form ULID) crossing a JSON boundary where numbers are parsed as IEEE-754 doubles. Excludes identifiers serialized as strings, and identifiers provably bounded below 2^53. ## Core Distinction **Predicate.** A JSON integer round-trips exactly through a double parser if and only if |n| < 2^53. At and above 2^53, adjacent integers collapse to the same double, so the parsed value differs from the original by the granularity of the representation. **Failure.** A service emits id=9007199254740993 (2^53+1). A JavaScript or Go-float64 consumer parses it as 9007199254740992, which is a *different identifier*. The consumer looks up or mutates the wrong row. **Asymmetry.** A rejected payload surfaces as an error. A truncated identifier does not: it is a valid number, valid JSON, a valid row, just not the row the writer intended. ## What Was Observed **Signature.** Writes landing on adjacent rows. Reads returning rows that were never written. Off-by-small deltas in identifier space, stable within a session because the precision loss is deterministic for a given value. **Trace.** Snowflake ids contain 41 bits of timestamp plus 10 bits of worker, reaching 2^63. A service decodes into `float64` by default. id 7000000000000000001 parses as 7000000000000000000. Every write to the first id silently touches the second. **Why monitoring stays green.** Both services succeed with 200. The only evidence is a mismatch on a different row, which no per-request check catches. ## Condition of Validity The identifier survives the JSON boundary only while at least one holds: 1. **String serialization.** The identifier is emitted and parsed as a string, and the schema marks the field as string unambiguously. 2. **Bit-width bound.** The identifier is provably below 2^53 in every deployment. 3. **Consumer posture.** The consumer parses numbers as arbitrary-precision integers, not doubles. Condition 2 is fragile because a schema change can quietly cross the threshold. ## Decision Boundary - String form on both sides: exact. - Integer below 2^53 on both sides: exact. - Integer crossing to a float64 consumer: loss is guaranteed above 2^53. - Integer with bigint parsing on every consumer: exact, provided none on the path parses as double. ## Operational Effect The invariant lives in the consumer's number parser, often in a different repo and often set by framework default. Remediation is string serialization at the schema. The trap is to widen the type in the producer. That does not help; the lossy step is the parse. ```python from dataclasses import dataclass class JsonIdentifierPrecisionError(ValueError): """Raised when a 64-bit identifier cannot round-trip through a JSON double parser.""" @dataclass(frozen=True) class JsonIdentifierGate: bit_width: int serialize_as_string: bool = False def verify(self) -> bool: if self.serialize_as_string: return True if self.bit_width > 53: raise JsonIdentifierPrecisionError( f"bit width {self.bit_width} exceeds 53: value cannot round-trip through IEEE-754" ) return True def dispatch_serialization(gate: JsonIdentifierGate) -> str: try: gate.verify() except JsonIdentifierPrecisionError: return "string" return "integer" ```