All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
0.3.9 - 2026-02-16
Improved
- Decimal encoding performance — Fixed a bottleneck that caused scheduler contention and connection timeouts when encoding Decimal values under high throughput.
- Error handling — The parser now returns descriptive errors for malformed unicode escapes and invalid exponents instead of risking NIF crashes.
- Reduced allocations — Less memory pressure when decoding arrays of same-shape objects.
Changed
- Removed unused
serdeandlazy_staticdependencies. - Internal code quality improvements (named constants, idiomatic error propagation).
0.3.8 - 2026-02-12
Fixed
- Duplicate key corruption — Fixed a critical bug where JSON objects containing duplicate keys had their entire parse result corrupted. Non-duplicate keys were silently dropped and values were assigned to wrong keys (e.g.,
{"a": 1, "b": 2, "b": 3, "c": 4}decoded to%{"a" => 4}instead of%{"a" => 1, "b" => 3, "c" => 4}). Root cause:build_map_with_duplicatesdecoded key Terms asVec<u8>(Erlang list type) instead ofBinary, causing all keys to silently decode to empty bytes viaunwrap_or_default()and collapse into a single entry. The fix uses zero-copyBinary::as_slice()references into the BEAM heap, which is also faster than the original intendedHashMap<Vec<u8>>approach.
Testing
- Strengthened duplicate key test coverage — Existing tests only used all-duplicate inputs (e.g.,
{"a": 1, "a": 2}) which accidentally produced correct results despite the bug. Added tests with mixed duplicate and unique keys to catch key-collapse regressions. - Tightened assertions across test suite — Replaced
=(Elixir subset match on maps) with==(exact equality) in map result assertions, replaced field-by-field checks with full map equality, and strengthened{:error, _}wildcards to verify%RustyJson.DecodeError{}struct type. These changes ensure tests fail on missing keys, extra keys, or wrong error types.
0.3.7 - 2026-02-12
Fixed
OrderedObject pretty-printing —
OrderedObjectnow produces properly indented multi-line output withpretty: true. Previously, encoding always returned compact single-line JSON because the Encoder produced aFragmentthat bypassed the NIF's inline pretty-printing. OrderedObject is now handled as a native struct in Rust, formatting directly from thevaluestuple list with zero intermediate serialization.Fragment pretty-printing — Pre-encoded
Fragmentcontent is now reformatted with depth-aware indentation whenpretty: trueis active. This fixes pretty-printing for any struct values nested inside Maps or Lists (e.g.,%{data: %OrderedObject{...}}). The reformatter streams through iodata byte-by-byte with zero buffer allocation, matching Jason's behavior where the Formatter reformats all output uniformly.
0.3.6 - 2026-02-11
Fixed
- Alpine Linux / musl compatibility — Fixed a NIF loading crash on Alpine Linux caused by
mimalloc's defaultinitial-execTLS model. The build configuration now automatically enablesmimalloc'slocal_dynamic_tlsfeature when compiling formusltargets, ensuring correct behavior on Alpine while preserving maximum performance on other platforms.
0.3.5 - 2026-02-01
Major backend refactor: the Rust core was rewritten for safety, stability, and performance. All architecture-specific SIMD intrinsics replaced with portable std::simd, eliminating all unsafe code. No API breaking changes — the Elixir interface is fully backwards-compatible with 0.3.4.
Added
sort_keysencode option —sort_keys: truesorts map keys lexicographically in the JSON output. Useful for snapshot tests, caching, and diffing. Sorting is recursive (nested maps are also sorted). Default:false— JSON objects are unordered per RFC 8259, and RustyJson preserves this semantics by default. No overhead when disabled.- AVX2 precompiled binary variants — Each x86_64 target now ships two precompiled binaries: a baseline (SSE2, 16-byte SIMD) and an AVX2 variant optimized for Haswell+ CPUs (32-byte SIMD, built with
-C target-cpu=x86-64-v3). At compile time,RustlerPrecompileddetects AVX2 support on the host CPU and downloads the optimal variant automatically. Total precompiled artifacts: 45 (30 baseline + 15 AVX2 variants across 5 x86_64 targets × 3 NIF versions).
Changed
- Portable SIMD (
std::simd) — Replaced all architecture-specific SIMD intrinsics (NEON, AVX2, SSE2) with Rust's portable SIMD. One codepath per pattern, zerounsafe, no#[cfg(target_arch)]branching. The compiler generates optimal instructions for each target automatically. Only usesstd::simdAPIs with stable semantics (comparisons, masks, splat/from_slice) — the critical stabilization blockers (swizzle, scatter/gather, mask element types) are unrelated to our usage and explicitly avoided. - Zero
unsafecode — Eliminated allunsafeblocks. SIMD is now safe viastd::simd, andmake_subbinary_uncheckedwas replaced with the safemake_subbinary. The entire codebase is 100% safe Rust. - Nightly Rust toolchain — Required for
#![feature(portable_simd)]. Pinned viarust-toolchain.toml.
Performance
- SIMD-accelerated everything — Hardware acceleration across the entire pipeline: string scanning (decode), escape scanning (encode), structural character indexing, and whitespace skipping. 32-byte wide paths on AVX2, 16-byte on all other targets, with scalar tails.
- Same-shape object detection — Reuses key sets for arrays of objects with identical schemas, providing 2-6x speedup on typical API responses.
- Direct NIF binary writes — Encoded JSON is written once directly into BEAM-managed memory, eliminating intermediate buffer copies.
- Fast-path integer parsing — Homogeneous integer arrays use a tighter, branchless loop that bypasses general-purpose parsing for small numbers.
- Allocation reduction — Switched to
SmallVecfor formatting context, interned struct field atoms, and pre-allocated URI buffers to minimize heap pressure. - SIMD digit scanning — Number parsing skips contiguous digit runs in 16/32-byte chunks with partial-chunk handling via
to_bitmask().trailing_zeros(). Applied across all digit-scanning sites inparse_number,parse_number_fast, andscan_number. - Bulk escaped string copy —
decode_escaped_stringuses SIMDfind_escape_jsonto locate the next escape-worthy byte, then copies the entire safe region in onememcpyviaextend_from_slice. - Precise SIMD exit positions —
skip_whitespaceandskip_ascii_digitsuseto_bitmask().trailing_zeros()to advance to the exact byte position within a partial chunk, eliminating redundant scalar fallback. (String scanning and structural indexing retain the simpler.any()+breakpattern, which benchmarks faster for their typical workloads of frequent hits and dense JSON.) - Static error messages — Replaced dynamic string allocation with static
Cowtypes for common parsing errors.
Fixed
- Test suite cleanup — Removed redundant/flawed assertions and added explicit regression tests for SIMD boundary safety and structural index validation.
0.3.4 - 2026-01-30
Performance
- Decode fast path —
decode!/1bypasses option parsing for the common no-options call (Phoenix, Plug, etc.). No API changes. - SIMD string scanning — String parsing uses portable SIMD (32 bytes/iter on AVX2, 16 bytes/iter elsewhere) combined with zero-copy sub-binary references for non-escaped strings. In local benchmarks, 2-4x faster on string-heavy payloads vs v0.3.3, 15-30% faster on small payloads. Results may vary by hardware and payload shape.
0.3.3 - 2026-01-30
Performance
- Single-pass struct encoding — Struct-heavy data (e.g., lists of derived or custom structs) is now encoded in ~1 walk instead of 3. Previously the encoding pipeline performed three separate walks: protocol dispatch, fragment resolution, and NIF serialization. Now the Encoder protocol produces iodata directly, and the NIF writes it in O(1). RustyJson is now faster across all encoding workloads — plain data and struct-heavy data alike.
- Compile-time derived encoder codegen —
@derive RustyJson.Encodernow generates iodata templates at compile time with pre-escaped keys. Eliminates runtimeMap.from_struct,Map.to_list, and key-escaping overhead. - NIF bypass for iodata Fragments — When the top-level encoding result is already iodata (no pretty-print or compression),
IO.iodata_to_binary/1is used directly instead of passing through the Rust NIF, avoiding unnecessary Erlang↔Rust term conversion. - Top-level-only fragment resolution — When
protocol: true(the default), fragment function resolution is now O(1) instead of O(n). Nested fragments are resolved during the protocol walk itself, so only the top-level result needs checking. - Deep-nested decode fast path — ~27% faster decode for deeply nested JSON (e.g., 100 levels of
{"nested": {...}}). Single-entry objects and arrays avoid heap allocation entirely. - No regressions on plain data workloads (maps, lists, primitives).
Changed
RustyJson.Encodercustom implementations should now return iodata viaRustyJson.Encodefunctions (e.g.,Encode.map/2) for best performance. Returning plain maps is still supported for backwards compatibility and will be re-encoded automatically.
Testing
- 421 tests, all passing with 0 failures.
0.3.2 - 2026-01-29
Fixed
validate_stringsnow defaults totrue— Jason's decoder always validates UTF-8 (its parser matches::utf8codepoints and rejects control bytes). The v0.3.1 release shippedvalidate_strings: false, which silently accepted invalid UTF-8 byte sequences — breaking the Jason-parity guarantee documented since v0.3.0. The default is nowtrueto match Jason's behavior. Passvalidate_strings: falseto opt out for maximum throughput on trusted input.
Testing
- 421 tests, all passing with 0 failures.
- Moved 10 invalid-UTF-8 JSONTestSuite
i_*fixtures from@implementation_acceptsto@implementation_rejectsto reflect the new default.
0.3.1 - 2026-01-29
Added - Robustness & Security
Decode Options
max_bytes— Maximum input size in bytes (default:0, unlimited). The check usesIO.iodata_length/1beforeIO.iodata_to_binary/1to avoid allocating a contiguous binary for oversized input. Also enforced defensively in the NIF.duplicate_keys: :last | :error— Opt-in strict duplicate key rejection (default::last, preserving last-wins semantics). When:error, tracks seen keys viaHashSetin the Rust parser and returns aDecodeErroron the first duplicate. Performance note: adds per-key overhead when enabled — use only when strict validation is needed.validate_strings: true | false— Opt-in UTF-8 validation for decoded strings (default:false). Whentrue, callsstd::str::from_utf8()on both escaped and non-escaped string paths, rejecting invalid byte sequences with aDecodeError.dirty_threshold— Byte size threshold for auto-dispatching decode to a dirty CPU scheduler (default:102_400/ 100 KB, configurable at compile time viaApplication.compile_env(:rustyjson, :dirty_threshold_bytes)). Set to0to disable. Prevents large inputs from blocking normal BEAM schedulers.
Encode Options
scheduler: :auto | :normal | :dirty— Controls which scheduler the encode NIF runs on (default::auto).:autopromotes to dirty only whencompress: :gzipis used (compression is always CPU-heavy).:dirtyalways uses the dirty CPU scheduler.:normalalways uses the normal scheduler.
Changed
Scheduler Strategy
- Added dirty CPU scheduler NIF variants (
nif_encode_direct_dirty,nif_decode_dirty) alongside the existing normal-scheduler NIFs. Both variants share the same implementation — only the scheduler annotation differs. - Updated
docs/ARCHITECTURE.md: renamed "Why We Don't Use Dirty Schedulers" to "Scheduler Strategy" explaining the hybrid approach (normal by default, dirty for large payloads or compression).
Security Hardening
- Randomized FNV hasher seed — The
FnvBuildHasherused forkeys: :internkey caching now generates a per-parse random seed by mixingstd::time::SystemTimewith a stack address. This blocks precomputed hash collision tables without adding any dependencies or measurable overhead. Previously used a fixed FNV offset basis (0xcbf29ce484222325). Note: the seed is not cryptographic — an adaptive attacker measuring aggregate latency could still craft collisions. The intern cache cap (below) bounds the damage in that scenario. - Intern cache cap (4096 keys) — The
keys: :interncache stops accepting new entries after 4096 unique keys. Beyond that, new keys are allocated normally (no worse than default mode). This serves dual purposes: (1) bounds worst-case CPU time from hash collisions to O(4096²) operations regardless of hash quality; (2) stops paying cache overhead when the input clearly has too many unique keys for interning to help. The cap is internal and not user-configurable.
Testing
- 412 tests, all passing with 0 failures.
- New test coverage: key interning correctness,
max_byteslimits (including iodata input), duplicate key detection (including nested objects), UTF-8 string validation (single-byte and multi-byte), dirty scheduler dispatch for both encode and decode.
Release Notes
Adding new NIF exports (nif_encode_direct_dirty, nif_decode_dirty) changes the compiled artifact. This requires rebuilding precompiled binaries and updating checksums.
0.3.0 - 2026-01-28
Breaking Changes
This release aims to achieve full Jason API parity. Three changes require action when upgrading:
1. decode/2 returns %DecodeError{} instead of a string
# Before (0.2.x)
{:error, message} = RustyJson.decode(bad_json)
Logger.error("Failed: #{message}")
# After (0.3.0)
{:error, %RustyJson.DecodeError{} = error} = RustyJson.decode(bad_json)
Logger.error("Failed: #{error.message}")
# Also available: error.position, error.data, error.token2. encode/2 returns %EncodeError{} instead of a string
# Before (0.2.x)
{:error, message} = RustyJson.encode(bad_data)
# After (0.3.0)
{:error, %RustyJson.EncodeError{} = error} = RustyJson.encode(bad_data)3. RustyJson.Encoder protocol changed from encode/1 to encode/2
# Before (0.2.x)
defimpl RustyJson.Encoder, for: MyStruct do
def encode(value), do: Map.take(value, [:name])
end
# After (0.3.0)
defimpl RustyJson.Encoder, for: MyStruct do
def encode(value, _opts), do: Map.take(value, [:name])
endAdded - Full Jason Feature Parity
RustyJson now matches Jason's public API 1:1 in signatures, return types, and behavior.
Decode Options
keys: :copy- Accepted for Jason compatibility. Equivalent to:stringssince RustyJson NIFs always produce copied binaries.keys: :atoms- Convert keys to atoms usingString.to_atom/1(matches Jason — unsafe with untrusted input).keys: :atoms!- Convert keys to existing atoms usingString.to_existing_atom/1(matches Jason — safe, raises if atom doesn't exist).keys: custom_function- Pass a function of arity 1 to transform keys recursively:keys: &String.upcase/1strings: :copy | :reference- Accepted for Jason compatibility (both behave identically).objects: :ordered_objects- Decode JSON objects as%RustyJson.OrderedObject{}structs that preserve key insertion order. Built in Rust during parsing for zero overhead. Key transforms (:atoms,:atoms!, custom functions) apply toOrderedObjectkeys as well.floats: :decimals- Decode JSON floats as%Decimal{}structs for exact decimal representation. Decimal components are parsed in Rust.decoding_integer_digit_limit- Configurable maximum digits for integer parsing (default: 1024, 0 to disable). Also configurable at compile time viaApplication.compile_env(:rustyjson, :decoding_integer_digit_limit, 1024). Enforced in the Rust parser.
Encode Options
protocol: trueis now the default - Encoding always goes through theRustyJson.Encoderprotocol first, matching Jason's behavior. Useprotocol: falseto bypass the protocol for maximum performance.maps: :strict- Detect duplicate serialized keys (e.g. atom:aand string"a"in the same map). Tracked viaHashSetin Rust.- Pretty print keyword opts -
pretty: [indent: 4, line_separator: "\r\n", after_colon: ""]for full control over formatting. Separators are passed to and applied in Rust. - iodata indent - The
:indentoption now accepts strings/iodata (e.g.pretty: "\t"for tab indentation), matching Jason's Formatter behavior. Indent strings are passed to Rust and applied directly.
New Modules
RustyJson.Decoder- Thin wrapper matching Jason's Decoder API. Providesparse/2that delegates toRustyJson.decode/2.RustyJson.Encode- Low-level encoding functions (value/2,atom/2,integer/1,float/1,list/2,keyword/2,map/2,string/2,struct/2,key/2), compatible with Jason's Encode module.keyword/2preserves insertion order (does not convert to map).RustyJson.Helpers- Compile-time macrosjson_map/1andjson_map_take/2that pre-encode JSON object keys at compile time for faster runtime encoding. Preserves key insertion order, propagates encoding options (escape, maps) at runtime via function-based Fragments.RustyJson.Sigil-~jsigil (runtime, supports interpolation) and~Jsigil (compile-time) for JSON literals. Modifiers:a(atoms),A(atoms!),r(reference),c(copy). Unknown modifiers raiseArgumentError.RustyJson.OrderedObject- Order-preserving JSON object struct withAccessbehaviour andEnumerableprotocol.
Error Factories
RustyJson.EncodeError.new/1- Factory functions for creating structured encode errors:new({:duplicate_key, key})andnew({:invalid_byte, byte, original}).
Changed
Error Return Types (Breaking)
decode/2now returns{:error, %RustyJson.DecodeError{}}instead of{:error, String.t()}. TheDecodeErrorstruct includes:message,:data,:position, and:tokenfields for detailed error diagnostics.encode/2now returns{:error, %RustyJson.EncodeError{} | Exception.t()}instead of{:error, String.t()}.encode!/2now wraps NIF errors in%RustyJson.EncodeError{}instead of leakingErlangError.
Encoder Protocol (Breaking)
RustyJson.Encoderprotocol changed fromencode/1toencode/2with anoptsparameter, matching Jason's Encoder protocol. Theoptsparameter carries encoder options (:escape,:maps) as a keyword list, enabling custom implementations likeFragmentandOrderedObjectto respect encoding context. All protocol implementations must update fromdef encode(value)todef encode(value, _opts).protocol: trueis now the default inencode/2andencode!/2. Previously required explicit opt-in. This matches Jason, which always dispatches through its Encoder protocol.Anyfallback now raisesProtocol.UndefinedErrorfor structs without an explicitRustyJson.Encoderimplementation, matching Jason. Previously, structs were silently encoded viaMap.from_struct/1. There is no fallback to Jason's Encoder — RustyJson is a complete replacement, not a bridge.MapSetandRangenow raiseProtocol.UndefinedErrorby default (matching Jason). Previously had pass-through encoder implementations. Useprotocol: falseto encode them via the Rust NIF directly.
Formatter API (Breaking)
RustyJson.Formatter.pretty_print/2now returnsbinary()directly instead of{:ok, binary()} | {:error, String.t()}. RaisesRustyJson.DecodeErroron invalid input.RustyJson.Formatter.minimize/2now returnsbinary()directly. Same error behavior.pretty_print_to_iodata/2returnsiodata()directly.minimize_to_iodata/2returnsiodata()directly. No default foroptsparameter (matching Jason).- Removed
pretty_print!/2,pretty_print_to_iodata!/2,minimize!/2,minimize_to_iodata!/2— Jason does not have bang variants for Formatter. - Stream-based rewrite — Formatter internals ported from Jason's stream-based approach. Preserves key order and number formatting during pretty-print and minimize operations.
OrderedObject
pop/2changed topop/3with an optionaldefaultparameter (default:nil), matching Jason's OrderedObjectpop/3.- Key type changed from
String.t()toString.Chars.t()for Jason compatibility.
Fixed
- Large integer precision — Integers exceeding
u64::MAXare now decoded using arbitrary-precisionBigInt(vianum-bigint) instead of falling back tof64, which lost precision. Matches Jason's behavior of preserving exact integer values regardless of magnitude. html_safeforward slash escaping —escape: :html_safenow correctly escapes/as\/, matching Jason. Previously/was only escaped in unicode/javascript safe modes.- Encoding options propagation —
escapeandmapsoptions now flow correctly through the Encoder protocol, Fragment functions, Helpers macros, and OrderedObject encoding. Previously these options were consumed before reaching protocol implementations. - Nested Fragment encoding — Fragments nested inside maps or lists now encode correctly in both
protocol: trueandprotocol: falsemodes. The Encoder protocol now resolves Fragment functions to iodata immediately instead of wrapping in another closure, andresolve_fragment_functionsrecursively traverses maps and lists to resolve any remaining function-based Fragments before the Rust NIF. - Helpers key validation regex — Fixed character class regex that incorrectly rejected alphabetic keys. Now uses hex escapes for correct ASCII range matching.
Performance
No regressions. Relative speedup vs Jason is unchanged from v0.2.0.
Testing
- 394 tests, all passing with 0 failures.
- New test files:
encode_test.exs,helpers_test.exs,sigil_test.exs,ordered_object_test.exs,decoder_test_module_test.exs. - Updated all error pattern matches across test suite for new structured error returns.
- Tightened formatter tests to use exact-match assertions instead of substring matches.
- Added coverage for: large integer precision, html_safe
/escaping, Fragment function opts propagation, Helpers opts flow, OrderedObject key transforms and encoding opts, atoms/atoms! key decoding, sigil unknown modifiers, MapSet/Range protocol errors.
0.2.0 - 2025-01-25
Added
Faster decoding for API responses and bulk data (
keys: :intern) - ~30% speedup for the most common JSON patternsModern APIs return arrays of objects with the same shape: paginated endpoints (
GET /users), GraphQL queries, database results, webhook events, ElasticSearch hits. This is the vast majority of JSON most applications decode.With
keys: :intern, RustyJson caches object keys during parsing so identical keys like"id","name","created_at"are allocated once and reused across all objects in the array.# Before: allocates "id", "name", "email" for every object RustyJson.decode!(json) # After: allocates each key once, reuses for all 10,000 objects RustyJson.decode!(json, keys: :intern) # ~30% fasterCaution: Don't use for single objects or varied schemas—the cache overhead makes it 2-3x slower when keys aren't reused. Only use for homogeneous arrays of 10+ objects.
See BENCHMARKS.md for detailed performance data.
Documentation
Error handling documentation - Added comprehensive documentation highlighting RustyJson's clear, actionable error messages and consistent
{:error, reason}returns:# Clear error messages describe the problem RustyJson.decode(~s({"key": "value\\'s"})) # => {:error, "Invalid escape sequence: \\'"} # Consistent error tuples for invalid input RustyJson.encode(%{{:tuple, :key} => 1}) # => {:error, "Map key must be atom, string, or integer"}Added error handling sections to README, moduledoc, and ARCHITECTURE.md
Testing
Automated JSONTestSuite regression tests - Added repeatable test suite that validates against JSONTestSuite on every test run. Ensures the documented 283/283 compliance doesn't regress. Fixtures are downloaded on first run to
test/fixtures/(gitignored).Added
keys: :internvalidation against the full JSONTestSuite.
0.1.1 - 2025-01-24
Changed
- Updated hex.pm description for better discoverability
0.1.0 - 2025-01-24
Added
- High-performance JSON encoding - 3-6x faster than Jason for medium/large payloads
- Memory efficient - 10-20x lower memory usage during encoding
- Full JSON spec compliance - 89 tests covering RFC 8259
- Drop-in Jason replacement - Compatible API with
encode/2,decode/2,encode_to_iodata/2 - Phoenix integration - Works with
config :phoenix, :json_library, RustyJson
Encoding Features
- Native Rust handling for
DateTime,NaiveDateTime,Date,Time,Decimal,URI,MapSet,Range - Multiple escape modes:
:json,:html_safe,:javascript_safe,:unicode_safe - Pretty printing with configurable indentation
- Gzip compression with
compress: :gzipoption lean: trueoption for maximum performance (skips struct type detection)protocol: true/falseoption for custom encoding viaRustyJson.Encoderprotocol (default changed totruein v0.3.0)
Jason Compatibility
RustyJson.Encoderprotocol with@derivesupportRustyJson.Encoderprotocol with@derivesupportRustyJson.Fragmentfor injecting pre-encoded JSONRustyJson.Formatterfor pretty-printing and minifying JSON strings
Safety
- Zero
unsafecode — all SIMD uses portablestd::simd(safe), no raw intrinsics - 128-level nesting depth limit per RFC 7159
- Safe Rust guarantees memory safety at compile time
Technical Details
- Built with Rustler 0.37+ for modern OTP compatibility (24-27)
- Uses mimalloc as default allocator
- Uses itoa and ryu for fast number formatting
- Uses lexical-core for number parsing
- Zero-copy string handling in decoder for unescaped strings
- SIMD-accelerated escape scanning via portable
std::simd