Pbwire Elixir binding

An Elixir wrapper over the pbwire NIF for Elixir (and any BEAM language): elixir-protobuf generates the structs as usual, Pbwire.load_schema/1 consumes them, and Pbwire.encode/1 / decode/2 run in C instead of Elixir — same structs, same wire format, byte-identical output. No FileDescriptorSet, no per-message native build, no second wire implementation.

The Erlang binding (bindings/erlang, Hex package pbwire) owns the NIF and the C core; this package (pbwire_elixir) owns the Elixir half only: translating the generated __message_props__/0 into the defs :pbwire consumes, and translating structs to the record tuples the NIF encodes and back. That split is deliberate — adding a second NIF that re-parses elixir-protobuf terms would duplicate the whole codec for no new wire fact. Elixir runs on the BEAM like Erlang, so it reuses the same NIF the same way, the way the Erlang binding reuses gpb instead of duplicating it.

Piece Path Role
runtime lib/ Pbwire (public API + struct↔tuple translation) and Pbwire.Schema (generated-module → NIF defs)
examples examples/addressbook.exs hand-written generated-style structs, struct round trip
tests test/ ExUnit self-check, elixir-protobuf as the oracle
benchmarks benchmarks/bench.exs four standard tiers vs Protobuf (make bench)
conformance conformance/ status and plan: blocked on test-message module-name collisions
plugin none elixir-protobuf (protoc-gen-elixir) is the generator; __message_props__/0 is the schema source

Build and run

Needs Elixir (see .tool-versions) and a C compiler; mix fetches pbwire (the NIF) and protobuf (the oracle) from Hex. The NIF links the repo's C core, which this binding compiles from src/ into build/ here — nothing outside bindings/elixir/ is written.

make          # core lib + deps + compile
make test     # ExUnit: byte-identity with Protobuf.encode, struct equality with Protobuf.decode
make bench    # four standard tiers vs elixir-protobuf on matrix3.proto

With mix directly (after one make setup, which builds build/libpbwire.a the :pbwire dep compiles against):

mix test
mix run examples/addressbook.exs
mix run benchmarks/bench.exs

Quick start

Given person.proto:

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;
}

Generate Elixir structs with protoc-gen-elixir, then load the schema once and encode/decode:

:ok = Pbwire.load_schema([MyApp.Person])
msg = %MyApp.Person{name: "abc def", id: 345, email: "a@example.com"}
bin = Pbwire.encode(msg) == Protobuf.encode(msg)  # true
%MyApp.Person{} = Pbwire.decode(bin, MyApp.Person)

Convenience wrappers that load the schema on each call:

Pbwire.encode_msg(msg, [MyApp.Person])
Pbwire.decode_msg(bin, MyApp.Person, [MyApp.Person])

Referencing another message, enum or map entry pulls it into the schema transitively: load_schema([MyApp.Person]) is enough when Person embeds the rest. Message and enum names in the NIF cache are the Elixir module atoms themselves, so two schemas can never collide the way unqualified record names do.

API reference

Function Returns Notes
Pbwire.load_schema(mods, opts \\ []) :ok mods is a list of generated modules; replaces any previously loaded schema for the whole node
Pbwire.purge_schema() :ok Forgets the schema; later encode/1 / decode/2 raise until load_schema/2 runs again
Pbwire.encode(struct) binary() struct from a loaded generated module; byte-identical to Protobuf.encode/1
Pbwire.decode(bin, mod) struct mod is the generated module; equal to Protobuf.decode(bin, mod)
Pbwire.encode_msg(struct, mods) binary() load_schema(mods) + encode(struct)
Pbwire.decode_msg(bin, mod, mods) struct load_schema(mods ++ [mod]) + decode(bin, mod)

Calling encode/1 or decode/2 with a module that has no loaded schema raises ArgumentError before touching the NIF. NIF-level failures (malformed wire data, a missing required field, an unknown message name) surface as ErlangError, the same exceptions the Erlang binding raises.

Supported protobuf features

Feature Support
Scalar types int32/64, uint32/64, sint32/64, fixed32/64, sfixed32/64, float, double, bool, string, bytes
Enums Yes, including unknown numbers (decode to integers, like the oracle); negative values
Occurrence required, optional (proto2 + explicit proto3), implicit presence, repeated (packed and unpacked)
Composite Nested messages, oneof ({member, value} tuples, like the struct), map<K, V> (Elixir maps)
proto3 Yes, including zero-omission and proto3_optional presence
Unknown fields Dropped on decode (the tuple form has no storage for them); structs decode with __unknown_fields__: []

