Crystal — Reference

Source: https://crystal-lang.org/reference/

Crystal

  • Created: first public release 2014-06-19 by Ary Borenszweig, Juan Wajnerman, and Brian Cardiff at Manas Technology Solutions, Buenos Aires (Wikipedia).
  • Latest stable: Crystal 1.20.1 (April 2026) (crystal-lang.org/reference).
  • Owner: Crystal core team, sponsored by Manas and 84codes (long-time corporate sponsor; runs CloudAMQP/CloudKarafka in Crystal). License: Apache 2.0.
  • Paradigms: object-oriented (everything is an object), with functional flavor; metaprogramming via macros.
  • Typing: static, strong, with global type inference and union types. Type annotations rarely required.
  • Memory: garbage-collected (Boehm-Demers-Weiser conservative GC).
  • Compilation: AOT to native via LLVM. Single static binary by default (with --static against musl).
  • Primary domains: web backends, CLI tools, network services, microservices — anywhere “Ruby ergonomics, C performance” is the pitch.
  • Official docs: https://crystal-lang.org/reference/

At a glance

Crystal reads like Ruby — same blocks, same .each, same def end syntax — but compiles ahead-of-time via LLVM, statically type-checks the entire program with global inference (you almost never write a type), and uses union types for flexibility (Int32 | String is a real first-class type). Concurrency is fibers + channels (Go-style CSP) on a Boehm GC. Multi-threading exists in preview (-Dpreview_mt) and is heading to default. The shards package manager and the Kemal/Lucky frameworks make web work fast.

Getting started

Install:

  • macOS: brew install crystal.
  • Linux: official repos at https://crystal-lang.org/install/.
  • Windows: experimental but functional (since 1.0); use the WSL or native installer.
  • asdf-crystal plugin if you use asdf.

Hello world (file hello.cr):

puts "Hello, world!"

Compile + run: crystal run hello.cr (compile to temp + execute), or crystal build hello.cr (produces ./hello binary).

REPL: icr (community Interactive Crystal). No first-class REPL — the AOT compilation model fights it. The idiomatic interactive workflow is crystal play (web playground) or crystal run on small files.

Project layout (a shard):

mypkg/
  shard.yml             # manifest
  shard.lock            # lockfile
  src/
    mypkg.cr            # entry point
    mypkg/
      submod.cr
  spec/                 # tests (Crystal's spec framework)
    mypkg_spec.cr
  bin/                  # compiled outputs (gitignored)
  README.md

Initialize with crystal init app myapp or crystal init lib mylib.

Package/build tool: shards (ships with Crystal). shards install reads shard.yml, populates lib/, writes shard.lock. Registry is implicit (Git-based) — depend on a GitHub repo + version directly. crystal build src/mypkg.cr -o bin/myapp --release for production.

Basics

Types and literals:

  • Integers: Int8/16/32/64/128, UInt8/16/32/64/128. Default literal 42 is Int32. Suffixes: 42_i64, 42_u8.
  • Floats: Float32, Float64 (default for 1.0). Suffix: 1.0_f32.
  • Bool, Char (single Unicode codepoint, 4 bytes), String (UTF-8, immutable), Symbol (:foo, interned).
  • Nil (singleton, type Nil). Optional pattern: Int32? (= Int32 | Nil).
  • Composites: Array(T) ([1, 2, 3]), Hash(K, V) ({:a => 1} or {a: 1}), Tuple(T1, T2, ...) ({1, "a"} — fixed-length, heterogeneous), NamedTuple(a: Int32, b: String), Set(T), Range, Slice(T) (pointer + size, for binary), Pointer(T) (raw, FFI/unsafe).

Variables/scoping: assignment with = (no declaration keyword for locals). CONSTANT = ... (uppercase) is module-level constant. Scopes: top-level, class, def, block. Methods cannot access enclosing locals; blocks (do |x| ... end or { |x| ... }) capture them.

Control flow: if/elsif/else/end, unless, case ... when ... end (pattern + type matching), while, until, loop, break, next. Everything is an expression (x = if cond then 1 else 2 end).

Functions (called methods even at top level — they’re methods of Object):

def add(x, y)
  x + y                        # last expression returned
end
 
def greet(name : String = "world") : String
  "Hello, #{name}"
end
 
# Type annotations optional; full inference covers most cases
greet                          # "Hello, world"
greet("Crystal")

Method overloading by arity AND type: def f(x : Int32) and def f(x : String) are distinct methods. Splat: def f(*args). Block param: def each(&block); yield ... end.

Strings: UTF-8, immutable, properly Unicode-aware ("abc".size is grapheme/char count). Interpolation: "x = #{x + 1}". Concat: "a" + "b". Multi-line heredoc:

s = <<-HEREDOC
  Multi
  line
HEREDOC

Collections: [1, 2, 3] is Array(Int32). [] of String for empty typed. Array.new(3, "x") to fill. Hash: {"a" => 1} (= Hash(String, Int32)). Tuple: {1, "a", true} is Tuple(Int32, String, Bool) — distinct type per arity/types.

Intermediate

