A native C-extension (no FFI, no fallback) Ruby TOML library over the
libteptris — the TOML counterpart of
leptris-ruby (XML) and yeptris-ruby (YAML+JSON). API-shape and value
semantics mirror the tomlib gem: offset and local datetimes become
Time, dates become Date, local times stay String.
Best-of-8 runs on a GitHub ubuntu-latest runner (ruby 3.3), 2026-09-21,
over the generated bench corpus (~700 KB per shape). Full per-shape
table regenerated on every main push by the lang-tier lane.
| Shape | teptris | tomlib 0.7.3 | tomlrb 2.0.4 |
|---|---|---|---|
array_heavy |
3.0 ms / 237 MB/s |
21.7 ms / 33 MB/s |
532 ms / 1.4 MB/s |
cargo_like |
4.0 ms / 88 MB/s |
66.7 ms / 5.3 MB/s |
307 ms / 1.2 MB/s |
datetime_heavy |
31.4 ms / 34 MB/s |
12,837 ms / 0.1 MB/s |
792 ms / 1.4 MB/s |
deep_tables |
2.9 ms / 84 MB/s |
7.0 ms / 35 MB/s |
247 ms / 1.0 MB/s |
mixed |
6.2 ms / 70 MB/s |
17.2 ms / 25 MB/s |
427 ms / 1.0 MB/s |
scalar_float |
16.8 ms / 88 MB/s |
18,761 ms / 0.1 MB/s |
855 ms / 1.7 MB/s |
scalar_int |
14.8 ms / 87 MB/s |
15,135 ms / 0.1 MB/s |
868 ms / 1.5 MB/s |
scalar_string |
9.1 ms / 142 MB/s |
3,587 ms / 0.4 MB/s |
430 ms / 3.0 MB/s |
teptris is 7-110x tomlib and 20-280x tomlrb per shape end to end (parse + materialize into Ruby objects); tomlib’s scalar-heavy cliffs are its datetime/number paths.
gem install teptris
RubyGems resolves a prebuilt platform gem where one exists; every other platform (FreeBSD, OpenBSD, Solaris/illumos, armv7/riscv64/ ppc64le, any-glibc Linux, Windows Ruby 3.0) gets the source gem, which compiles the vendored engine and the extension with the installing Ruby’s own toolchain — a C compiler is required and the install fails loudly without one (the extension is the binding; there is no pure-Ruby fallback).
static, self-contained)
| Platform gem | Ruby | Notes |
|---|---|---|
x86_64-linux, aarch64-linux (fat) |
3.3–4.0 |
one ext per minor; GLIBC >= 2.14 (x86_64) / 2.17 (aarch64) |
x86_64-linux-musl, aarch64-linux-musl (fat) |
3.3–4.0 |
one ext per minor; built in Alpine containers |
arm-linux, arm-linux-musl |
3.3 |
32-bit ARM; qemu-emulated build (no PGO), single minor |
powerpc64le-linux, s390x-linux (fat) |
3.3–4.0 |
big-endian servers; qemu-emulated builds (no PGO) |
s390x-linux-musl |
3.3 |
Alpine on IBM Z; qemu-emulated (no PGO), single minor (alpine 3.4+/4.0 s390x images not published) |
x86_64-darwin, arm64-darwin (fat) |
3.3–4.0 |
one ext per minor; macOS >= 11.0 |
x64-mingw32 (fat) |
3.0 |
msvcrt-era RubyInstaller |
x64-mingw-ucrt (fat) |
3.1–4.0 |
one ext per Ruby minor |
aarch64-mingw-ucrt (fat) |
3.4–4.0 |
RubyInstaller ARM64 builds |
ruby (source) |
3.0–4.0 |
everything else — including 3.0–3.2 on fat platforms and any minor on the emulated tier; compiles at install |
A native extension binds the Ruby minor that built it, so the fat
gems ship one extension per minor under lib/teptris/<minor>/ and
the loader picks yours. On a minor a platform gem does not carry,
the LoadError names the remedy: install the source gem (gem
install teptris --platform ruby), which compiles the vendored
engine at install.
Every prebuilt gem also carries the FULL ENGINE SOURCE at
ext/teptris_ext/engine (beside the prebuilt extension): untar the
gem, cd ext/teptris_ext && ruby extconf.rb && make to rebuild the
extension against your own toolchain. The ruby-platform source
gem vendors the same tree and compiles automatically at install.
The published-gem smoke lane installs each Windows fat gem from rubygems on every released Ruby and round-trips it; mingw and darwin gems ship byte-identical Ruby sources (EOLs aside).
|
Tip
|
Musl (Alpine) note
Some rubygems versions (verified on 3.5.22) resolve the glibc gem
for musl rubies even with an explicit gem fetch teptris --platform aarch64-linux-musl # or x86_64/arm-linux-musl gem install ./teptris-*.gem --local |
rake compile # two-stage PGO build of the pinned libteptris tag + the ext bundle exec rspec
Both directions are native (C extensions over libteptris; no FFI, no fallback):
-
load: 1.6×–124× faster than tomlib on every corpus shape (mixed 3.4×, array-heavy 5.0×, scalar shapes 100×+ where tomlib degrades quadratically); 50–380× toml-rb; GC allocations at parity.
-
dump: 3×–10× faster than tomlib on every shape (through the C builder API — the engine emitter is the single formatting source).
The engine implements the TOML 1.1 draft grammar (a strict superset of
1.0: \e/\xNN escapes, optional seconds, inline-table newlines and
trailing commas) with 100% toml-test conformance, and parses at ≥3×
the best C/C++ competitor on every benchmark shape.
Full matrix: teptris benchmarks/LEDGER.md.
require "teptris"
Teptris::TOML.load("title = \"teptris\"\n[owner]\nname = \"t\"\n")
# => {"title" => "teptris", "owner" => {"name" => "t"}}
Teptris::TOML.dump({"title" => "teptris", "owner" => {"name" => "t"}})
begin
Teptris::TOML.load("a = [1,")
rescue Teptris::ParseError => e
e.line # 1
e.column # 8
endCompile a plan once, materialize only the planned keys in one native
pass (Teptris::Descriptor / Teptris::TOML.load_schema, shipped since
0.2.29; C ABI in libteptris teptris/plan.h). The walk returns a
nested Hash with String keys and already-cast Ruby values — the
shape Serializable.instantiate wants after a wire→attr rename, not
the column-array shape of Yeptris::Schema.
# MappingHash-shaped model: wire names match TOML keys.
DESC = Teptris::Descriptor.build(
children: [
{ name: "id", kind: :scalar },
{ name: "name", kind: :scalar },
{ name: "tags", kind: :collection }, # array of scalars
{ name: "items", kind: :nested, plan: { # table or [[array-of-tables]]
children: [
{ name: "sku", kind: :scalar },
{ name: "qty", kind: :scalar },
] } },
{ name: "extra", kind: :raw }, # escape hatch: full subtree
])
# compile once per model class; reuse across documents
h = Teptris::TOML.load_schema(toml_string, DESC)
# => {
# "id"=>1, "name"=>"widget", "tags"=>["a","b"],
# "items"=>[{"sku"=>"x","qty"=>2}, ...],
# "extra"=>{"whatever"=>true}, # or nil if absent
# }
# unplanned keys never appear
# lutaml-model adapter half (illustrative):
# kwargs = mapping.wire_to_attrs(h) # rename + symbolize
# Model.instantiate(**kwargs)Row kinds:
-
:scalar— any TOML value (String/Integer/Float/true/false/Time/Date) -
:collection— array of scalars -
:nested— recurse viaplan:; also spans arrays of tables -
:raw— the untouched native subtree (callback/escape hatch)
Absent planned keys read as nil. Type tags are not declared on
the Ruby surface: TOML already carries the value kind, and the walk
materializes the same datetime contract as TOML.load. Wire→attr
renames, when_attribute, and polymorphic partitions stay on the
framework side (interpretive), matching the yeptris-ruby#184 split.
load_lazy is the complementary path when the plan is not known up
front: one parse, host objects only along touched paths.
When the corpus is N TOML documents rather than one (unitsml-style streams, config splits, log shards), accept an Array (or list of file paths) and ship one C call:
# Eager: one Array of Hashes, same datetime contract as TOML.load
docs = Teptris::TOML.load_batch(toml_strings,
safe_load: [Time, Date],
datetime_policy: :native)
# File paths
docs = Teptris::TOML.load_files(["a.toml", "b.toml", "c.toml"])
# Lazy twin: parse N eagerly, materialize only along touched paths
arr = Teptris::TOML.load_lazy_batch(toml_strings)
arr.each { |doc| doc["x"].value } # touches one path per docPerf shape (best-of-N on ubuntu-latest, ruby 3.3, 2026-09-21):
eager load_batch is a wash or slower than per-doc load for tiny /
medium docs (Ruby’s per-doc dispatch is amortized by JIT; the batch’s
C-side scratch + tight Ruby allocation loses on those shapes) and a
clear win for large docs. The shared-options parsing (one safe_load /
datetime_policy check per call) and per-doc error report (a single
ParseError carrying the failing doc’s line/column) make eager
load_batch a useful API even where it isn’t a speedup.
load_lazy_batch is the real many-small speedup when materialization
is deferred — parse-only across the batch, no per-doc Hash work.
Gem publishing and version numbers are USER release decisions.