Not covered: groups and extensions (elixir-protobuf generates neither), NaN bit-identity (any quiet-NaN encoding decodes to :nan; the NIF and the oracle pick differentpayload bits), and missing-required tolerance (Pbwire.decode/2 raises where the oracle fills the default).

Options

Pbwire.load_schema/2 passes its options to :pbwire.set_opts/1 before loading:

Option Default Effect
:with_utf8 true Allow non-Latin-1 UTF-8 in string fields when encoding (Elixir strings are UTF-8; the Erlang default is false)
:string_as_list unset Decode string fields as charlists instead of binaries (leave off: Elixir strings are binaries)

Benchmark

benchmarks/bench.exs measures Pbwire against elixir-protobuf (pure Elixir) on the same schema and payload as every other binding in this repo: bindings/c/tests/data/matrix3.proto with the repeated fields scaled x0 / x1 / x100 / x3000 (136 / 174 / 2469 / 74913 bytes on the wire). Both backends build the same struct, so the timer measures the codec, not the tree. Every backend must match byte for byte (encode, plus re-encoding a decoded reference) before anything is timed.

Last run: 2026-11-05 (matrix3.proto, the four standard tiers; Elixir 1.20.5 / OTP 29, Apple M4 Pro)

payload op pbwire-elixir (MB/s, x protobuf) protobuf (MB/s)
136 B encode 154.1 (3.3x) 47.3
136 B decode 84.8 (1.4x) 59.5
174 B encode 157.3 (3.0x) 51.6
174 B decode 102.0 (1.6x) 63.4
2469 B encode 166.8 (2.8x) 60.2
2469 B decode 99.0 (1.7x) 57.8
74913 B encode 145.6 (2.3x) 63.2
74913 B decode 84.7 (1.4x) 60.6

Higher is better. Throughput in MB/s on serialized wire size.

The baseline is pure Elixir (~50 MB/s flat across tiers: per-field bookkeeping in the interpreter), so the NIF wins every cell; xlarge decode is the closest race (1.40x), where the struct↔tuple translation the Elixir half owns is most visible. Rerun with make bench and paste the new table over the block above.

FAQ

Do I need to regenerate my .proto files or change my structs?

No. Keep protoc-gen-elixir output and struct definitions; only the encode/decode calls change.

Does it produce the same bytes as Protobuf.encode/1?

Yes for the messages covered by the test suite (which asserts == on every case, including a 3000-element repeated field). Both follow the protobuf wire format, so any conforming decoder reads either.

How is this different from the Erlang binding?

Same NIF, same C core, same cache semantics — plus a translation layer: generated modules in, record tuples over the NIF, structs back out. The Erlang binding consumes gpb defs; this one consumes __message_props__/0. Neither parses .proto text and neither owns wire code.

Why does encode/1 raise ArgumentError?

No schema is loaded for the struct's module (or purge_schema/0 was called). Call Pbwire.load_schema/1 first, or use encode_msg/2 / decode_msg/3.

Can several processes or applications use it at once?

Encoding and decoding are safe from any number of processes. The schema cache is global to the node, so two applications with different .proto schemas need separate nodes — same rule as the Erlang binding.

Why is there no c_src/ here?

There is no Elixir-side C to write: the NIF ships in the :pbwire Hex package and links the repo's C core (built into build/ by make). An Elixir NIF that re-parses elixir-protobuf terms would duplicate the Erlang binding's whole codec for identical bytes.

Development

make tests   # ExUnit (byte parity + struct parity with the oracle)
make bench   # the four standard tiers
make c-format       # mix format
make c-format-check # mix format --check-formatted

Layout: lib/pbwire.ex (public API + struct↔tuple translation), lib/pbwire/schema.ex (generated-module → gpb-style defs, cached in persistent_term beside the NIF's own global cache), test/ (ExUnit plus test/support/ generated-style modules), benchmarks/bench.exs (byte-gated harness), conformance/README.md (status and plan).

Generated models

protoc-gen-pbwire (in ../../codegen, --pbwire_opt=lang=elixir) emits one .ex file per .proto: elixir-protobuf modules (the binding's schema source, no descriptor set embedded — the modules are the schema) plus a per-file Registry module holding message_modules/0, the whole-file list to pass straight to Pbwire.load_schema/1 (load_schema resolves referenced modules transitively). [,model=bool][,register=bool] [,registry=bool] (all default true): model=false leaves the list without modules; registry=false drops it; register is a no-op (loading is always explicit). The Registry is per-file — qualified (Spectest.Maptest.Registry) when the file stem differs from the package prefix, short (Xdep.Registry) otherwise — so two files in one package never share one.