Crystal JSON::Serializable: Struct-to-JSON Mapping Without Boilerplate
Crystal's JSON::Serializable module generates to_json/from_json at compile time. A worked struct example, key renaming, strict parsing, and the nil/circular-reference pitfalls to avoid.
27 Nov 2025, 19:44 UTC

If you need to convert Crystal structs to JSON and back, the answer is almost always JSON::Serializable. Include the module in your struct or class, declare your properties, and Crystal generates to_json and from_json at compile time — no serializers, no reflection at runtime, no external shards. This guide shows a working configuration, explains how the mapping happens, and covers the limits that bite people in practice.
A worked example
The following assumes Crystal 1.x (behavior described here has been stable across the 1.x series). Create a scratch project and try it:
# Run from a terminal; no special permissions needed.
crystal init app json_demo
cd json_demo
Replace the contents of src/json_demo.cr with:
require "json"
struct User
include JSON::Serializable
property name : String
property age : Int32
property nickname : String?
end
json = %({"name":"Ada","age":36,"nickname":null})
user = User.from_json(json)
puts user.name # => Ada
puts user.to_json # => {"name":"Ada","age":36,"nickname":null}
Run it with crystal run src/json_demo.cr. You should see the parsed name and the re-serialized JSON. Note that nickname is declared nilable (String?), so a JSON null parses cleanly.
How the mapping works
JSON::Serializable is a macro-based module. When the compiler sees include JSON::Serializable, it inspects the type's instance variables at compile time and generates two methods: a to_json that walks each ivar and emits a key/value pair, and a self.from_json constructor that reads a JSON object and assigns matching keys to ivars. Because this is compile-time code generation, there is no runtime reflection cost and type mismatches (e.g., a JSON string where an Int32 is expected) raise a JSON::SerializableError immediately.
The supported field types are those with a defined JSON mapping: primitives (String, Int32, Float64, Bool, nilable variants), Array and Hash of supported types, Time (via a converter annotation), and any other type that itself includes JSON::Serializable. Nesting just works:
struct Team
include JSON::Serializable
property name : String
property members : Array(User)
end
Renaming keys and handling extra fields
JSON keys are matched to ivar names exactly. If the API you consume uses different names, use the @[JSON::Field] annotation:
struct User
include JSON::Serializable
@[JSON::Field(key: "full_name")]
property name : String
@[JSON::Field(ignore: true)]
property internal_cache : String? = nil
end
Two behaviors surprise newcomers:
- Unknown keys in the input are silently ignored by default. If you want strict parsing that raises on unexpected keys, use
JSON::Serializable::Strictinstead ofJSON::Serializable. There is alsoJSON::Serializable::Unmapped, which captures unknown keys into a hash instead of discarding them. - Missing keys for non-nilable fields raise an error, while missing keys for nilable fields or fields with defaults are fine. Design your structs around what the producer actually guarantees.
Limits and common mistakes
Nil fields serialize as null, not as omitted keys. If the consumer expects absent keys instead of explicit nulls, annotate the field:
@[JSON::Field(emit_null: false)]
property nickname : String?
With emit_null: false, a nil nickname is left out of the output entirely. Without it you get "nickname":null, which is not the same thing to a strict consumer.
Circular references will crash your program. If Team has members : Array(User) and User has team : Team, serialization recurses until the stack overflows. Break the cycle by marking one direction @[JSON::Field(ignore: true)] or by serializing an ID instead of the object.
Custom types need their own mapping. A field of a type that is not JSON-mappable will fail to compile. Either include JSON::Serializable in that type, or write a converter — a small module with to_json(value, builder) and from_json(pull) methods — and attach it with @[JSON::Field(converter: MyConverter)]. This is the standard approach for Time formats, enums stored as strings, or third-party types.
Verifying the behavior
Two quick checks you can run in the scratch project:
- Add an extra key like
"extra":trueto the input JSON and re-run. With plainJSON::Serializableit parses fine; switch the include toJSON::Serializable::Strictand the same input raises a parse error. That confirms which mode you are in. - Print
user.to_jsonbefore and after settingnickname = nilwith and withoutemit_null: falseto confirm how nulls are emitted in your version.
These behaviors are long-established in Crystal 1.x, but if you are on an older 0.x compiler, verify against your installed stdlib docs (crystal docs) since annotation names changed before 1.0.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.