serialize.elixir
September 7, 2026 · View on GitHub

A bitpacking serialization library for Elixir on the BEAM. Part of the serialize family, wire compatible with the C++, C, Go, C#, Rust, JavaScript, Dart and Java libraries — the same values produce the same bytes in every implementation, so a stream written by one reads in any other. STANDARD.md is the authority on every byte — a verbatim copy of the specification in mas-bandwidth/serialize, vendored here beside the implementation and held to the upstream text by a CI check.
If this library helps you, please support it
Getting it
serialize.elixir ships as source today. The implementation is ready and the
package is not yet published: mix.exs declares the app :serialize and
carries no Hex package metadata, and nothing named serialize exists on
hex.pm. Publishing it there is a separate round.
Depend on it from git:
defp deps do
[
{:serialize, git: "https://github.com/mas-bandwidth/serialize.elixir.git", tag: "v1.1.2"}
]
end
mix deps.get, and the modules are yours:
alias Serialize.{MeasureStream, ReadStream, WriteStream}
Pure Elixir on the BEAM, zero dependencies. Pin a release tag as above rather
than tracking main. The newest is on the
releases page: a
release states a format version, and two endpoints interoperate only when they
carry the same one.
The surface
The complete family operation set in the Serialize module, unified
over three immutable streams — Serialize.WriteStream,
Serialize.ReadStream and Serialize.MeasureStream — so one serialize
function per message covers writing, reading and measuring. Every
operation returns {:ok, stream, value} or {:error, stream}; writer
contract violations raise ArgumentError; hostile bytes refuse as
values and never raise. A refusal is terminal — the read stream latches
failed, and every later read on it refuses — and hands back no value at
all. USAGE.md teaches every operation by example.
- Raw bits:
serialize_bits/3(1–64 bits in one call — BEAM integers are arbitrary precision),serialize_align/1. - Ranged integers:
serialize_int/4,serialize_int64/4,serialize_int128/4— offset from min in exactly the bit length of the range.min <= maxis the legal relation at every width, and a degeneratemin == maxrange costs zero bits. - Unsigned helpers and bool:
serialize_uint8/16/32/64,serialize_uint128,serialize_bool/2. - Floats:
serialize_float/2andserialize_double/2, bit transparent both ways — non-finite patterns travel as{:nonfinite, bits}, because BEAM float terms cannot be NaN or infinity;serialize_compressed_float/5, quantizing in float32 with the standard's two roundings on each side, emulated exactly. - Bytes and strings:
serialize_bytes/3(aligned bulk copy, count agreed, not transmitted);serialize_string/3(UTF-8 on the wire, payload validated on read);serialize_wstring/3(one 32-bit group per UTF-16 code unit, no alignment anywhere). - The relative integer:
serialize_int_relative/3— the flag ladder for strictly increasing sequences over the domain0to2^31 - 1, one bit for a difference of 1. - Fixed point:
serialize_fixed/6— Q formats at 8/16/32/64/128-bit storage in one function, the raw scaled integer as an exact ranged offset, byte identical toserialize_int64wherever storage fits 64 bits. - Range pricing:
Serialize.Bits.bits_required/2— one function covers the 32, 64 and 128 bit domains. - The bitpacker underneath:
Serialize.BitWriterandSerialize.BitReader, the family wire on BEAM binaries.
Zero external dependencies.
Quick example
One serialize function per message covers writing, reading and measuring —
the family's unified pattern, composed with with:
alias Serialize.{ReadStream, WriteStream}
def serialize(stream, d) do
with {:ok, stream, x} <- Serialize.serialize_int(stream, d.x, -100, 100),
{:ok, stream, flag} <- Serialize.serialize_bool(stream, d.flag) do
{:ok, stream, %{d | x: x, flag: flag}}
end
end
# write
{:ok, writer, _} = serialize(WriteStream.new(), %{x: -37, flag: true})
data = writer |> WriteStream.flush() |> WriteStream.data()
# read: hostile bytes refuse as {:error, stream}, never raise
{:ok, _reader, decoded} = serialize(ReadStream.new(data), %{x: 0, flag: false})
Writes assume trusted data — caller contract violations raise
ArgumentError. Reads validate always.
Toolchain
The toolchain is pinned per project, never system-wide:
tending/PINS.md records the exact OTP and Elixir
versions, download URLs and SHA-256 hashes. dist/ is gitignored —
re-fetch by the pinned URLs, verify the hashes, unpack, and prefix the
PATH:
export PATH="$PWD/dist/otp-29.0.5/bin:$PWD/dist/elixir-1.20.4/bin:$PATH"
Testing
mix test
ExUnit alone, no test dependencies. The suite runs the family's shared
conformance corpus, vendored verbatim in conformance/ and
held to upstream by a CI check. The suite discovers that directory rather
than naming its files, so a newly vendored file runs without anyone
editing a list, an empty directory fails the run, and a vector whose
operation the runner cannot drive fails rather than being skipped. Every
vector goes through this port's reader: an accepted vector must yield its
value and consume exactly the stated bits, a vector marked writer = canonical must round trip back through the write stream byte for byte,
a sequence stating measure_at_least must measure at least its floor,
and a refused vector must refuse, hand back no value, poison every later
step and leave the stream terminal. Beside it the suite pins the family's golden
vectors byte for byte — the golden wire message covering every operation
class, the discriminating compressed-float vectors (bit patterns, not
tolerances), the string and wide-string pins, every relative-integer
tier, and the fixed point shapes at every group count — plus
per-primitive unit suites, refusal proofs for hostile input, terminal
failure after every class of refusal, and the measure bound.
interop/ takes it further: the CI interop job builds the
C++ reference at a pinned release and runs it head to head with this
port. Both halves write the same boundary message — every operation the
standard defines, at its boundary values — and the files must be byte
identical; each then decodes the other's bytes and re-encodes them
exactly; both must refuse every truncation of the other's stream; and
both run the corpus. The release candidate in this repository exchanges
bytes with the reference on every push, so wire compatibility is
measured rather than asserted. mix run interop/interop.exs write out.bin runs one exchange by hand.
Benchmarking for the serialize family lives in mas-bandwidth/schema's data-driven bench, which measures the generated codecs across every language on one corpus.
Write stream growth
The write stream grows without bound and takes no capacity: the runtime appends through ERTS append-optimized binaries (reserved space doubles as writes land, amortized O(1)), and Erlang exposes no way to pre-size a binary's reservation — so a capacity parameter here would be accepted and ignored. Sizing a buffer up front is the other ports' surface, where buffers are real.
License
BSD 3-Clause, © Más Bandwidth LLC.