RustyJson is a Rust-backed JSON library for Elixir. Its common encode and decode calls follow Jason, so most call sites can move over with a module-name change. It exists for applications where JSON has become measurable work.
Start with Jason
Jason is a good default for most Elixir applications. It is pure Elixir and keeps builds and deployments simple. If Jason already meets your latency and memory needs, keep using it.
RustyJson is useful when profiling points at JSON: multi-megabyte API responses, large exports, ingestion pipelines, or high volumes of repeated records. It moves parsing and serialization into Rust and reads or writes BEAM terms without first building an intermediate Rust representation.
On this repository's Apple M1 Pro benchmarks, encoding a roughly 10 MB payload is about 5-6x faster than Jason and uses 2-3x less total memory. Large decodes are about 2.4-3.5x faster. Smaller payloads show smaller gains. These results explain why the library exists, but they are not a promise for every application. Benchmark the payloads that matter to you.
Deliberate tradeoffs
RustyJson is a native dependency. Precompiled binaries cover the supported
targets, while source builds require nightly Rust. RustyJson's own Rust source
contains no unsafe blocks, which reduces the risk of memory-unsafe bugs in the
library. Your application still loads a NIF and relies on native dependencies. If
that operational cost outweighs the measured gain, Jason is the better fit.
RustyJson also takes a stricter position on JSON keys. It keeps decoded keys as
strings and leaves out the modes that create atoms dynamically from JSON input.
Code that relied on keys: :atoms or the lowercase a sigil modifier must change.
Why RustyJson keeps decoded keys as strings explains the
reason and the practical effect on evolving APIs.
Unknown options and invalid option values raise instead of being ignored. Custom
structs require a RustyJson.Encoder implementation; RustyJson does not fall back
to Jason.Encoder or Elixir's JSON.Encoder. These choices make mistakes visible
at the call site, but they mean compatibility is deliberate rather than absolute.
Installation
def deps do
[{:rustyjson, "~> 0.4"}]
endPrebuilt binaries are provided through Rustler Precompiled
for the supported targets and NIF versions. On x86_64, the build selects an AVX2
variant when the host CPU supports it. To build from source, set
FORCE_RUSTYJSON_BUILD=true.
Using the API
The common encode/decode calls mirror Jason:
# Core calls match Jason
RustyJson.encode(term) # => {:ok, json} | {:error, reason}
RustyJson.encode!(term) # => json | raises
RustyJson.decode(json) # => {:ok, term} | {:error, reason}
RustyJson.decode!(json) # => term | raises
# Phoenix interface
RustyJson.encode_to_iodata(term)
RustyJson.encode_to_iodata!(term)
# Pretty-print when needed
RustyJson.encode!(data, pretty: true)Phoenix integration
# config/config.exs
config :phoenix, :json_library, RustyJsonWhen Phoenix LiveView 1.2+ is present, RustyJson encodes
%Phoenix.LiveView.JS{} through Phoenix.LiveView.JS.to_encodable/1 without
declaring Phoenix or LiveView as package dependencies. RustyJson still requires
explicit RustyJson.Encoder implementations for other custom structs; it does
not fall back to Jason.Encoder or Elixir's JSON.Encoder.
Moving from Jason
Most call sites can change Jason to RustyJson directly:
# Before
@derive {Jason.Encoder, only: [:name, :email]}
Jason.encode!(data)
Jason.Fragment.new(json)
# After
@derive {RustyJson.Encoder, only: [:name, :email]}
RustyJson.encode!(data)
RustyJson.Fragment.new(json)Upgrading from RustyJson 0.3
RustyJson 0.4 removes APIs that create atoms dynamically and rejects invalid options:
- Replace
keys: :atomswith string keys (the default) orkeys: :atoms!when every key atom already exists. - Replace lowercase
aon~j/~Jwith no modifier for strings or uppercaseAfor existing atoms. - Remove unknown encode/decode options.
:protocol,:lean,:sort_keys, and:validate_stringsrequire booleans;:decoding_integer_digit_limit,:max_bytes, and:dirty_thresholdrequire non-negative integers. Invalid:prettyvalues or nested keys also raiseArgumentError.
Benchmarks
These results were measured on an Apple M1 Pro. They show where RustyJson's architecture helps, but payload shape and hardware matter. Run the benchmark suite against your own data before choosing a library for performance alone.
Encoding
| Dataset | RustyJson | Jason | Speed | Memory |
|---|---|---|---|---|
| Settlement report (10 MB) | 24 ms | 131 ms | 5.5x faster | 2-3x less |
| canada.json (2.1 MB) | 6 ms | 18 ms | 3x faster | 2-3x less |
| twitter.json (617 KB) | 1.2 ms | 3.5 ms | 2.9x faster | similar |
Decoding
| Dataset | RustyJson | Jason | Speed |
|---|---|---|---|
| Settlement report (10 MB) | 61 ms | 152 ms | 2.5x faster |
| canada.json (2.1 MB) | 8 ms | 29 ms | 3.5x faster |
Both libraries produce the same Elixir data structures, so their decoded results use similar amounts of memory.
Repeated keys in API responses
API responses and database results often contain arrays of objects with the same
keys. keys: :intern caches those key strings during parsing:
# API response: 10,000 users with {id, name, email, created_at}
RustyJson.decode!(json, keys: :intern) # ~30% fasterThis allocates each repeated key once and reuses it across the array. The option is slower for a single object or varied schemas because it pays cache overhead without reusing entries. Use it when you know the payload contains at least 10 similarly shaped objects.
BEAM reductions and scheduler behavior
# Reductions (BEAM work units) for encoding 10 MB settlement report:
RustyJson.encode!(data) # 404 reductions
Jason.encode!(data) # 11,570,847 reductionsRustyJson uses roughly 28,000x fewer reductions in this test because the NIF does
most of the work. The CPU work still exists. Decodes of 100 KB or more use a dirty
scheduler by default. Encoding uses a dirty scheduler automatically for gzip
compression; for a large uncompressed encode, pass scheduler: :dirty when
blocking a normal scheduler is a concern.
See docs/BENCHMARKS.md for detailed methodology.
API reference
Built-in types
RustyJson handles these types in Rust without protocol overhead:
| Type | JSON Output |
|---|---|
DateTime | "2024-01-15T14:30:00Z" |
NaiveDateTime | "2024-01-15T14:30:00" |
Date | "2024-01-15" |
Time | "14:30:00" |
Decimal | "123.45" |
URI | "https://example.com" |
| Structs | Object without __struct__ |
| Tuples | Arrays |
Note:
MapSetandRangeare not encoded by default. They raiseProtocol.UndefinedErrorwithprotocol: true(the default), matching Jason's behavior. Useprotocol: falseto encode them via the Rust NIF directly (MapSet→ array,Range→ object), or add an explicitRustyJson.Encoderimpl.
Fragments
Use a fragment to inject JSON that has already been encoded:
fragment = RustyJson.Fragment.new(~s({"pre":"encoded"}))
RustyJson.encode!(%{data: fragment})
# => {"data":{"pre":"encoded"}}Formatter
Pretty-print or minimize a JSON string:
RustyJson.Formatter.pretty_print(json_string)
RustyJson.Formatter.minimize(json_string)Common options
The RustyJson module documentation contains the complete option list.
Encoding:
pretty: true | integer- Pretty print with indentationescape: :json | :html_safe | :javascript_safe | :unicode_safe- Escape modecompress: :gzip | {:gzip, 0..9}- Gzip compressionlean: true- Skip special type handling for max speedprotocol: true- Enable customRustyJson.Encoderprotocolsort_keys: true- Sort map keys lexicographically (useful for snapshot tests, caching, diffing)scheduler: :auto | :normal | :dirty- Choose where encoding NIF work runs
Decoding:
keys: :strings | :atoms! | :copy | :intern | function- Key handling (:atomsis intentionally unsupported):intern- ~30% faster for arrays of objects (REST APIs, GraphQL, DB results, webhooks)
dirty_threshold: non-negative integer- Input size that moves decoding to a dirty schedulermax_bytes: non-negative integer- Reject inputs larger than the given byte limit
Custom encoders
For custom types, implement the RustyJson.Encoder protocol and use protocol: true:
defimpl RustyJson.Encoder, for: Money do
def encode(%Money{amount: amount, currency: currency}, _opts) do
RustyJson.Encode.map(%{amount: Decimal.to_string(amount), currency: currency}, _opts)
end
end
RustyJson.encode!(money)Or use @derive:
defmodule User do
@derive {RustyJson.Encoder, only: [:name, :email]}
defstruct [:name, :email, :password_hash]
endJSON compliance
RustyJson passes all 283 mandatory cases from JSONTestSuite:
- 95/95
y_tests (must accept) - 188/188
n_tests (must reject) - Rejects lone surrogates per RFC 7493 I-JSON
Run mix test test/json_test_suite_test.exs to validate compliance (downloads test fixtures on first run).
See docs/ARCHITECTURE.md for detailed compliance information.
Error handling
Data errors return exception structs with a message and, for decode errors, a byte position:
# Decode errors include a byte position
{:error, error} = RustyJson.decode(~s({"key": "value\\'s"}))
error.message
# => "Invalid escape sequence: \\' at position 8"
# Unencodable values return error tuples, not exceptions
{:error, error} = RustyJson.encode(%{{:tuple, :key} => 1})
error.message
# => "Map key must be atom, string, or integer"
# Strict UTF-16 surrogate validation per RFC 7493
{:error, error} = RustyJson.decode(~s("\\uD800"))
error.message
# => "Lone surrogate in string at position 0"encode/1 and decode/1 return {:error, exception} tuples for invalid data.
Invalid options raise ArgumentError, keeping configuration mistakes separate
from data errors.
How RustyJson reduces work
For encoding, a serde-based NIF normally converts data in three stages:
- Erlang term → Rust struct (allocation)
- Rust struct → JSON bytes (allocation)
- JSON bytes → Erlang binary (allocation)
For encoding, RustyJson walks the Erlang term tree and writes JSON bytes directly. For decoding, it builds BEAM terms while parsing instead of creating an intermediate Rust syntax tree.
Common Elixir types such as DateTime, Decimal, and URI pass through the
encoder protocol unchanged and are formatted in Rust. Custom types still use the
Elixir encoder protocol. MapSet and Range require protocol: false or an
explicit encoder implementation.
Direct encoder
- Walks Erlang terms directly via Rustler's term API
- Writes to a single buffer without intermediate allocations
- Uses itoa and ryu for fast number formatting
- SIMD-accelerated escape scanning (16 bytes/iter, 32 bytes/iter on AVX2)
- 256-byte lookup table for O(1) escape detection
Direct decoder
- Parses JSON while building Erlang terms (no intermediate AST)
- SIMD-accelerated string scanning, whitespace skipping, and structural character indexing
- Zero-copy strings for unescaped content
- Single-entry fast path for objects and arrays (avoids heap allocation for deeply nested JSON)
- lexical-core for fast number parsing
Portable SIMD
- All SIMD uses Rust's
std::simd, with one code path per pattern and nounsafeblocks - The compiler generates optimal instructions for each target: SSE2 on x86_64, NEON on aarch64, scalar on others
- AVX2 precompiled variants use 32-byte wide paths for additional throughput on Haswell+ CPUs
Allocator
RustyJson uses mimalloc by default.
Source builds can select jemalloc or snmalloc in Cargo.toml:
[features]
default = ["mimalloc"]
# Or: "jemalloc", "snmalloc"Limits
- JSON nesting is limited to 128 levels.
- Precompiled binaries cover the supported targets. Other targets need a source build.
- Source builds require nightly Rust for
#![feature(portable_simd)].
Acknowledgments
- Rustler - Erlang NIF bindings for Rust
- Jason - API design and behavior reference
- Original Jsonrs - Initial inspiration