Crystal Macros: Compile-Time Metaprogramming Without the Runtime Bill
Crystal replaces Ruby's runtime metaprogramming with compile-time macros. Here's how JSON::Serializable and a small getter-generating macro work, what they cost, and how to verify expansions.
20 Aug 2026, 15:13 UTC

If you come to Crystal from Ruby, the first thing you miss is method_missing and friends. Crystal is statically typed and compiled, so there is no runtime reflection to lean on. The deliberate replacement is the macro system: code that writes code before your program ever runs. Done well, you get Ruby-flavored ergonomics with zero runtime overhead. Done badly, you get unreadable code and cryptic compiler errors. This post is about doing it well.
What a Crystal macro actually is
A macro runs during compilation and receives syntax trees (AST nodes), not values. It can inspect type names, method definitions, and annotations, then emit new Crystal code that the compiler type-checks like anything you wrote by hand. The two constructs to know:
{{ ... }}interpolates a compile-time value into generated code.{% ... %}executes compile-time control flow (loops, conditionals) without emitting anything itself.
The key consequence: by the time your binary exists, the macro is gone. There is no interpreter pass, no reflection lookup, no dispatch cost. The generated code is just code.
The canonical example: JSON::Serializable
The standard library's JSON::Serializable is the best advertisement for the design. You include a module, and the compiler generates a fully type-checked parser and serializer for your exact fields:
require "json"
struct Point
include JSON::Serializable
getter x : Float64
getter y : Float64
def initialize(@x : Float64, @y : Float64)
end
end
point = Point.from_json(%({"x": 1.5, "y": 2.0}))
puts point.to_json # => {"x":1.5,"y":2.0}
No reflection, no hash-of-anything intermediate. If the JSON contains a string where x expects a Float64, you get a typed deserialization error, and fields you never declared are handled according to explicit options rather than silently stuffed into a dynamic object. Run this with any recent stable crystal binary (crystal run point.cr) and confirm the round trip yourself — syntax details in the standard library do evolve between releases.
Writing one yourself: generating getters
Here is a small, realistic macro that eliminates boilerplate — generating a getter for each name you pass:
macro define_getters(*names)
{% for name in names %}
def {{name.id}}
@{{name.id}}
end
{% end %}
end
class Config
define_getters host, port
def initialize(@host : String, @port : Int32)
end
end
The {% for %} loop runs at compile time over the argument list, and {{name.id}} pastes each identifier into a method definition. The result is exactly what you would have typed by hand — and the compiler treats it that way.
To see what a macro generates, drop {{debug}} inside the macro body (or use puts inside a {% %} block) and compile. The expanded source prints during compilation, which is the single most useful habit when writing or debugging macros. Never assume an expansion is correct; print it.
The trade-offs are real
Macros are not free, just not paid at runtime:
- Error messages point at expansions. A type error inside generated code surfaces at the macro call site with expanded source you may not recognize. Small macros with
{{debug}}-friendly bodies mitigate this; sprawling ones do not. - Compile times grow. Macro-heavy codebases make the compiler generate and type-check far more code than you wrote. This is usually acceptable, but it is a real cost in large projects.
- Readability suffers first. A future reader has to mentally expand your macro to understand the class. Plain methods and generics are the idiomatic default in Crystal; reach for a macro when boilerplate elimination clearly wins — serializers, ORM mappings, declarative DSLs like those in Lucky or Kemal — not as a general substitute for abstraction.
Also keep the hard boundary in mind: macros cannot see runtime data. They operate on syntax only. If your logic needs values, it belongs in a normal method.
A practical rule of thumb
Before writing a macro, ask: can a generic method or a module do this? If yes, use that. If the answer is "no, because I need to generate methods from a list of names or annotations," a macro is the right tool — and you will pay nothing for it at runtime. Verify the expansion with {{debug}}, write a spec against the generated behavior (not the macro itself), and check the official language reference's macro section for your Crystal version before publishing macro-heavy code, since syntax details are version-sensitive. That discipline gets you the Ruby-style ergonomics without the Ruby-style surprise bill.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.