Using Crystal's JSON::Serializable for Boilerplate‑Free JSON Mapping
Learn how Crystal’s JSON::Serializable macro generates to_json/from_json methods, when to use annotations, and what trade‑offs to consider.
05 Jan 2026, 11:56 UTC

Problem: Manual JSON boilerplate slows down service development
When building HTTP services in Crystal, you often need to convert structs or classes to JSON for responses and parse incoming payloads. Writing to_json and from_json methods by hand is repetitive, error‑prone, and makes refactoring tedious.
Thesis: JSON::Serializable eliminates the boilerplate with compile‑time macros
Crystal’s JSON::Serializable module uses macros to generate serialization code based on a type’s instance variables. The generated methods are inlined, so runtime overhead is minimal and you get the same performance as hand‑written code.
How it works
When you include JSON::Serializable, the compiler inspects the type’s instance variables at compile time and creates:
def to_json(io : JSON::Builder) : Nildef from_json(value : JSON::Value)
These methods follow the variable names unless you override them with annotations.
Controlling the output with @[JSON::Field]
By default, all public instance variables are serialized. To rename a field, exclude it, or specify a custom converter, use the @[JSON::Field] annotation:
struct User
@[JSON::Field(name: "user_id")]
id : Int32
@[JSON::Field(name: "email_address")]
email : String
# This variable is ignored unless annotated
@[JSON::Ignore]
internal_token : String
include JSON::Serializable
end
Private variables are omitted automatically; expose them only with @[JSON::Field] or @[JSON::Ignore] to make the intent explicit.
Worked example: creating, building, and verifying a JSON‑serializable struct
Create a new Crystal application:
crystal init app json_demo cd json_demoReplace the generated
src/json_demo.crwith the following code:require "json" struct Product @[JSON::Field(name: "sku")] code : String name : String price : Float64 include JSON::Serializable end # Example usage product = Product.new("ABC123", "Gadget", 19.99) json = product.to_json puts json # => {"sku":"ABC123","name":"Gadget","price":19.99} # Round‑trip check parsed = Product.from_json(json) puts parsed == product # => trueCompile the binary in release mode:
crystal build src/json_demo.cr --releaseThis produces an executable
json_demoin the project root.Run the binary and inspect the output:
./json_demoYou should see a single line of JSON matching the expected keys (
sku,name,price) and values. No external libraries are required.Verify round‑trip fidelity programmatically (optional):
crystal specAdd a spec that asserts
Product.from_json(json) == originalfor various inputs. If the assertion passes, the generated methods are correctly handling the struct.
Trade‑offs and limitations
- Recompilation required: Because the methods are generated at compile time, any change to a struct’s instance variables (adding, removing, or renaming) necessitates a full rebuild. Hot‑reloading is not supported.
- Visibility rules: Private instance variables are ignored unless you explicitly annotate them with
@[JSON::Field]or@[JSON::Ignore]. Forgetting to annotate a newly added private field can lead to missing data in the JSON output. - Macro opacity: The generated code is not visible in your source files, which can make debugging harder if you expect custom logic. For complex conversion needs, you can still define custom
to_json/from_jsonmethods; the macro will not override them.
Practical way to check the result
After building, run the binary and capture its output to a file:
./json_demo > output.jsonThen compare
output.jsonagainst an expected template using a diff tool or a simple equality check in a test script. If the files match, the serialization is working as intended.Actionable closing
If your Crystal service needs to exchange JSON data and you control the struct definitions,
JSON::Serializableoffers a low‑boilerplate, high‑performance path. Keep in mind the recompilation step and visibility rules; add a quick round‑trip test to your CI pipeline to catch accidental mismatches early. For cases where you need dynamic formats or runtime‑generated fields, fall back to manual methods or a third‑party library, but for most static payloads the macro‑based approach is the most efficient choice.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.