Type system depth:

  • Union types: Int32 | String | Nil is a first-class type. Branching narrows: inside if x.is_a?(String), x is typed String.
  • Generic classes: class Box(T); end. Multiple params allowed.
  • Modules as mixins: module Comparable; ... end, then class Foo; include Comparable; end (instance methods) or extend (class methods).
  • Abstract methods: abstract def name : String in abstract classes/modules.
  • Inheritance: single inheritance + module mixins. Foo < Bar.
  • Type restrictions in method sigs are checked at compile time and used for overload resolution.

Modules: namespace via module M; ... end. File and module names are separate (one file may contain many; one module may span many files). require "./other" for relative file include; require "json" for stdlib; require "kemal" for shard. Public/private: private def, private class, protected.

Error handling: exceptions, like Ruby. raise "msg" (raises Exception), raise CustomError.new(...). begin ... rescue Foo ... ensure ... end. Inline rescue: value = risky() rescue default. Custom errors: class MyError < Exception; end. Result types are not built-in; Result(T, E) exists in shards.

Concurrency primitives:

  • Fibers (lightweight, M:1 by default, M:N with -Dpreview_mt): spawn { do_work }. Fiber stacks are small (4KiB initial). Cooperative scheduling — yields on I/O via the event loop.
  • Channels: ch = Channel(Int32).new (unbuffered) or Channel(Int32).new(10) (buffered). ch.send 1; x = ch.receive. select for multiplexing.
  • Atomics: Atomic(Int32).new(0), compare_and_set, add.
  • Mutex for locking shared state under -Dpreview_mt.

I/O: File.read, File.write, File.open(path, "r") { |io| ... }. IO is an abstract base; IO::Memory for in-memory, IO::FileDescriptor for OS handles. STDIN/STDOUT/STDERR. Networking: TCPSocket, UDPSocket, HTTP::Server, HTTP::Client. JSON via JSON.parse / JSON::Serializable (mixin auto-derives parsers).

Stdlib highlights: Array, Hash, Set, Tuple, NamedTuple, Slice, Time/Time::Span, JSON, YAML, XML, URI, HTTP::Server/HTTP::Client, Socket, OpenSSL, OAuth2, Log, Spec (test framework), Regex, Math, Random, Crypto, Digest, Base64, File, Dir, Process, ENV, Channel, Mutex.

Advanced

Memory / GC: Boehm-Demers-Weiser conservative GC ships with the runtime. Conservative because it scans the stack as bytes. Pros: simple, no compiler-side ownership tracking; works for FFI. Cons: occasional false retentions, no compaction. Allocations of heap-managed objects (classes) go to the GC; Struct types are stack-allocated value types with no GC overhead. GC.collect to force; GC.stats for telemetry.

Concurrency deep dive:

  • Default single-threaded fibers + event loop (epoll/kqueue/IOCP).
  • Multi-thread mode: -Dpreview_mt. Spawns N worker threads, scheduler distributes fibers. Channels and Atomic are MT-safe; many other types (e.g., Hash) are not — wrap with Mutex.
  • Path to default MT: tracked in core team RFCs; expect default ON in 2.x.
  • No async/await colored functions — fibers + blocking I/O calls is the model. Code looks synchronous.

FFI: first-class via lib blocks.

@[Link("c")]
lib LibC
  fun puts(s : LibC::Char*) : LibC::Int
  struct Tm
    tm_sec  : Int32
    tm_min  : Int32
    # ...
  end
end
 
LibC.puts("hello")

fun declares C functions; struct/union/enum mirror C types. @[Link] controls library resolution. Pointer ops: ptr.value, ptr[i], ptr + 4. Crystal’s Slice(UInt8) interops with UInt8* + length cleanly.

Reflection: limited at runtime (no MOP-style introspection like Ruby), substantial at macro time — see God mode.

Performance tools:

  • --release enables LLVM optimizations; build always with --release for benchmarks (debug build is much slower).
  • crystal build --release --no-debug for prod.
  • --stats and --time flags show compile phase costs.
  • valgrind and perf work on the resulting binary.
  • Benchmark.ips { |x| x.report("name") { ... } } for micro-benchmarks.

God mode

Macros = compile-time AST manipulation, written in a Crystal-like macro language. Macros run at compile time, take AST nodes, emit code:

macro define_setters(*names)
  {% for name in names %}
    def {{name.id}}=(v)
      @{{name.id}} = v
    end
  {% end %}
end
 
class Foo
  define_setters x, y, z
end

The {% %} blocks evaluate at compile time; {{ }} interpolates. .id, .stringify, .types are AST methods. {% if @type.has_method?(:foo) %} introspects the surrounding type. JSON::Serializable and many ORM mappers are pure macros.

method_missing macro: define a macro method_missing(call) ... end to intercept unknown method calls at compile time (unlike Ruby’s runtime method_missing). Used by DB::Mapper, dynamic-feel libraries.

Type inference + union types: the compiler runs whole-program inference. After x = cond ? 1 : "s", x : Int32 | String. Calling x.upcase is a compile error unless you narrow first (if x.is_a?(String)). This is unusual — most “dynamic-feel” languages let you compile and crash at runtime.

