RustyJson
A JSON library for Elixir powered by Rust NIFs, designed as a drop-in replacement for Jason.
Why RustyJson?
The Problem: JSON encoding in Elixir can be memory-intensive. When encoding large data structures, Jason (and other pure-Elixir encoders) create many intermediate binary allocations that pressure the garbage collector. For high-throughput applications processing large JSON payloads, this memory overhead becomes significant.
Why not existing Rust JSON NIFs? After OTP 24, Erlang's binary handling improved significantly, closing the performance gap between NIFs and pure-Elixir implementations. Libraries like jiffy and the original jsonrs struggled to outperform Jason on modern BEAM versions. Additionally, the original jsonrs is incompatible with Rustler 0.37+, which is required by many other packages.
RustyJson's approach: Rather than trying to beat Jason on speed alone, RustyJson focuses on:
- Dramatically lower memory usage during encoding (10-20x reduction for large payloads)
- Competitive encoding speed (3-6x faster for medium/large data)
- Full Jason API compatibility as a true drop-in replacement
- Modern Rustler 0.37+ support for compatibility with the ecosystem
Installation
def deps do
[{:rustyjson, "~> 0.1"}]
end
Pre-built binaries are provided via Rustler Precompiled. To build from source, set FORCE_RUSTYJSON_BUILD=true.
Drop-in Jason Replacement
RustyJson implements the same API as Jason:
# These work identically to 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)
# Options match Jason
RustyJson.encode!(data, pretty: true)
RustyJson.decode!(json, keys: :atoms)
Phoenix Integration
# config/config.exs
config :phoenix, :json_library, RustyJson
Migrating from Jason
Find/replace Jason → RustyJson in your codebase:
# 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)
Fragments
Inject pre-encoded JSON directly:
fragment = RustyJson.Fragment.new(~s({"pre":"encoded"}))
RustyJson.encode!(%{data: fragment})
# => {"data":{"pre":"encoded"}}
Formatter
Pretty-print or minify JSON strings:
RustyJson.Formatter.pretty_print(json_string)
RustyJson.Formatter.minify(json_string)
Benchmarks
All benchmarks run on Apple Silicon M1. Results may vary on other architectures.
Synthetic Benchmarks
| Payload | Encoding | Decoding |
|---|---|---|
| Small (~25 bytes) | ~1x | 1.3x faster |
| Medium (~7 KB) | 3-6x faster | 2x faster |
| Large (~500 KB) | 3-4x faster | 1.2x faster |
Note: Small payloads show minimal difference due to NIF call overhead.
Real-World Benchmark: Amazon Settlement Reports
Processing 31 settlement reports (TSV → parsed data → JSON files) with reports containing 4 to 15,820 rows each:
Example: 13,073-row report (2.1 MB download)
| Metric | Jason | RustyJson | Improvement |
|---|---|---|---|
| Save JSON time | 1,556 ms | 70 ms | 22x faster |
| Memory (Save JSON) | +146.8 MB | +6.7 MB | 22x less |
| Total memory | +162.3 MB | +22.4 MB | 7x less |
Example: 10,961-row report (1.82 MB download)
| Metric | Jason | RustyJson | Improvement |
|---|---|---|---|
| Save JSON time | 1,317 ms | 51 ms | 26x faster |
| Memory (Save JSON) | +149.0 MB | +16 KB | 9,300x less |
| Total memory | +161.9 MB | +21.9 MB | 7x less |
The memory difference is most dramatic during the encoding step itself, where RustyJson avoids the intermediate allocations that Jason requires.
Features
Built-in Type Support
These types are handled natively 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" |
MapSet | [1, 2, 3] |
Range | {"first": 1, "last": 10} |
| Structs | Object without __struct__ |
| Tuples | Arrays |
Options
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.Encoderprotocol
Decoding:
keys: :strings | :atoms | :atoms!- Key handling
Custom Encoding
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}) do
%{amount: Decimal.to_string(amount), currency: currency}
end
end
RustyJson.encode!(money, protocol: true)
Or use @derive:
defmodule User do
@derive {RustyJson.Encoder, only: [:name, :email]}
defstruct [:name, :email, :password_hash]
end
JSON Spec Compliance
RustyJson passes 72+ tests covering full JSON spec compliance:
- Primitives:
null,true,false - Numbers: integers, floats, exponents, large numbers
- Strings: Unicode, escape sequences, surrogate pairs (emoji)
- Arrays and objects: nested, mixed types, duplicate keys (last wins)
- Error handling: rejects trailing commas, single quotes, unquoted keys
- Nesting depth: 128-level maximum per RFC 7159
How It Works
Why RustyJson Is Different
Most Rust JSON libraries for Elixir use serde to convert between Rust and Erlang types. This requires:
- Erlang term → Rust struct (allocation)
- Rust struct → JSON bytes (allocation)
- JSON bytes → Erlang binary (allocation)
RustyJson eliminates the middle step by walking the Erlang term tree directly and writing JSON bytes without intermediate Rust structures.
Key Optimizations
Custom 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
- 256-byte lookup table for O(1) escape detection
Custom Direct Decoder:
- Parses JSON while building Erlang terms (no intermediate AST)
- Zero-copy strings for unescaped content
- lexical-core for fast number parsing
Memory Allocator: Uses mimalloc by default. Alternatives available via Cargo features:
[features]
default = ["mimalloc"]
# Or: "jemalloc", "snmalloc"
What We Learned
The bottleneck for JSON NIFs isn't parsing or formatting—it's crossing the NIF boundary and building Erlang terms. SIMD-accelerated parsers like simd-json and sonic-rs showed minimal improvement because term construction dominates the workload.
The wins come from:
- Avoiding intermediate allocations (no Rust structs, no serde)
- Efficient term building (direct writes to Erlang heap)
- Good memory allocator (mimalloc reduces fragmentation)
Limitations
- Maximum nesting depth: 128 levels (per RFC 7159)
- Decoding very large payloads (>500 KB) may be only marginally faster than Jason
- Benchmarks are on Apple Silicon M1; results on other architectures may differ
Acknowledgments
- Rustler - Erlang NIF bindings for Rust
- Jason - API design and behavior reference
- Original Jsonrs - Initial inspiration