🚀 zaml
A fast YAML 1.2 parser for Elixir, powered by a Zig NIF that wraps
libyaml. Heavily inspired by
kubkon/zig-yaml, with a matching
Python extension that lives in this same repository (see
Python extension below). The same parser is also
available for JavaScript (see
JavaScript package below) and as a command that
turns YAML into JSON or CBOR (see
Command line below).
iex> Zaml.load("foo: 1\nbar: [a, b, 3.14]\n")
%{"bar" => ["a", "b", 3.14], "foo" => 1}
iex> Zaml.load("""
...> name: zaml
...> tags: [yaml, elixir, nif, zig]
...> ratio: 3.14
...> flag: true
...> empty: ~
...> """)
%{"name" => "zaml", "tags" => ["yaml", "elixir", "nif", "zig"],
"ratio" => 3.14, "flag" => true, "empty" => nil}
Why?
The most popular Elixir YAML libraries — yaml_elixir
and its underlying yamerl — are
pure-Erlang ports. They're correct but slow. The existing native
option on Hex is fast_yaml
(processone), which is the obvious thing to compare against because it
also wraps libyaml.
Zaml is fast_yaml but with a thinner event-to-term bridge: the
libyaml event stream is consumed in Zig and turned into Erlang terms
in a single pass, building maps directly instead of going through
intermediate proplists. That gives a ~3–3.5× speedup over fast_yaml
across flat and nested mappings, and 36–46× over the pure-Erlang
parsers. See benchmarks below.
The newer glazer NIF is the one
library that can beat Zaml — it's faster on nested data (~1.3×) and
slower on a single flat 1M-key map (~2.1×). It gets there with a
hand-rolled C++ YAML parser built with profile-guided optimisation,
whereas Zaml deliberately stays on top of battle-tested libyaml.
The toolchain is also notably lighter: Zaml is built with the
single zig CLI invocation you see in the Makefile, with no
./configure, no rebar3 port-compiler, and no autoconf.
Benchmarks
Two fixtures, both parsed to native Erlang terms, on an Apple M3 Pro.
Full report at benchmark/RESULTS.md.
1M-line flat mapping (16 MB)
| Parser | Avg (s) | Relative |
|---|---|---|
| Zaml (NIF, libyaml + Zig) | 0.84 | 1.00× |
glazer 1.1.6 (C++ NIF, PGO) |
1.73 | 2.06× |
fast_yaml 1.0.40 (rebar3 NIF) |
2.54 | 3.03× |
yamerl 0.10.0 (pure Erlang) |
30.27 | 36.1× |
yaml_elixir 2.12.2 (yamerl) |
30.95 | 36.9× |
100k-key nested mapping (15 MB)
| Parser | Avg (s) | Relative |
|---|---|---|
| Zaml (NIF, libyaml + Zig) | 0.53 | 1.00× |
glazer 1.1.6 (C++ NIF, PGO) |
0.41 | 0.77× |
fast_yaml 1.0.40 (rebar3 NIF) |
1.86 | 3.50× |
yamerl 0.10.0 (pure Erlang) |
23.88 | 45.0× |
yaml_elixir 2.12.2 (yamerl) |
24.40 | 45.9× |
Both Zaml and fast_yaml wrap the same libyaml C parser, so the
~3× gap there is purely the event→term bridge. glazer is a
different design (a hand-rolled C++ parser, not libyaml): it wins on
nested data and loses on the wide flat map, so there's no single
"fastest" library across shapes.
Installation
Add zaml to your mix.exs:
def deps do
[
{:zaml, "~> 0.3.0"}
]
end
Build prerequisites
The NIF is built at compile time from source. You need:
- Zig 0.16.x (
brew install zig,apt install zig,asdf install zig latest, …). Zig 0.17 does not work yet: its C importer fails on the headers of the C libraries. - libyaml development headers
- macOS:
brew install libyaml - Debian/Ubuntu:
apt install libyaml-dev - Alpine:
apk add yaml-dev - Fedora:
dnf install libyaml-devel
- macOS:
- An Erlang/OTP installation (any reasonably recent version, 24+)
The compiler auto-detects libyaml via pkg-config first, then falls
back to well-known locations. Override with YAML_INCLUDE_DIR and
YAML_LIB_DIR env vars or make variables if it lives somewhere
unusual.
mix deps.get
mix compile # builds the NIF via elixir_make / Makefile
Usage
Zaml.load(yaml_string) :: term
Returns the corresponding Elixir term for the first YAML document in the input. Scalar type detection follows the YAML 1.2 core schema:
| YAML scalar | Elixir term |
|---|---|
42, -3, +1_000 |
integer (42, -3, 1000) |
3.14, 1.0e-3 |
float (3.14, 0.001) |
true, yes, on |
atom true (or :true quoted) |
false, no, off |
atom false |
null, ~, empty |
atom nil |
| everything else | binary (string) |
!!str 42 |
binary "42" (explicit tag respected) |
!!int "42" |
integer 42 (explicit tag respected) |
Sequences become lists, mappings become maps with binary keys.
Returns :error for non-binary input, :parse_error for malformed
YAML, and :nil for an empty document.
Anchor / alias example
iex> Zaml.load("""
...> defaults: &d {a: 1, b: 2}
...> prod: *d
...> """)
%{"defaults" => %{"a" => 1, "b" => 2}, "prod" => %{"a" => 1, "b" => 2}}
Note: YAML merge keys (
<<: *alias) are not yet supported.
Using the alternatives
For reference, here's how the same parse is done with each library the benchmarks compare against:
# zaml — returns the first document as a term
Zaml.load(yaml)
# fast_yaml — returns {:ok, [doc]}; :maps gives maps instead of proplists
:fast_yaml.decode(yaml, [:sane_scalars, :maps])
# glazer — hand-rolled C++ NIF; use_nil maps null -> nil (default is :null)
:glazer_yaml.decode(yaml, [:use_nil])
# ...or the Elixir wrapper:
Glazer.YAML.decode!(yaml)
# yaml_elixir — the most widely used wrapper, over pure-Erlang yamerl
YamlElixir.read_from_string(yaml) # => {:ok, term}
# yamerl directly (raw records; yaml_elixir adds the Elixir mapper)
:yamerl_constr.string(yaml, detailed_constr: true, str_node_as_binary: true)
Two other libraries in the space aren't in the parse benchmarks:
ymlr is an encoder (Ymlr.document!/1),
not a parser, and yamleam is a
pure-Gleam parser, so it isn't reachable as a plain Mix dependency.
Running tests
mix test
The test suite covers basic scalars, nested maps and lists, explicit tags, anchors, empty input, and parse-error handling.
Running benchmarks
The repo ships a Mix task that compares the Elixir NIF against
fast_yaml, glazer, yaml_elixir, and yamerl on the same fixture
(any parser whose dependency isn't installed is skipped).
# 1. Build the Elixir NIF + the benchmark deps
# (glazer needs a C++23 compiler and rebar3; `brew install rebar3`)
mix deps.get
mix compile
# 2. Generate the 100k-key nested fixture
python3 benchmark/gen_big_yaml.py 100000 benchmark/big.yml
# 3. Generate the 1M-line flat fixture
python3 -c "
import yaml
yaml.dump({f'a{i}': f'b{i+1}' for i in range(1_000_000)},
Dumper=yaml.CSafeDumper, default_flow_style=False,
open('benchmark/big_1m.yml', 'w'))
"
# 4. Run the head-to-head
mix zaml_nif.bench benchmark/big.yml # Zaml parse ~0.5 s (all parsers run)
mix zaml_nif.bench benchmark/big_1m.yml # Zaml parse ~0.8 s (all parsers run)
A separate cross-language script (benchmark/run_benchmark.py) also
compares against PyYAML and ruamel.yaml if you want the Python
numbers.
Project layout
.
├── lib/
│ ├── zaml.ex # public Elixir API
│ └── mix/tasks/
│ ├── zaml_nif.build.ex # manual NIF build (`mix zaml_nif.build`)
│ └── zaml_nif.bench.ex # `mix zaml_nif.bench` benchmark task
├── native/
│ └── zaml_nif.zig # the NIF (uses libyaml via @cImport)
├── core/ # the host independent core
│ ├── scalar.zig # how a scalar and a tag resolve
│ ├── stream.zig # what the streaming encoders share
│ ├── parse.zig, parse_c.zig # the event stream, native and WASM
│ ├── cbor.zig, json.zig # the output formats
│ ├── base64.zig, emit.zig, event.zig
│ ├── value.zig # the value tree, the reference of the tests
│ └── native.zig # what the native hosts share
├── wasm/
│ ├── main.zig # the WebAssembly module (browser)
│ ├── wasi_main.zig # the WASI command
│ ├── alloc.zig, c_abi.zig # the freestanding heap and the C ABI
│ └── shim/ # the C bridge to the vendored libyaml
├── packages/
│ ├── zamljs/ # the npm package
│ └── wasmer/ # the Wasmer package
├── vendor/libyaml/ # the libyaml sources the WASM builds use
├── build.zig # builds the WebAssembly targets
├── priv/ # compiled zaml.so lands here
├── test/ # ExUnit tests
├── benchmark/
│ ├── benchmark.exs # Elixir side
│ ├── run_benchmark.py # Python driver
│ ├── gen_big_yaml.py # fixture generator
│ ├── big.yml # generated 100k-key fixture
│ └── RESULTS.md # last benchmark run
├── Makefile # `make` builds the NIF directly
└── mix.exs
Limitations / status
- YAML merge keys (
<<: *alias) are not yet expanded. - Multi-document input returns only the first document.
- Not yet battle-tested on very large or adversarial input. If you find a crash, please open an issue with the input.
Credits
kubkon/zig-yaml— the original pure-Zig YAML parser that inspired this project.yaml/libyaml— the production YAML 1.1 parser that both the Elixir NIF and the Python extension wrap.
Python extension
The original proof-of-concept has grown into a real, pip install-able
CPython extension (zamlpy 0.3.0, Python 3.10+):
zamlpy.load(str_or_bytes) parses a YAML document with libyaml and returns
native Python objects. Like the Elixir NIF, it is built in pure Zig by
importing Python.h directly — no clang or setuptools C glue. It began
as the subject of a PyCon DE 2022 talk on Speeding Up Python with
Zig.
Scalars are resolved with the YAML 1.2 core schema (int, arbitrary-
precision int, float, bool, null), !!str / !!int / !!float /
!!bool / !!null tags are honored, and anchors/aliases are supported.
Only the first document in a stream is returned; malformed input raises
ValueError.
Prerequisites
- Python 3.10+ (CPython)
- Zig 0.16.x
- libyaml (
brew install libyaml,apt install libyaml-dev,dnf install libyaml-devel)
builder.py auto-detects libyaml under /opt/homebrew, /usr/local,
and /usr. Override with YAML_INCLUDE_DIR / YAML_LIB_DIR if needed.
Building the Python extension
The extension has no Python runtime dependencies; it links libyaml and is
built from source by pip (via the custom builder.py backend) on
install.
python -m venv .venv
source .venv/bin/activate
pip install . # or: pip install -e . for a development checkout
python test.py
The PyPI
zamlproject (owned by a third party) is an unrelated no-op stub. This project's Python distribution is published aszamlpy.
Python benchmark
cd benchmark
python run_benchmark.py
On an Apple M3 Pro (Python 3.14), parsing a 15 MB YAML document:
zamlpy 0.51 s 1.0×
PyYAML CSafeLoader 9.16 s 17.8×
PyYAML SafeLoader 33.85 s 65.9×
ruamel.yaml 48.40 s 94.2×
Cross-platform notes (Python extension)
- macOS:
brew install zig libyaml. - Linux: tested via Docker (
fedorabase image,libyaml-develvia the distro package manager). - Windows: the extension does not build yet. The C importer of Zig
cannot translate the headers of the Windows SDK, so the
Python.himport fails (see translate-c#307). The other hosts (Elixir, JavaScript, Wasmer) are not affected.
JavaScript package
The parser is also available for JavaScript and TypeScript as zamljs on
npm. The package ships a WebAssembly module built from the same libyaml
sources as the native builds, so the rules are the same everywhere.
npm install zamljs
import { parse } from 'zamljs';
parse('foo: 1\nbar: [a, b, 3.14]\n');
// { foo: 1, bar: [ 'a', 'b', 3.14 ] }
The package is ESM-only TypeScript with no runtime dependencies and it
ships its own types. It works in browsers, Node.js, Bun and Deno: the
browser and node export conditions pick the right loader, so a
bundler gets the browser build and Node reads the module from the disk.
Nothing is lost on the way to JavaScript: an integer that does not fit a
number comes back as a bigint, and a !!binary value stays a
Uint8Array. A mapping whose keys are not all strings becomes a Map.
A document that cannot be parsed throws a ZamlError that carries the
line and column of the problem.
parseCbor and parseJson return the raw encoded document instead of a
value, which is what the command below writes.
Building the package from a checkout:
zig build wasm # builds the WebAssembly module
cd packages/zamljs
npm install
npm run build # compiles the TypeScript, copies the module
npm test
Command line
The parser is also a command that turns YAML into JSON or CBOR, built for
WASI and published on the Wasmer registry as
niranjanaryan/zaml.
wasmer install niranjanaryan/zaml
$ echo 'foo: 1
bar: [a, b, 3.14]' | wasmer run niranjanaryan/zaml
{"foo":1,"bar":["a","b",3.14]}
The output is JSON by default. CBOR keeps the exact value of every integer, which JSON cannot:
$ echo 'big: 123456789012345678901234567890' | wasmer run niranjanaryan/zaml -- --format cbor
A document that cannot be parsed is reported on standard error, and the command exits with 1:
$ echo 'a: [1, 2' | wasmer run niranjanaryan/zaml
zaml: line 2, column 1: did not find expected ',' or ']'
Building it from a checkout:
zig build wasm-wasi # writes zig-out/dist/wasm/zaml.wasi.wasm
Building the WebAssembly targets
Both WebAssembly targets are built by the build script of the repository
and need nothing but the Zig toolchain — the libyaml sources they use
are vendored in vendor/libyaml/.
zig build test # the tests of the shared core
zig build wasm # zig-out/dist/wasm/zaml.wasm
zig build wasm-wasi # zig-out/dist/wasm/zaml.wasi.wasm
The browser module has no imports at all: it grows its own memory and carries the handful of C functions libyaml needs. The WASI command uses the C library of the runtime instead.
Metadata
Release files for zamlpy 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| zamlpy-0.3.0.tar.gz | 44.2 kB | Without provenance | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| zamlpy-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl | CPython 3.13 | CPython 3.13 | Linux musl 1.2+ x86-64 | Details |
| zamlpy-0.3.0-cp313-cp313-manylinux_2_28_x86_64.whl | CPython 3.13 | CPython 3.13 | Linux glibc 2.28+ x86-64 | Details |
| zamlpy-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl | CPython 3.12 | CPython 3.12 | Linux musl 1.2+ x86-64 | Details |
| zamlpy-0.3.0-cp312-cp312-manylinux_2_28_x86_64.whl | CPython 3.12 | CPython 3.12 | Linux glibc 2.28+ x86-64 | Details |
| zamlpy-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl | CPython 3.11 | CPython 3.11 | Linux musl 1.2+ x86-64 | Details |
| zamlpy-0.3.0-cp311-cp311-manylinux_2_28_x86_64.whl | CPython 3.11 | CPython 3.11 | Linux glibc 2.28+ x86-64 | Details |
| zamlpy-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl | CPython 3.10 | CPython 3.10 | Linux musl 1.2+ x86-64 | Details |
| zamlpy-0.3.0-cp310-cp310-manylinux_2_28_x86_64.whl | CPython 3.10 | CPython 3.10 | Linux glibc 2.28+ x86-64 | Details |
Total release size: 1.0 MB
Release files / zamlpy-0.3.0.tar.gz
| Download URL | zamlpy-0.3.0.tar.gz |
|---|---|
| Size | 44.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
80e91ce6816912c601f0ba9793c697b11e8ea9af905d34d573963ca238c09790
|
|
BLAKE2b-256 checksum How to use checksums |
a29ee07964d855d24a454917bace2d46c4d90f042aee34e94016314075068a94
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl
| Download URL | zamlpy-0.3.0-cp313-cp313-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 119.6 kB |
| Tags | CPython 3.13 Linux musl 1.2+ x86-64 |
|
SHA-256 checksum How to use checksums |
6475ee45f15042bbbe45e4be897837aea618f691caee8d21d008560b49a27dd1
|
|
BLAKE2b-256 checksum How to use checksums |
63719fa3b10c708c045289ba369fc9060201c03f1d60b2a3897464deef170896
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp313-cp313-manylinux_2_28_x86_64.whl
| Download URL | zamlpy-0.3.0-cp313-cp313-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 129.8 kB |
| Tags | CPython 3.13 Linux glibc 2.28+ x86-64 |
|
SHA-256 checksum How to use checksums |
95dd0fa1af57e85602cef6e5eccede143872bcca3e8157eb69b08ad72b2b40f7
|
|
BLAKE2b-256 checksum How to use checksums |
2eb2c47699e18211797d4eed7c51bfd8057add406578409b80511a1d1a94fe95
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl
| Download URL | zamlpy-0.3.0-cp312-cp312-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 119.6 kB |
| Tags | CPython 3.12 Linux musl 1.2+ x86-64 |
|
SHA-256 checksum How to use checksums |
908e83c5a8dee6448b15ece2b06264580d3c04e91bfd8e59dd457ab11e9f4ecd
|
|
BLAKE2b-256 checksum How to use checksums |
8b5609687c6c6bf54b614818f93c7830e64b96f517bcc12f0a85d74fb03aa825
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp312-cp312-manylinux_2_28_x86_64.whl
| Download URL | zamlpy-0.3.0-cp312-cp312-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 129.7 kB |
| Tags | CPython 3.12 Linux glibc 2.28+ x86-64 |
|
SHA-256 checksum How to use checksums |
0dcf3a726654840d2a51359a0477ffede1019f27453f04db5cf44af6a61f19d5
|
|
BLAKE2b-256 checksum How to use checksums |
801f09ae056648a3d6555a426f0181a4de96bc6461b05142a84d580908cb9fd4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl
| Download URL | zamlpy-0.3.0-cp311-cp311-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 119.5 kB |
| Tags | CPython 3.11 Linux musl 1.2+ x86-64 |
|
SHA-256 checksum How to use checksums |
599748f564f018325a765ee746c892f5245f11a5a37cd4c9923c6b3c29be183c
|
|
BLAKE2b-256 checksum How to use checksums |
74089e465afa49fb19be798123874a8822236a00fb523b04ffb2bc17a6fd2bba
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp311-cp311-manylinux_2_28_x86_64.whl
| Download URL | zamlpy-0.3.0-cp311-cp311-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 129.7 kB |
| Tags | CPython 3.11 Linux glibc 2.28+ x86-64 |
|
SHA-256 checksum How to use checksums |
3c2479fb433c2c8c820604a64f8d50d4b4c196f58e7e8fb07dd36699ca0704e3
|
|
BLAKE2b-256 checksum How to use checksums |
31376484dd53b12a4ab95d1668d9fde75103fd4d37c2c005b7031bea11090d49
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl
| Download URL | zamlpy-0.3.0-cp310-cp310-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 119.5 kB |
| Tags | CPython 3.10 Linux musl 1.2+ x86-64 |
|
SHA-256 checksum How to use checksums |
1b904989a9914567fee80edb6e7df7a10957ee964c74328b492696426476531a
|
|
BLAKE2b-256 checksum How to use checksums |
f3c99110f74661d4cee2c57e5e96cc51e7ec78120fdee064ee924ba9115436b2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Release files / zamlpy-0.3.0-cp310-cp310-manylinux_2_28_x86_64.whl
| Download URL | zamlpy-0.3.0-cp310-cp310-manylinux_2_28_x86_64.whl |
|---|---|
| Size | 129.7 kB |
| Tags | CPython 3.10 Linux glibc 2.28+ x86-64 |
|
SHA-256 checksum How to use checksums |
3c43861a12dbcca5621fdae5c1a51b307f59f69a3247475e51569db553ff58d5
|
|
BLAKE2b-256 checksum How to use checksums |
66e2eded8fa325e26a7c54e6996eab0cb36d69dca35ddd1758bdbbc7759a4d1b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.15.0
|
Security for zamlpy 0.3.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.