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"}]
end

Prebuilt 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, RustyJson

When 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: :atoms with string keys (the default) or keys: :atoms! when every key atom already exists.
  • Replace lowercase a on ~j/~J with no modifier for strings or uppercase A for existing atoms.
  • Remove unknown encode/decode options. :protocol, :lean, :sort_keys, and :validate_strings require booleans; :decoding_integer_digit_limit, :max_bytes, and :dirty_threshold require non-negative integers. Invalid :pretty values or nested keys also raise ArgumentError.

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

DatasetRustyJsonJasonSpeedMemory
Settlement report (10 MB)24 ms131 ms5.5x faster2-3x less
canada.json (2.1 MB)6 ms18 ms3x faster2-3x less
twitter.json (617 KB)1.2 ms3.5 ms2.9x fastersimilar

Decoding

DatasetRustyJsonJasonSpeed
Settlement report (10 MB)61 ms152 ms2.5x faster
canada.json (2.1 MB)8 ms29 ms3.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% faster

This 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 reductions

RustyJson 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:

TypeJSON 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"
StructsObject without __struct__
TuplesArrays

Note: MapSet and Range are not encoded by default. They raise Protocol.UndefinedError with protocol: true (the default), matching Jason's behavior. Use protocol: false to encode them via the Rust NIF directly (MapSet → array, Range → object), or add an explicit RustyJson.Encoder impl.

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 indentation

  • escape: :json | :html_safe | :javascript_safe | :unicode_safe - Escape mode

  • compress: :gzip | {:gzip, 0..9} - Gzip compression

  • lean: true - Skip special type handling for max speed
  • protocol: true - Enable custom RustyJson.Encoder protocol
  • sort_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 (:atoms is 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 scheduler
  • max_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]
end

JSON 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:

  1. Erlang term → Rust struct (allocation)
  2. Rust struct → JSON bytes (allocation)
  3. 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 no unsafe blocks
  • 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

License

MIT License