Fibers + scheduler internals: each fiber has its own stack (4KiB grows on demand). The scheduler is in Crystal::Scheduler; you can implement custom event loops by replacing Crystal::EventLoop. Stack switching uses ucontext-style assembly.

Bindings to C via lib: the lib block plus @[Link] annotations is the only FFI mechanism; no header parsing. Tools like crystal_lib auto-generate bindings from C headers. Many shards (sqlite3, PCRE, OpenSSL) are just lib declarations.

Boehm GC tricks: GC.malloc_atomic for blocks containing no pointers (the GC won’t scan inside) — used internally for String, byte arrays. GC.set_finalizer(obj) { ... } for cleanup hooks (rarely needed).

-D flags / compile-time conditions: -Dpreview_mt, -Dwithout_openssl, custom flags via {% if flag?(:my_flag) %}. Selects code paths at compile time.

Static binaries: crystal build --release --static --link-flags='-static-pie' against musl-libc Alpine container produces a fully-static binary suitable for FROM scratch Docker. The killer deployment story.

Embedding tricks: {{ read_file("data.json") }} embeds a file’s contents at compile time. {{ run("./gen.cr") }} shells out at compile time and embeds output (similar to a build-script).

Idioms & style

  • Naming: snake_case methods/vars, CamelCase classes/modules, SCREAMING_SNAKE constants. Predicate methods end in ? (empty?); mutating methods end in ! (reverse!).
  • Formatter: crystal tool format (built-in). No options to argue.
  • Linter: Ameba (crenv plugin add ameba or via shard). The de facto style/quality checker.
  • Idiomatic patterns:
    • Avoid type annotations unless needed for clarity, overload resolution, or instance-var typing.
    • Use Struct for small immutable value types.
    • Prefer modules + include over deep inheritance.
    • Use JSON::Serializable and YAML::Serializable mixins instead of hand-writing parsers.
    • Use Spec for tests; one _spec.cr per file.
    • Prefer &.method for nil-safe calls (obj.try &.method).
  • Expert review focus: union type explosion (large T | U | ... slows compile and obscures intent), missing @instance_vars type declarations (compiler infers from constructor — sometimes wrongly), uncovered abstract methods, fiber starvation (CPU loops without Fiber.yield), shadowing block params, macro hygiene (use %-prefixed gensym vars).

Ecosystem

  • Web: Kemal (Sinatra-clone, dominant), Lucky (full-stack, Rails-shaped), Athena (Symfony-inspired), Amber (Rails-inspired), Marten (Django-inspired), HTTP::Server (stdlib).
  • Database: crystal-db (interface), crystal-pg (Postgres), crystal-mysql, crystal-sqlite3, Granite ORM, Avram (Lucky’s ORM), Jennifer ORM.
  • Networking / messaging: amqp-client.cr (84codes), redis, crystal-kafka, crystal-natsio.
  • CLI: option_parser (stdlib), admiral, clim.
  • Testing: Spec (stdlib, RSpec-style — describe, it, should/expect). Run with crystal spec.
  • Mocking: Crystal lacks runtime metaprogramming for mocks; use protocol-based mocks (spectator) or hand-rolled.
  • Docs: crystal docs generates HTML from doc comments (# lines above defs). Style: https://crystal-lang.org/api/.
  • Notable users: 84codes (CloudAMQP, CloudKarafka — runs production at scale on Crystal), Manas (the originators), Kagi (search backend portions, per Wikipedia), Nikola Motor (vehicle infotainment), NeuralLegion.

Gotchas

  • Whole-program compile: Crystal type-infers across your entire program + all dependencies. Adding one shard can blow up compile time. Production builds take seconds-to-minutes; non-incremental.
  • Union type inference surprises: a method that returns Int32 | String | Nil requires you to narrow before each operation. Forgetting Nil is the most common bug.
  • return in a block: returns from the enclosing method, not the block. Use next to return a value from the block.
  • Instance variable type inference: if you only assign @x once, the compiler infers its type from that assignment. To allow nil initially, declare @x : Int32?.
  • Splat surprises: def f(*args : Int32) with 0 args yields Tuple() — empty tuple type; type *args : Int32 only constrains element type.
  • Multi-thread mode caveats: many stdlib types are not thread-safe. Audit before flipping -Dpreview_mt. Channels and Atomic are.
  • Heap allocation everywhere classes are involved: class instances are GC’d; if you need stack/value semantics, use struct (immutable, value-type, no GC overhead, no inheritance beyond modules).
  • Shards are Git-based: a force-push or repo deletion breaks builds. Prefer tagged versions in shard.yml.
  • No nil coalescing operator (no ??) — use x || default or x.try &.method.
  • Time.now was removed in 0.27 — use Time.utc or Time.local.
  • Macro errors are cryptic: a wrong {{...}} interpolation yields multi-screen ASTs. Use {% pp ast_node %} to debug.
  • Boehm GC false retentions are rare but possible; GC.collect won’t free things the conservative scan thinks are live.
  • Compile errors mention every overload: a missing method on a union type lists every variant — overwhelming for big unions.

Citations