Numeric Entity IDs Across Rust, Java, Go, Python, SQL, and JSON
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:
| Environment | Natural representation | Important boundary |
|---|---|---|
| Rust | u64 | Must be checked before signed database binding |
| Java | long / Long | Native maximum is 2^63 - 1 |
| Go | uint64 or int64 | Database and API interoperability favor int64 range |
| Python | arbitrary-precision int | Requires explicit range validation |
| SQL | commonly signed BIGINT | Maximum is normally 2^63 - 1 |
| JavaScript | Number | Exact 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.
