High-performance JSON library for Elixir and Erlang via Rustler NIFs, powered by sonic-rs (SIMD-accelerated).

Torque provides the fastest JSON encoding and decoding available in the BEAM ecosystem, with a selective field extraction API for workloads that only need a subset of fields from each document.

Documentation: hexdocs.pm/torque (Elixir Torque and Erlang torque APIs).

Features

  • SIMD-accelerated decoding (AVX2 on x86, NEON on ARM)
  • Ultra-low memory encoder (64 B per encode vs ~4 KB for OTP json/jason)
  • Parse-then-get API for selective field extraction via JSON Pointer (RFC 6901, with one documented deviation: "/" selects the root, not the empty key)
  • Batch field extraction (get_many/2) with single NIF call
  • Pre-compiled pointers with fused parse + extract (parse_get_many_nil/2)
  • Automatic dirty CPU scheduler dispatch for decode/parse inputs larger than 20 KB (opt-in dirty: true for encode)
  • jiffy-compatible {proplist} encoding
  • Opt-in Torque.Encoder protocol for encoding structs, with @derive

Installation

Add to your mix.exs:

def deps do
  [
    {:torque, "~> 0.5.1"}
  ]
end

or, with rebar3, to your rebar.config (see Erlang):

{deps, [{torque, "~> 0.5.1"}]}.

Precompiled binaries are available for macOS and glibc Linux on aarch64 and x86_64 (with CPU-optimized variants on x86_64, below). Anything else, such as musl (Alpine) or Windows, builds from source: install a stable Rust toolchain and set TORQUE_BUILD=true.

A source build targets the platform's baseline CPU, so the binary runs on any machine of that architecture. On x86_64 that baseline is SSE2, which leaves the AVX2 parser paths out: set RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq" to match the precompiled v3 variant, or -C target-cpu=native only when the binary will run on the machine that built it.

CPU-optimized variants

On x86_64, precompiled binaries are available for three CPU feature levels:

VariantCPU featurestarget-cpu
baselineSSE2x86-64
v2SSE4.2, SSSE3, POPCNTx86-64-v2
v3AVX2, AVX, BMI1, BMI2, FMA, PCLMULQDQx86-64-v3 + pclmulqdq

At compile time, Torque auto-detects the host CPU and downloads the best matching variant, checked against the checksums shipped in the package and cached in the user cache directory. To override detection (e.g., when cross-compiling for a different target):

TORQUE_CPU_VARIANT=v2 mix compile  # force SSE4.2 variant
TORQUE_CPU_VARIANT=v3 mix compile  # force AVX2 variant
TORQUE_CPU_VARIANT=base mix compile  # force baseline

To build on one platform for another (a release built on macOS for Linux, say), set TORQUE_NIF_TARGET to the target's triple, e.g. x86_64-unknown-linux-gnu.

Usage

Decoding

{:ok, data} = Torque.decode(~s({"name":"Alice","age":30}))
# %{"name" => "Alice", "age" => 30}

data = Torque.decode!(json)

Selective Field Extraction

Parse once, extract many fields without building the full Elixir term tree:

{:ok, doc} = Torque.parse(json)

{:ok, "example.com"} = Torque.get(doc, "/site/domain")
nil = Torque.get(doc, "/missing/field", nil)

# Batch extraction (single NIF call, fastest path)
results = Torque.get_many(doc, ["/id", "/site/domain", "/device/ip"])
# [{:ok, "req-1"}, {:ok, "example.com"}, {:ok, "1.2.3.4"}]

When your JSON is known to have no duplicate object keys, pass unique_keys: true for faster field lookups (uses sonic-rs internal indexing instead of linear scan):

{:ok, doc} = Torque.parse(json, unique_keys: true)

Compiled Pointers

When the same fixed set of paths is extracted from every document, compile the pointers once and reuse the handle. parse_get_many_nil/2 then reads the document in a single pass, building values only where a path ends and skipping everything else, without building an intermediate document. Against parse/2 + get_many_nil/2 with the same handle, on PGO builds:

