Zero-Overhead JSON Serialization in Crystal Using Compile-Time Macros
Crystal lacks runtime reflection, but compile-time macros can generate type-safe JSON serialization methods with zero runtime overhead.
08 Nov 2025, 14:50 UTC

The problem: serialization without reflection
Crystal does not provide a runtime reflection API. A running program cannot inspect an object's fields and dynamically build a JSON serializer from them. If you need to_json for many data classes, the alternative is writing the same property-by-property code over and over. The standard library solves this for JSON::Serializable by generating serialization methods during compilation, not at runtime.
The useful takeaway: a small macro can iterate a class's instance variables and emit to_json for you. The generated code is ordinary Crystal, so it runs as fast as a hand-written method and requires no reflection machinery in the binary.
How compile-time macros work here
A Crystal macro receives abstract syntax tree (AST) nodes as input and returns AST nodes that the compiler splices into the program. An AST is the compiler's structured representation of source code. Because expansion happens while compiling, the macro can read compile-time type information such as @type.instance_vars, but it cannot inspect runtime values. The result is zero-cost metaprogramming: the compiled binary contains only the generated methods.
Worked example: generate to_json with a macro
This macro defines to_json for any class that invokes it. It writes one json.property call per instance variable.
macro json_serializable
def to_json(json : JSON::Builder)
{% for var in @type.instance_vars %}
{% name = var.name %}
json.property "{{name}}", @{{name}}
{% end %}
end
end
class Product
@sku : String
@price : Float64
@tags : Array(String)
json_serializable
end
The {% for %} loop runs at compile time. @type.instance_vars holds the variables known for Product when the macro expands. {{name}} splices the variable name into the generated source, and @{{name}} references the instance variable. After expansion, the compiler treats Product#to_json as if you had typed it manually.
You can use it like this:
product = Product.new
product.sku = "ABC-123"
product.price = 19.99
product.tags = ["sale", "electronics"]
builder = JSON::Builder.new
product.to_json(builder)
When compiled and run, the generated method writes each field to the builder. The exact output depends on your JSON::Builder configuration, but the structure matches the instance variables in declaration order.
Verify what the macro generated
Run this from your project root:
crystal tool expand src/product.cr > /tmp/expanded_product.cr
No special permissions are required. Inspect /tmp/expanded_product.cr and confirm it contains a def to_json method with one property call per instance variable. You can also check the intermediate representation:
crystal build --emit=llvm-ir src/product.cr
Expect to see the generated to_json as ordinary function code and no runtime reflection calls, because Crystal has no reflection API for this path. Confirm your toolchain with:
crystal --version
Macro behavior is stable across Crystal 1.x releases; error-reporting details vary by patch version.
Limits and common mistakes
- No runtime values. Macros operate on syntax and types only. You cannot branch on the value of
@skuinside the macro. @typeis lexical. It refers to the macro's definition context, not necessarily the class where the macro is called. Generic type arguments may not be visible the way you expect.- Build-time cost with
run. Executing arbitrary Crystal code at compile time for file reads or network requests slows builds and breaks hermeticity. Avoid it for reproducible builds. - Harder debugging. Macro failures produce AST dumps at the call site. Use
{% debug %}during development andcrystal tool expandto inspect generated code. - Variable hygiene. Names declared with
{% var %}can leak into the caller's scope. Prefermacroovermacro defand keep introduced names local. - Not Turing-complete. There is no unbounded recursion in the macro language. Complex generation may require a separate
runscript, which adds build complexity.
Manual, macro, and reflection compared
| Approach | Runtime cost | Boilerplate | Available in Crystal |
|---|---|---|---|
Hand-written to_json | None | High per class | Yes |
| Compile-time macro | None | Low, written once | Yes |
| Runtime reflection | Reflection overhead | Low | No standard API |
Practical check
- Add the macro and invoke it in a test class.
- Run
crystal tool expandand verifyto_jsonappears with the expected property calls. - Run
crystal buildand confirm compilation succeeds without reflection-related errors. - If behavior is wrong, inspect the expanded source rather than guessing at runtime.
This change modifies source files only. Roll back by reverting the file or commit in your version-control system.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.