pbwire Erlang binding
A NIF over the C core for Erlang/OTP (and Elixir): gpb generates the record
definitions as usual, pbwire:load_cache/1 consumes them, and
pbwire:encode/1 / decode/2 run in C instead of Erlang — same record
tuples, same wire format, byte-identical output. No libprotobuf, no per-message
native build.
Previously published standalone as enif_protobuf; it is now developed here
as bindings/erlang and published to Hex as pbwire. If you depended on the
old repository, point your checkout at this one and replace enif_protobuf:
with pbwire: — the API and the wire format are unchanged.
| Piece | Path | Role |
|---|---|---|
| runtime | c_src/, src/, priv/ |
the NIF (ep_encoder.c / ep_decoder.c) and its Erlang module |
| examples | examples/addressbook.escript |
hand-built defs, record round trip |
| tests | test/, c_src_tests/ |
EUnit suites and the NIF's C tests (make erlang-test) |
| benchmarks | benchmarks/ |
vs gpb and gpb NIF (make erlang-bench) |
| conformance | conformance/ |
status and plan: blocked on gpb record-name collisions |
| plugin | none | epb takes gpb definitions; gpb_compile / protoc-erl is the generator |
This is the rebar3 layout (src/, c_src/, include/, priv/, test/), the
local equivalent of the other bindings' runtime/ + tests/.
Build and run
Needs Erlang/OTP (see .tool-versions) and a C compiler;
rebar3 is bootstrapped by the binding's own Makefile.
make erlang-test # from the repo root: EUnit + the NIF C tests
make erlang-bench # from the repo root: epb vs gpb (+ gpb NIF, needs protoc + libprotobuf)
With rebar3 directly (from bindings/erlang):
rebar3 compile
rebar3 eunit
Quick start
Given x.proto:
message Person {
required string name = 1;
required int32 id = 2;
optional string email = 3;
}
Generate and compile Erlang with gpb:
export GPB_PATH=/path/to/gpb
${GPB_PATH}/bin/protoc-erl -I. x.proto
erlc -I${GPB_PATH}/include x.erl
Load the schema cache once, then encode and decode:
1> rr("x.hrl").
['Person']
2> Msg = #'Person'{name = "abc def", id = 345, email = "a@example.com"}.
3> gpb_bin = x:encode_msg(Msg).
4> pbwire:load_cache(x:get_msg_defs()).
ok
5> epb_bin = pbwire:encode(Msg).
6> gpb_bin =:= epb_bin.
true
7> pbwire:decode(epb_bin, 'Person').
#'Person'{name = <<"abc def">>, id = 345, email = <<"a@example.com">>}
Convenience wrappers that load the cache on each call:
pbwire:encode_msg(Msg, x:get_msg_defs()).
pbwire:decode_msg(Bin, 'Person', x:get_msg_defs()).
On gpb 5+ the introspection list was split: get_msg_defs/0 keeps only
messages and enums, while get_proto_defs/0 (compile with
{introspect_get_proto_defs, true}) also carries {syntax, "proto3"} and
proto3_msgs. Pass Module:get_proto_defs() there: without the proto3
markers a proto3 message is read as proto2 and its zero-valued singular
fields are written out instead of omitted. get_msg_defs/0 still works for
proto2.
API reference
| Function | Returns | Notes |
|---|---|---|
pbwire:load_cache(Defs) |
ok |
Defs is Module:get_msg_defs(), or Module:get_proto_defs() on gpb 5+ (it adds the proto3 markers); replaces any previously loaded schema for the whole node |
pbwire:purge_cache() |
ok |
Frees the loaded schema; later encode/1 / decode/2 raise cache_not_exists |
pbwire:set_opts(Opts) |
ok |
Call before load_cache/1; unknown options or non-boolean values raise badarg |
pbwire:encode(Msg) |
binary() |
Msg is a gpb record or map tuple; raises badarg if it is not a tuple |
pbwire:decode(Bin, MsgName) |
record/map tuple | MsgName is the message atom, e.g. 'Person'; raises unknown_msg if the schema has no such message |
pbwire:encode_msg(Msg, Defs) |
binary() |
load_cache(Defs) + encode(Msg) |
pbwire:decode_msg(Bin, Name, Defs) |
tuple | load_cache(Defs) + decode(Bin, Name) |
pbwire:debug_term(Term) |
term | Prints the internal node layout of the loaded schema (debugging aid) |
Calling encode/1 or decode/2 before load_cache/1 raises cache_not_exists. All functions are
safe to call concurrently from many processes: the schema cache is shared and read-locked, and each
scheduler gets its own scratch buffers.
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 aliases and negative values (encoded as 64-bit varints, as protobuf requires) |
| Occurrence | required, optional, repeated (packed and unpacked) |
| Composite | Nested messages, oneof, map<K, V>, group (legacy) |
| Unknown fields | Preserved on decode and re-emitted on encode |
| proto3 | Yes, including default-value and optional semantics matching gpb |
Options
Call pbwire:set_opts/1 before load_cache/1:
| Option | Default | Effect |
|---|---|---|
{string_as_list, true} |
false |
Decode string fields as lists instead of binaries |
{with_utf8, true} |
false |
Allow non-Latin-1 UTF-8 in string fields when encoding |
string fields decode to binaries by default; non-Latin-1 text needs
{with_utf8, true} or encoding raises.
Benchmark
benchmarks/proto-bench measures epb against gpb (pure Erlang) and the
gpb NIF (libprotobuf behind gpb's own NIF generator) 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 gpb record, so the timer measures the codec, not the
tree. Every backend must match gpb byte for byte (encoding the record and
re-encoding a decoded reference) before anything is timed.
The gpb NIF baseline needs protoc plus the protobuf C++ runtime; the suite
builds and runs it when pkg-config --exists protobuf succeeds, otherwise the
table simply has no gpb-nif column. Each op is the best of three timed
samples (BENCH_TARGET_TIME seconds each, default 2) after one warm-up pass.
Copied from BENCHMARKS.md, which is the single source
for all performance numbers; higher is better, MB/s over wire bytes. See
benchmarks/README.md for all targets (per-backend
runs, quick mode, the gpb NIF baseline).
Last run: 2026-11-04 10:29:33 UTC (matrix3.proto, the four standard tiers; Erlang 29.0.1 per .tool-versions; machine Apple M4 Pro)
| payload | op | pbwire-erlang (MB/s, x gpb) | gpb (MB/s) | gpb-nif (MB/s) |
|---|---|---|---|---|
| 136 B | encode | 491.2 (2.47x) | 198.9 | 56.5 |
| 136 B | decode | 512.0 (2.18x) | 234.9 | 60.9 |
| 174 B | encode | 472.0 (2.44x) | 193.5 | 63.7 |
| 174 B | decode | 525.6 (2.21x) | 238.2 | 80.3 |
| 2469 B | encode | 407.9 (3.60x) | 113.4 | 43.9 |
| 2469 B | decode | 326.1 (1.81x) | 180.0 | 135.5 |
| 74913 B | encode | 441.7 (4.21x) | 104.9 | 109.6 |
| 74913 B | decode | 354.1 (1.98x) | 178.8 | 182.8 |
Higher is better. Throughput in MB/s on serialized wire size. Case names are the four standard tiers at the repo root (BENCHMARKS.md).
Refresh the table after a run (rewrites this block and the one in
benchmarks/README.md):
make update-readme # from bindings/erlang/benchmarks (runs the suite)
FAQ
Do I need to regenerate my .proto files or change my records?
No. Keep gpb's protoc-erl output and record definitions; only the encode/decode calls change.
Does it produce the same bytes as gpb?
Yes for the messages covered by the test suite (the tests assert gpb_bin =:= epb_bin). Both follow
the protobuf wire format, so any conforming decoder reads either.
Can I use it from Elixir?
Yes. It is an Erlang library with no non-Erlang API: generate the schema module with gpb's
protoc-erl, load it once with :pbwire.load_cache(X.get_msg_defs()), and call
:pbwire.decode(bin, :'Person') instead of the generated decode.
Why does encode/1 or decode/2 raise cache_not_exists?
No schema is loaded yet (or purge_cache/0 was called). Call pbwire:load_cache/1 first, or
use encode_msg/2 / decode_msg/3.
Why do I get badarg from set_opts/1?
Only {string_as_list, boolean()} and {with_utf8, boolean()} are accepted, and the argument must be
a proper list of such tuples.
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 must not share one VM — give each its own node, or
coordinate the single load_cache/1 call.
Does it run on dirty schedulers?
No; calls execute on normal schedulers and hold a NIF read lock for the duration of the call. Keep individual messages reasonably sized, or shard very large payload work across pools.
Why does memory stay high after encoding one huge message?
The per-scheduler encode buffer grows geometrically to fit the largest message encoded and is not shrunk afterwards; it is released when the NIF is unloaded (VM exit). This is a deliberate trade-off for the fast path.
How does it handle malformed wire data?
Decoding is strict, following the reference C++/Java parsers: a tag is a varint32 (at most
five bytes, bits above bit 31 dropped), a length prefix must be a strict varint32, group
nesting is capped at 100 and reserved wire types raise. A malformed binary raises instead of
returning a partially decoded message, so decode/2 is safe on untrusted input. gpb is more
lenient about oversized tags; for those inputs the two implementations may disagree.
Development
make tests # EUnit (gpb compatibility and PropEr round-trip tests included)
make c-tests # C unit tests
make c-format # clang-format >= 19 applied to c_src/ and c_src_tests/
Layout: src/pbwire.erl (public API + NIF stubs) → c_src/pbwire.c (NIF entry
points, per-thread contexts, cache swap), c_src/ep_encoder.c / c_src/ep_decoder.c
(single-pass wire codec over the schema cache), test/ (EUnit, gpb parity, PropEr fuzzing),
benchmarks/ (proto-bench suite). Each thread also owns a core pbw_ctx (arena +
reusable buffers) whose user slot points back at the thread data.
See AGENTS.md for the invariants that must hold in this codebase — an uncaught C
exception or a bad term handled in the NIF kills the whole VM.
Typed models
gpb records are the typed model: protoc-erl generates the record definitions and get_msg_defs/0,
and the binding encodes/decodes those records directly. There is no second generator and no
bindings/typed output for Erlang.