arm64 (M1 Pro)x86_64 (Xeon E5-2630 v3)
1.2 KB bid request, all 51 fields~3× faster~3.6× faster
1.2 KB bid request, 3 fields~1.3× faster~3× faster
776 KB feed, 3 fieldsparity~3.2× faster
# Once, at startup (e.g. into :persistent_term or application state; the
# handle is a NIF resource, so it cannot live in a module attribute):
pointers = Torque.compile_pointers(["/id", "/site/domain", "/imp/0/banner/w"], unique_keys: true)

# Per document — parse + extract in one call:
{:ok, ["req-1", "example.com", 300]} = Torque.parse_get_many_nil(json, pointers)

Missing fields and JSON null both become nil. The handle also works with an already-parsed document via Torque.get_many_nil(doc, pointers).

By default a malformed document is reported wherever the fault is, as parse/2 would report it, even in a region no path selects. validate: false skips unselected regions with a structural bracket scan instead of tokenizing them, but a malformed number, literal, or separator inside one of them goes unreported, and so does anything after the document, which is therefore not UTF-8 checked either. Truncated input, invalid UTF-8 in any byte the walk consumed, and errors in selected values are still rejected. Use it only with trusted input.

It is not a free speed-up. A bracket scan over 64-byte blocks beats tokenizing a large subtree and loses to it on the few-byte scalars a dense path set leaves behind, so the win tracks how little of the document the paths select. Against a validated handle, on both machines, 3 paths run ~1.9× faster unvalidated on the 1.2 KB request and ~4× faster on the 776 KB feed, while all 51 fields of the request run 3-7% slower. Measure your own path set.

pointers = Torque.compile_pointers(paths, unique_keys: true, validate: false)

Encoding

# Maps with atom or binary keys
{:ok, json} = Torque.encode(%{id: "abc", price: 1.5})
# "{\"id\":\"abc\",\"price\":1.5}"

# Integer keys are stringified — JSON object names must be strings
{:ok, json} = Torque.encode(%{0 => "a", 1 => "b"})
# "{\"0\":\"a\",\"1\":\"b\"}"

# Bang variant
json = Torque.encode!(%{id: "abc"})

# iodata variant (fastest, no {:ok, ...} tuple wrapping)
json = Torque.encode_to_iodata(%{id: "abc"})

# jiffy-compatible proplist format
{:ok, json} = Torque.encode({[{:id, "abc"}, {:price, 1.5}]})

Structs are rejected with {:error, :unhandled_struct} unless they implement Torque.Encoder. Implement the protocol for custom types, or derive it to encode a subset of fields:

defimpl Torque.Encoder, for: Decimal do
  def encode(decimal), do: Decimal.to_string(decimal)
end

# or, on the struct itself:
@derive {Torque.Encoder, only: [:id, :name]}
defstruct [:id, :name, :secret]

Date, Time, NaiveDateTime, and DateTime ship with implementations and encode as ISO 8601 strings.

Breaking change in 0.4.0. Structs previously encoded as raw maps, leaking the struct marker into the output: ~D[2026-09-14] produced {"calendar":"Elixir.Calendar.ISO","month":9,"__struct__":"Elixir.Date",...}. They now error unless the protocol is implemented.

Unlike decoding, encoding cannot cheaply predict its output size, so dirty scheduler dispatch is opt-in. Pass dirty: true (accepted by encode/2, encode!/2, encode_to_iodata/2, and encode_to_iodata!/2) when terms are expected to encode to large output (more than roughly 20 KB):

{:ok, json} = Torque.encode(big_term, dirty: true)

Using with Phoenix, Plug and Postgrex

Torque provides the functions these libraries call on a JSON module (encode_to_iodata!/1, encode!/1, decode!/1), so it can replace Jason in their configuration:

# config/config.exs
config :phoenix, :json_library, Torque
config :postgrex, :json_library, Torque

# endpoint.ex
plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json],
  json_decoder: Torque

