Skip to main content

Numeric Entity IDs Across Rust, Java, Go, Python, SQL, and JSON

· 6 min read
TeaQL Team
Core Team

An entity ID looks like a small implementation detail until the same value has to survive a database join, a Rust service, a Java service, a Go worker, a Python script, and a browser. Each environment can represent a different set of integers, and accepting the widest type in one layer can make the whole system less portable.

TeaQL therefore separates three decisions:

  • persistence identities and foreign keys are compact numeric values;
  • their portable persisted range is the positive signed 64-bit range;
  • IDs crossing a JSON boundary are encoded as decimal strings.

The range is enormous for an identity space, but small enough to have a direct, unambiguous representation in the database and in the major server languages TeaQL supports.

Why Numeric Foreign Keys

For internal relationships, a fixed-width numeric key is usually a better storage and execution key than a business code or textual UUID:

  • an integer comparison has no collation rules;
  • an eight-byte key keeps primary-key and foreign-key indexes compact;
  • smaller indexes improve cache density and reduce I/O;
  • hash joins, grouping, sorting, and deduplication operate on fixed-width values;
  • the same business record can change its public number without changing its persistence identity.

This is not an argument against meaningful identifiers. An order can have both an internal id and a unique order_no. The first is optimized for persistence relationships; the second exists for users, partners, and business policy.

Using a business string as every foreign key couples database relationships to format, case, collation, and lifecycle decisions that belong at a different boundary.

Relationship Loading Still Needs a Strategy

A compact foreign key helps both common association-loading approaches, but it does not decide between them.

One approach joins related tables into a flat result and reconstructs the object tree. This works well for one-to-one and many-to-one relationships, and when related columns participate in filtering or ordering. It can also give a single-query snapshot.

The other approach loads a page of root entities, loads each child collection in a bounded query using the root IDs, and assembles the graph in application code. This avoids the cartesian multiplication caused by joining several one-to-many collections.

If an order has 10 lines, 5 payments, and 3 promotions, one wide join can produce 150 rows for one order. The repeated IDs are inexpensive individually, but the multiplied rows, repeated columns, sorting, and object deduplication are not. A batched loader transfers each record close to once and makes root paging predictable. It must, however, avoid one query per parent and must use a transaction when the graph requires a consistent multi-query snapshot.

The practical design is hybrid:

  • let the database filter, authorize, order, and page root entities;
  • join scalar relationships when that simplifies the query;
  • batch-load multi-valued relationships by numeric foreign key;
  • let the application enforce object identity and graph boundaries.

Why Not Use All of u64?

Rust and Go provide native unsigned 64-bit integers. Python integers can also represent every u64 value. The complete range is:

0 .. 18,446,744,073,709,551,615

That is attractive when looking at one language in isolation. A cross-language contract has a narrower useful intersection:

EnvironmentNatural representationImportant boundary
Rustu64Must be checked before signed database binding
Javalong / LongNative maximum is 2^63 - 1
Gouint64 or int64Database and API interoperability favor int64 range
Pythonarbitrary-precision intRequires explicit range validation
SQLcommonly signed BIGINTMaximum is normally 2^63 - 1
JavaScriptNumberExact integers stop at 2^53 - 1

Java can preserve unsigned bit patterns in a long and offers unsigned helper methods, but ordinary comparisons, logs, ORM mappings, and third-party APIs still treat the value as signed. BigInteger removes the range problem while adding a non-primitive type at nearly every Java boundary.

Using the upper half of u64 would therefore buy capacity that normal entity systems do not need while imposing conversions and exceptional behavior on every consumer.

The Portable Range

TeaQL's portable persisted-ID range is:

1 .. 9,223,372,036,854,775,807
2^63 - 1

Zero is reserved as an unassigned or sentinel value and must not be emitted by an ID generator. A Rust implementation may continue to use u64 internally, but construction, generation, decoding, and persistence boundaries must reject values greater than i64::MAX.

This leaves more than enough room. At one million new IDs per second, consuming the entire positive signed 64-bit space would take roughly 292,000 years. Real Snowflake layouts intentionally divide the bits among time, node, and sequence, so their operational lifetime must be calculated from that layout rather than from a simple sequential estimate.

Why JSON Uses a String Anyway

Restricting IDs to signed 64-bit solves the Java and database boundary, but not the browser boundary. A JavaScript Number cannot exactly represent every integer above 9,007,199,254,740,991 (2^53 - 1). An apparently valid JSON number can therefore arrive rounded.

The portable JSON form is a canonical decimal string:

{
"id": "9223372036854775807",
"parentId": "481923487123456789"
}

Clients validate the digits and range when converting to a native integer. They should not accept signs, decimals, exponent notation, whitespace, or leading zeroes except for the reserved literal "0" at a boundary that explicitly permits an unassigned value.

Protobuf users can choose int64 for this portable range. Its JSON mapping also uses decimal strings for 64-bit integers, which aligns naturally with the HTTP contract.

Overflow Is a Failure, Not a Rollover Policy

ID allocation and arithmetic must use checked operations. Reaching the upper bound returns an explicit range-exhausted error, stops new allocations, and raises an operational alert. Existing data can remain readable and mutable.

Wrapping to zero risks primary-key collisions. Saturating at the maximum makes every later allocation return the same value. Both turn a clear capacity failure into data corruption or an opaque uniqueness failure.

The database primary key remains the final collision defense, while the generator is responsible for atomic allocation, topology uniqueness, clock behavior, and range validation.

The Trade-off We Chose

The design deliberately gives up the upper half of u64 in exchange for one stable persistence contract across Rust, Java, Go, Python, and signed SQL BIGINT. It keeps numeric joins and indexes, avoids unsigned Java semantics, and handles JavaScript explicitly instead of pretending JSON numbers are safe.

The exact normative rules, validation points, and migration checks are in the Portable Entity ID Range reference.