Xlang Serialization Format
Cross-language Serialization Specification
Apache Fory™ xlang serialization enables automatic cross-language object serialization with support for shared references, circular references, and polymorphism. Unlike traditional serialization frameworks that require IDL definitions and schema compilation, Fory serializes objects directly without any intermediate steps.
Key characteristics:
- Automatic: No IDL definition, no schema compilation, no manual object-to-protocol conversion
- Cross-language: Same binary format works seamlessly across Java, Python, C++, Rust, Go, JavaScript, and more
- Reference-aware: Handles shared references and circular references without duplication or infinite recursion
- Polymorphic: Supports object polymorphism with runtime type resolution
This specification defines the Fory xlang binary format. The format is dynamic rather than static, which enables flexibility and ease of use at the cost of additional complexity in the wire format.
Type Systems
Data Types
- bool: a boolean value (true or false).
- int8: a 8-bit signed integer.
- int16: a 16-bit signed integer.
- int32: a 32-bit signed integer.
- var_int32: a 32-bit signed integer which use fory var_int32 encoding.
- int64: a 64-bit signed integer.
- var_int64: a 64-bit signed integer which use fory PVL encoding.
- sli_int64: a 64-bit signed integer which use fory SLI encoding.
- float16: a 16-bit floating point number.
- float32: a 32-bit floating point number.
- float64: a 64-bit floating point number including NaN and Infinity.
- string: a text string encoded using Latin1/UTF16/UTF-8 encoding.
- enum: a data type consisting of a set of named values. Rust enum with non-predefined field values are not supported as an enum.
- named_enum: an enum whose value will be serialized as the registered name.
- struct: a morphic(final) type serialized by Fory Struct serializer. i.e. it doesn't have subclasses. Suppose we're
deserializing
List<SomeClass>, we can save dynamic serializer dispatch sinceSomeClassis morphic(final). - compatible_struct: a morphic(final) type serialized by Fory compatible Struct serializer.
- named_struct: a
structwhose type mapping will be encoded as a name. - named_compatible_struct: a
compatible_structwhose type mapping will be encoded as a name. - ext: a type which will be serialized by a customized serializer.
- named_ext: an
exttype whose type mapping will be encoded as a name. - list: a sequence of objects.
- set: an unordered set of unique elements.
- map: a map of key-value pairs. Mutable types such as
list/map/set/arrayare not allowed as key of map. - duration: an absolute length of time, independent of any calendar/timezone, as a count of nanoseconds.
- timestamp: a point in time, independent of any calendar/timezone, as a count of nanoseconds. The count is relative to an epoch at UTC midnight on January 1, 1970.
- local_date: a naive date without timezone. The count is days relative to an epoch at UTC midnight on Jan 1, 1970.
- decimal: exact decimal value represented as an integer value in two's complement.
- binary: an variable-length array of bytes.
- array: only allow 1d numeric components. Other arrays will be taken as List. The implementation should support the
interoperability between array and list.
- bool_array: one dimensional int16 array.
- int8_array: one dimensional int8 array.
- int16_array: one dimensional int16 array.
- int32_array: one dimensional int32 array.
- int64_array: one dimensional int64 array.
- float16_array: one dimensional half_float_16 array.
- float32_array: one dimensional float32 array.
- float64_array: one dimensional float64 array.
- union: a tagged union type that can hold one of several alternative types. The active alternative is identified by an index.
- none: represents an empty/unit value with no data (e.g., for empty union alternatives).
Note:
- Unsigned int/long are not added here, since not every language support those types.
Polymorphisms
For polymorphism, if one non-final class is registered, and only one subclass is registered, then we can take all elements in List/Map have same type, thus reduce runtime check cost.
Collection/Array polymorphism are not fully supported, since some languages such as golang have only one collection type. If users want to get exactly the type he passed, he must pass that type when deserializing or annotate that type to the field of struct.
Type disambiguation
Due to differences between type systems of languages, those types can't be mapped one-to-one between languages. When deserializing, Fory use the target data structure type and the data type in the data jointly to determine how to deserialize and populate the target data structure. For example:
class Foo {
int[] intArray;
Object[] objects;
List<Object> objectList;
}
class Foo2 {
int[] intArray;
List<Object> objects;
List<Object> objectList;
}
intArray has an int32_array type. But both objects and objectList fields in the serialize data have list data
type. When deserializing, the implementation will create an Object array for objects, but create a ArrayList
for objectList to populate its elements. And the serialized data of Foo can be deserialized into Foo2 too.
Users can also provide meta hints for fields of a type, or the type whole. Here is an example in java which use annotation to provide such information.
@ForyObject(fieldsNullable = false, trackingRef = false)
class Foo {
@ForyField(trackingRef = false)
int[] intArray;
@ForyField(polymorphic = true)
Object object;
@ForyField(tagId = 1, nullable = true)
List<Object> objectList;
}
Such information can be provided in other languages too:
- cpp: use macro and template.
- golang: use struct tag.
- python: use typehint.
- rust: use macro.
Type ID
All internal data types are expressed using an ID in range 0~64. Users can use IDs in range 0~8192 for registering their
custom types (struct/ext/enum). User type IDs are in a separate namespace and combined with internal type IDs via bit shifting:
(user_type_id << 8) | internal_type_id.
Internal Type ID Table
| Type ID | Name | Description |
|---|---|---|
| 0 | UNKNOWN | Unknown type, used for dynamic typing |
| 1 | BOOL | Boolean value |
| 2 | INT8 | 8-bit signed integer |
| 3 | INT16 | 16-bit signed integer |
| 4 | INT32 | 32-bit signed integer |
| 5 | VAR_INT32 | Variable-length encoded 32-bit signed integer |
| 6 | INT64 | 64-bit signed integer |
| 7 | VAR_INT64 | Variable-length encoded 64-bit signed integer |
| 8 | SLI_INT64 | Small Long as Int encoded 64-bit signed integer |
| 9 | FLOAT16 | 16-bit floating point (half precision) |
| 10 | FLOAT32 | 32-bit floating point (single precision) |
| 11 | FLOAT64 | 64-bit floating point (double precision) |
| 12 | STRING | UTF-8/UTF-16/Latin1 encoded string |
| 13 | ENUM | Enum registered by numeric ID |
| 14 | NAMED_ENUM | Enum registered by namespace + type name |
| 15 | STRUCT | Struct registered by numeric ID (schema consistent) |
| 16 | COMPATIBLE_STRUCT | Struct with schema evolution support (by ID) |
| 17 | NAMED_STRUCT | Struct registered by namespace + type name |
| 18 | NAMED_COMPATIBLE_STRUCT | Struct with schema evolution (by name) |
| 19 | EXT | Extension type registered by numeric ID |
| 20 | NAMED_EXT | Extension type registered by namespace + type name |
| 21 | LIST | Ordered collection (List, Array, Vector) |
| 22 | SET | Unordered collection of unique elements |
| 23 | MAP | Key-value mapping |
| 24 | DURATION | Time duration (seconds + nanoseconds) |
| 25 | TIMESTAMP | Point in time (nanoseconds since epoch) |
| 26 | LOCAL_DATE | Date without timezone (days since epoch) |
| 27 | DECIMAL | Arbitrary precision decimal |
| 28 | BINARY | Raw binary data |
| 29 | ARRAY | Generic array type |
| 30 | BOOL_ARRAY | 1D boolean array |
| 31 | INT8_ARRAY | 1D int8 array |
| 32 | INT16_ARRAY | 1D int16 array |
| 33 | INT32_ARRAY | 1D int32 array |
| 34 | INT64_ARRAY | 1D int64 array |
| 35 | FLOAT16_ARRAY | 1D float16 array |
| 36 | FLOAT32_ARRAY | 1D float32 array |
| 37 | FLOAT64_ARRAY | 1D float64 array |
| 38 | UNION | Tagged union type (one of several alternatives) |
| 39 | NONE | Empty/unit type (no data) |
Type ID Encoding for User Types
When registering user types (struct/ext/enum), the full type ID combines user ID and internal type ID:
Full Type ID = (user_type_id << 8) | internal_type_id
Examples:
| User ID | Type | Internal ID | Full Type ID | Decimal |
|---|---|---|---|---|
| 0 | STRUCT | 15 | (0 << 8) | 15 | 15 |
| 0 | ENUM | 13 | (0 << 8) | 13 | 13 |
| 1 | STRUCT | 15 | (1 << 8) | 15 | 271 |
| 1 | COMPATIBLE_STRUCT | 16 | (1 << 8) | 16 | 272 |
| 2 | NAMED_STRUCT | 17 | (2 << 8) | 17 | 529 |
When reading type IDs:
- Extract internal type:
internal_type_id = full_type_id & 0xFF - Extract user type ID:
user_type_id = full_type_id >> 8
Type mapping
See Type mapping
Spec overview
Here is the overall format:
| fory header | object ref meta | object type meta | object value data |
The data are serialized using little endian byte order overall. If bytes swap is costly for some object, Fory will write the byte order for that object into the data instead of converting it to little endian.
Fory header
Fory header format for xlang serialization:
| 2 bytes | 1 byte bitmap | 1 byte | optional 4 bytes |
+--------------+--------------------------------+------------+------------------------------------+
| magic number | 4 bits reserved | 4 bits meta | language | unsigned int for meta start offset |
Detailed byte layout:
Byte 0-1: Magic number (0x62d4) - little endian
Byte 2: Bitmap flags
- Bit 0: null flag (0x01)
- Bit 1: endian flag (0x02)
- Bit 2: xlang flag (0x04)
- Bit 3: oob flag (0x08)
- Bits 4-7: reserved
Byte 3: Language ID (only present when xlang flag is set)
Byte 4-7: Meta start offset (only present when meta share mode is enabled)
- magic number:
0x62d4(2 bytes, little endian) - used to identify fory xlang serialization protocol. - null flag (bit 0): 1 when object is null, 0 otherwise. If an object is null, only this flag and endian flag are set.
- endian flag (bit 1): 1 when data is encoded by little endian, 0 for big endian. Modern implementations always use little endian.
- xlang flag (bit 2): 1 when serialization uses Fory xlang format, 0 when serialization uses Fory language-native format.
- oob flag (bit 3): 1 when out-of-band serialization is enabled (BufferCallback is not null), 0 otherwise.
- language: 1 byte indicating the source language. This allows deserializers to optimize for specific language characteristics.
Language IDs
| Language | ID |
|---|---|
| XLANG | 0 |
| JAVA | 1 |
| PYTHON | 2 |
| CPP | 3 |
| GO | 4 |
| JAVASCRIPT | 5 |
| RUST | 6 |
| DART | 7 |
Meta Start Offset
If compatible mode is enabled, an uncompressed unsigned int32 (4 bytes, little endian) is appended to indicate the start offset of metadata. During serialization, this is initially written as a placeholder (e.g., -1 or 0), then updated after all objects are serialized and metadata is collected.
Reference Meta
Reference tracking handles whether the object is null, and whether to track reference for the object by writing corresponding flags and maintaining internal state.
Reference Flags
| Flag | Byte Value (int8) | Hex | Description |
|---|---|---|---|
| NULL FLAG | -3 | 0xFD | Object is null. No further bytes are written for this object. |
| REF FLAG | -2 | 0xFE | Object was already serialized. Followed by unsigned varint32 reference ID. |
| NOT_NULL VALUE FLAG | -1 | 0xFF | Object is non-null but reference tracking is disabled for this type. Object data follows immediately. |
| REF VALUE FLAG | 0 | 0x00 | Object is referencable and this is its first occurrence. Object data follows. Assigns next reference ID. |
Reference Tracking Algorithm
Writing:
function write_ref_or_null(buffer, obj):
if obj is null:
buffer.write_int8(NULL_FLAG) // -3
return true // done, no more data to write
if reference_tracking_enabled:
ref_id = lookup_written_objects(obj)
if ref_id exists:
buffer.write_int8(REF_FLAG) // -2
buffer.write_varuint32(ref_id)
return true // done, reference written
else:
buffer.write_int8(REF_VALUE_FLAG) // 0
add_to_written_objects(obj, next_ref_id++)
return false // continue to serialize object data
else:
buffer.write_int8(NOT_NULL_VALUE_FLAG) // -1
return false // continue to serialize object data
Reading:
function read_ref_or_null(buffer):
flag = buffer.read_int8()
switch flag:
case NULL_FLAG (-3):
return (null, true) // null object, done
case REF_FLAG (-2):
ref_id = buffer.read_varuint32()
obj = get_from_read_objects(ref_id)
return (obj, true) // referenced object, done
case NOT_NULL_VALUE_FLAG (-1):
return (null, false) // non-null, continue reading
case REF_VALUE_FLAG (0):
reserve_ref_slot() // will be filled after reading
return (null, false) // non-null, continue reading