Structs, Ecto schemas included, must implement Torque.Encoder (for example @derive {Torque.Encoder, only: [:id, :name]}): a struct without an implementation raises rather than encoding its raw fields.

Erlang

The torque module is the Erlang API. It follows Erlang conventions: JSON null is null, and the bulk lookups return undefined for a path the document does not contain, so it stays distinct from a JSON null. Results are {ok, _} | {error, _} tuples and options are proplists.

{ok, #{<<"a">> := null}} = torque:decode(<<"{\"a\":null}">>),
{ok, <<"{\"a\":null}">>} = torque:encode(#{a => null}),

{ok, Doc} = torque:parse(Json),
{ok, Domain} = torque:get(Doc, <<"/site/domain">>),
[Id, undefined] = torque:get_many_values(Doc, [<<"/id">>, <<"/missing">>]),

%% Compile fixed paths once, then extract them in one pass per document:
Pointers = torque:compile_pointers([<<"/id">>, <<"/site/domain">>], [{unique_keys, true}]),
{ok, [Id, Domain]} = torque:parse_get_many_values(Json, Pointers).

The Erlang API guide documents every function and option.

A rebar3 build fetches the precompiled NIF for the platform from the GitHub release, checks it against the checksums shipped in the package, and caches it in the user cache directory. That needs OTP 25 or later; with TORQUE_BUILD=true the NIF is built with cargo instead, which works on any platform. TORQUE_CPU_VARIANT picks the x86_64 variant as it does for Mix.

API

FunctionDescription
Torque.compile_pointers(paths, opts)Pre-compile a fixed path set into a reusable handle
Torque.decode(binary, opts)Decode JSON to Elixir terms (strings: :copy to detach strings from the input)
Torque.decode!(binary, opts)Decode JSON, raising on error
Torque.encode(term, opts)Encode term to JSON binary
Torque.encode!(term, opts)Encode term, raising on error
Torque.encode_to_iodata(term, opts)Encode term, returns binary directly (fastest)
Torque.encode_to_iodata!(term, opts)Alias for encode_to_iodata/2 (Phoenix :json_library)
Torque.get(doc, path)Extract field by JSON Pointer path
Torque.get(doc, path, default)Extract field with default for missing paths
Torque.get_many(doc, paths)Extract multiple fields in one NIF call
Torque.get_many_nil(doc, paths)Extract multiple fields, nil for missing
Torque.get_many_defaults(doc, defaults)Extract fields with per-path defaults (%{path => default})
Torque.length(doc, path)Return length of array at path
Torque.parse(binary, opts)Parse JSON into opaque document reference
Torque.parse_get_many_nil(binary, pointers)Fused parse + extract of compiled pointers in one NIF call

Type Conversion

JSON to Elixir

JSONElixir
objectmap (binary keys)
arraylist
stringbinary
integerinteger
floatfloat
true, falsetrue, false
nullnil

For objects with duplicate keys, the last value wins (unless unique_keys: true is passed to parse/2).

Integers outside the signed/unsigned 64-bit range decode as exact arbitrary-precision integers (Erlang bignums) rather than degrading to lossy floats, from decode/1 and from every get and parse_get_many_nil lookup alike. One exception: a compile_pointers/2 handle with the default validate: true rejects an integer beyond the f64 range (about 1.8e308) in a region no path selects.

Elixir to JSON

ElixirJSON
map (atom/binary/integer keys)object
listarray
binarystring
integernumber
floatnumber
true, falsetrue, false
nilnull
atomstring
{keyword_list}object
struct implementing Torque.Encoderwhatever encode/1 returns

Errors

Functions return {:error, reason} tuples (or raise ArgumentError for bang/iodata variants). Possible reason atoms:

Decode / Parse

AtomReturned byMeaning
:nesting_too_deepdecode/1, parse/1, get/2, get_many/2, parse_get_many_nil/2Document exceeds 128 nesting levels

parse/1, decode/1, and parse_get_many_nil/2 also return {:error, binary} with a message from sonic-rs for malformed JSON.

Encode

AtomReturned byMeaning
:unsupported_typeencode/1Term has no JSON representation (PID, reference, port, …)
:invalid_utf8encode/1Binary string or map key is not valid UTF-8
:invalid_keyencode/1Map key is not an atom, binary, or integer (e.g. float or tuple key)
:malformed_proplistencode/1{proplist} contains a non-{key, value} element
:non_finite_floatencode/1Float is infinity or NaN (unreachable from normal BEAM code)
:nesting_too_deepencode/1Term exceeds 128 nesting levels
:unhandled_structencode/1Struct has no Torque.Encoder implementation
:encoder_expansion_too_deepencode/1A Torque.Encoder implementation expands the same struct again, or structs nest past 128 levels

Benchmarks

Per-commit trends and the full cross-library comparison are published at lpgauth.github.io/torque/dev/bench.

Every table below comes from one run of bench/torque_bench.exs, against glazer 1.1.5, on each of two machines:

  • arm64: Apple M1 Pro, macOS, OTP 29, Elixir 1.20.3, Apple clang 21, rustc 1.98.1
  • x86_64: Intel Xeon E5-2630 v3 (Haswell, 2.4 GHz), Ubuntu 22.04 container pinned to one core of a shared server, OTP 29, Elixir 1.20.2, GCC 13.4, rustc 1.99.0

Both libraries are profile-guided optimised (PGO) builds on both machines: Torque PGO (via scripts/pgo-build.sh, which builds with -C target-cpu=native) and Glazer PGO (via make -C deps/glazer/c_src PGO=generate, the workload in bench/glazer_pgo_workload.exs, then PGO=use). Glazer's Makefile writes that flow for GCC; under clang the raw counters need an explicit llvm-profdata merge -o obj/pgo/default.profdata obj/pgo/*.profraw between those two steps.

glazer is benchmarked with UTF-8 validation enabled (validate_utf8 on decode, force_utf8 on encode — both off by default in glazer) so every library provides the same guarantee Torque always does: JSON strings are valid UTF-8.

Decode (1.2 KB OpenRTB)

arm64

Libraryipsmeanmedianp99memory
torque403.3K2.48 μs2.38 μs2.79 μs1.56 KB
glazer351.5K2.85 μs2.75 μs3.67 μs1.56 KB
jiffy205.2K4.87 μs4.54 μs8.25 μs1.55 KB
otp json138.8K7.20 μs7.00 μs10.71 μs7.73 KB
jason103.8K9.63 μs9.13 μs15.91 μs9.54 KB

x86_64

Libraryipsmeanmedianp99memory
torque170.8K5.85 μs5.64 μs9.31 μs1.56 KB
glazer167.5K5.97 μs5.71 μs10.04 μs1.56 KB
jiffy90.6K11.03 μs9.57 μs21.12 μs1.55 KB
otp json60.1K16.63 μs16.11 μs24.10 μs7.73 KB
jason53.0K18.87 μs18.27 μs28.57 μs9.46 KB

Decode (750 KB Twitter)

arm64

Libraryipsmeanmedianp99memory
torque740.91.35 ms1.24 ms1.71 ms1.57 KB
glazer611.91.63 ms1.55 ms2.06 ms1.58 KB
jiffy298.13.35 ms3.47 ms3.81 ms2.30 MB
otp json205.24.87 ms4.93 ms5.59 ms2.48 MB
jason143.26.99 ms6.95 ms7.39 ms3.54 MB

x86_64

Libraryipsmeanmedianp99memory
torque357.22.80 ms2.81 ms3.68 ms1.57 KB
glazer344.12.91 ms2.91 ms3.73 ms1.58 KB
jiffy146.56.82 ms6.14 ms8.71 ms2.30 MB
otp json88.611.29 ms11.41 ms14.06 ms2.48 MB
jason70.814.11 ms13.99 ms16.97 ms3.54 MB

Encode (1.2 KB OpenRTB)

Function is the call benchmarked. Torque's encode!/1 raises on error and encode_to_iodata/1 returns the same binary without the {:ok, _} tuple; Jason's encode_to_iodata!/1, jiffy and json:encode/1 return iodata. Input is the term encoded: this payload is a bid response with atom keys, the 750 KB one below is decoded JSON, so its keys are binaries.

arm64

LibraryFunctionInputipsmeanmedianp99memory
torqueencode_to_iodata/1proplist, atom keys2390K0.42 μs0.33 μs0.46 μs64 B
torqueencode!/1map, atom keys2200K0.45 μs0.42 μs0.50 μs88 B
torqueencode!/1proplist, atom keys2150K0.46 μs0.38 μs0.50 μs88 B
torqueencode_to_iodata/1map, atom keys2150K0.46 μs0.38 μs0.50 μs64 B
otp jsonjson:encode/1map, atom keys1120K0.89 μs0.83 μs1.12 μs3.84 KB
glazerencode/2map, atom keys1100K0.91 μs0.83 μs1.00 μs64 B
jiffyencode/2proplist, atom keys860K1.16 μs1.04 μs1.21 μs120 B
jiffyencode/1map, atom keys680K1.46 μs1.33 μs1.54 μs632 B
jasonencode_to_iodata!/1map, atom keys590K1.69 μs1.62 μs2.42 μs3.76 KB
jasonencode!/1map, atom keys370K2.69 μs2.50 μs4.08 μs3.82 KB

x86_64

LibraryFunctionInputipsmeanmedianp99memory
torqueencode_to_iodata/1proplist, atom keys1015K0.99 μs0.85 μs1.18 μs64 B
torqueencode!/1proplist, atom keys992.8K1.01 μs0.87 μs1.23 μs88 B
torqueencode_to_iodata/1map, atom keys923.5K1.08 μs0.95 μs1.22 μs64 B
torqueencode!/1map, atom keys911.3K1.10 μs0.97 μs1.24 μs88 B
glazerencode/2map, atom keys470.8K2.12 μs1.95 μs2.68 μs64 B
otp jsonjson:encode/1map, atom keys433.3K2.31 μs2.02 μs3.42 μs3.84 KB
jiffyencode/2proplist, atom keys401.9K2.49 μs2.14 μs3.19 μs120 B
jiffyencode/1map, atom keys332.4K3.01 μs2.66 μs3.86 μs632 B
jasonencode_to_iodata!/1map, atom keys225.4K4.44 μs3.51 μs7.00 μs3.76 KB
jasonencode!/1map, atom keys171.4K5.83 μs5.10 μs10.22 μs3.82 KB

Encode (750 KB Twitter)

arm64

LibraryFunctionInputipsmeanmedianp99memory
torqueencode!/1proplist, binary keys1646.10.61 ms0.59 ms0.69 ms88 B
torqueencode_to_iodata/1proplist, binary keys1639.80.61 ms0.60 ms0.68 ms64 B
torqueencode_to_iodata/1map, binary keys1517.40.66 ms0.65 ms0.73 ms64 B
torqueencode!/1map, binary keys1514.00.66 ms0.64 ms0.77 ms88 B
glazerencode/2map, binary keys873.81.14 ms1.13 ms1.32 ms64 B
jiffyencode/1proplist, binary keys610.01.64 ms1.62 ms1.86 ms2.97 KB
jiffyencode/1map, binary keys463.62.16 ms2.05 ms2.66 ms803.19 KB
otp jsonjson:encode/1map, binary keys259.13.86 ms4.23 ms5.08 ms5.40 MB
jasonencode_to_iodata!/1map, binary keys249.34.01 ms3.71 ms5.98 ms4.96 MB
jasonencode!/1map, binary keys134.27.45 ms7.31 ms8.52 ms4.96 MB

x86_64

LibraryFunctionInputipsmeanmedianp99memory
torqueencode!/1proplist, binary keys786.21.27 ms1.34 ms2.30 ms88 B
torqueencode_to_iodata/1proplist, binary keys765.61.31 ms1.35 ms2.35 ms64 B
torqueencode_to_iodata/1map, binary keys650.31.54 ms1.58 ms2.53 ms64 B
torqueencode!/1map, binary keys649.21.54 ms1.58 ms2.54 ms88 B
jiffyencode/1proplist, binary keys376.82.65 ms2.52 ms3.53 ms2.97 KB
glazerencode/2map, binary keys368.22.72 ms2.79 ms3.59 ms64 B
jiffyencode/1map, binary keys256.33.90 ms3.68 ms5.45 ms803.19 KB
otp jsonjson:encode/1map, binary keys108.29.25 ms8.60 ms11.93 ms5.40 MB
jasonencode_to_iodata!/1map, binary keys73.413.62 ms13.64 ms15.42 ms4.96 MB
jasonencode!/1map, binary keys58.417.12 ms17.22 ms19.14 ms4.96 MB

Parse (1.2 KB OpenRTB)

arm64

Libraryipsmeanmedianp99
torque parse616.3K1.62 μs1.29 μs2.83 μs
torque parse(unique_keys)597.1K1.67 μs1.33 μs3.08 μs

x86_64

Libraryipsmeanmedianp99
torque parse243.3K4.11 μs3.31 μs6.96 μs
torque parse(unique_keys)232.6K4.30 μs3.43 μs6.48 μs

Extract 5 fields from raw JSON (1.2 KB OpenRTB)

End-to-end cost of pulling 5 fields out of a JSON blob: parse + get (torque) vs decode + find (glazer has no lazy handle, so it must fully decode first). This is the apples-to-apples version of "get" — torque's selective extraction skips materializing the whole document.

parse_get_many_nil goes further. Given a handle compiled once at startup (like glazer's compiled jq paths), it walks the document a single time and builds a value only where a path ends, so no document is built at all. validate: false also skips validating the regions no path selects, which on a document this small is most of what is left.

arm64

Libraryipsmeanmedianp99
torque parse_get_many_nil unique_keys validate: false1339K0.75 μs0.71 μs0.83 μs
torque parse_get_many_nil unique_keys713.4K1.40 μs1.38 μs1.50 μs
torque parse_get_many_nil709.8K1.41 μs1.38 μs1.54 μs
torque parse(unique_keys) + get_many514.8K1.94 μs1.75 μs3.29 μs
torque parse + get x5486.6K2.05 μs1.83 μs3.79 μs
torque parse + get_many485.5K2.06 μs1.71 μs3.33 μs
glazer decode + find x5316.4K3.16 μs3.08 μs4.04 μs

x86_64

Libraryipsmeanmedianp99
torque parse_get_many_nil unique_keys validate: false917.7K1.09 μs1.00 μs1.63 μs
torque parse_get_many_nil532.9K1.88 μs1.78 μs2.41 μs
torque parse_get_many_nil unique_keys532.8K1.88 μs1.77 μs2.42 μs
torque parse(unique_keys) + get_many206.7K4.84 μs3.96 μs12.93 μs
torque parse + get x5204.5K4.89 μs4.22 μs9.29 μs
torque parse + get_many198.0K5.05 μs3.88 μs8.12 μs
glazer decode + find x5149.9K6.67 μs6.45 μs10.81 μs

Run benchmarks locally:

MIX_ENV=bench mix run bench/torque_bench.exs

Limitations

  • Integer map keys are lossy: JSON object names must be strings (RFC 8259 §4), so encode/1 stringifies integer keys and decode/1 gives them back as binaries — %{1 => "a"} round-trips to %{"1" => "a"}. A map mixing both forms, like %{1 => "a", "1" => "b"}, encodes to duplicate names ({"1":"a","1":"b"}); RFC 8259 says names should be unique, and decoders resolve the collision however they choose. Jason behaves identically.
  • Nesting depth: JSON documents nested deeper than 128 levels return {:error, :nesting_too_deep} from decode/1, parse/1, get/2, get_many/2, and encode/1 rather than crashing the VM. Real-world documents are never this deep; the limit exists to prevent stack overflow in the NIF (the dirty CPU scheduler, used for inputs over 20 KB, has a small stack).

License

MIT