Understanding Memory Management in Crystal
Memory management in Crystal is a hybrid system that combines automatic garbage collection with the ability to perform low-level manual memory operations. Unlike languages that rely solely on a garbage collector (like Java or Go) or those that demand explicit allocation and deallocation (like C), Crystal gives developers the best of both worlds: safety through automation and control when performance demands it.
At the core of Crystal's memory model sits the Boehm-Demers-Weiser conservative garbage collector (libgc). This collector automatically tracks heap allocations and frees memory that is no longer reachable. However, Crystal also allows allocations on the stack via struct types and provides an unsafe escape hatch for direct pointer manipulation and manual memory management through LibC bindings.
Key Concepts
- Stack allocation – Structs and primitive values live on the stack, offering deterministic cleanup when they fall out of scope
- Heap allocation – Classes and dynamic data reside on the heap, managed by the garbage collector
- Conservative GC – The collector scans the stack, registers, and global data to find potential pointers; it is "conservative" because it may retain some unreachable memory if a bit pattern looks like a valid pointer
- Unsafe blocks – Delimited by the
unsafekeyword, these regions allow pointer arithmetic, manualmalloc/free, and direct memory access - RAII via blocks – Crystal's idiomatic resource management uses block-based patterns with
ensureclauses rather than explicit destructors
Why Memory Management Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Effective memory management directly impacts application performance, reliability, and resource footprint. Here's why it deserves deep attention in Crystal:
- Predictable latency – Understanding when the GC runs helps avoid unexpected pauses in latency-sensitive applications like web servers or real-time systems
- Reduced memory pressure – Choosing stack-allocated structs over heap-allocated classes can dramatically lower GC workload and memory fragmentation
- FFI safety – When interfacing with C libraries, manual memory management ensures correct ownership semantics across language boundaries
- No reference cycles – The conservative GC handles cycles automatically, but large object graphs still consume resources; knowing how to break them or use weak references (
WeakRef) keeps memory usage lean - Zero-cost abstractions – Crystal's struct-based collections (like
StaticArray) give you C-like performance without sacrificing type safety
Stack vs Heap Allocation
Crystal draws a clear line between value types and reference types. This distinction is the foundation of its memory strategy.
Value Types: Structs on the Stack
Structs, primitives (Int32, Float64, Bool, Char), tuples, and static arrays are allocated directly on the stack or inline within their containing object. They require no GC intervention and are automatically cleaned up when the enclosing scope exits.
# Value type – allocated on the stack, no GC overhead
struct Point
getter x : Float64
getter y : Float64
def initialize(@x : Float64, @y : Float64)
end
def distance_to(other : Point) : Float64
dx = x - other.x
dy = y - other.y
Math.sqrt(dx * dx + dy * dy)
end
end
# These live entirely on the stack
p1 = Point.new(0.0, 0.0)
p2 = Point.new(3.0, 4.0)
puts p1.distance_to(p2) # => 5.0
# When the scope ends, no GC cycle is needed
Reference Types: Classes on the Heap
Classes, strings, arrays, hashes, and most standard library collections are heap-allocated. The garbage collector tracks them and reclaims memory when they become unreachable.
# Reference type – allocated on the heap, managed by GC
class Person
property name : String
property age : Int32
def initialize(@name : String, @age : Int32)
end
end
# This object lives on the heap
person = Person.new("Alice", 30)
# The GC will collect it when no references remain
person = nil # Now eligible for collection
Memory Footprint Comparison
Structs avoid the per-object GC bookkeeping overhead (typically 16-24 bytes per heap allocation). For small, short-lived objects created in tight loops, this difference can be substantial.
# Heap-heavy approach – 1,000,000 class instances, each GC-tracked
class Vector3D
property x, y, z : Float64
def initialize(@x, @y, @z)
end
end
# This creates 1,000,000 separate heap objects
vectors = Array.new(1_000_000) { |i| Vector3D.new(i.to_f, 0.0, 0.0) }
# GC must scan all of them
# Stack-friendly approach – a single heap array of structs
struct Vector3DStruct
getter x, y, z : Float64
def initialize(@x, @y, @z)
end
end
# The array is one heap object; struct data is packed inline
vectors_struct = Array.new(1_000_000) { |i| Vector3DStruct.new(i.to_f, 0.0, 0.0) }
# Far less GC pressure
The Garbage Collector in Depth
Crystal uses the libgc library, a mature conservative collector originally developed for C programs. Understanding its behavior helps you write GC-friendly code.
How the Conservative GC Works
- The GC periodically scans the stack, CPU registers, and static data segments for bit patterns that resemble valid heap pointers
- Any heap block "referenced" by such a pattern is marked as reachable and retained
- Unmarked blocks are considered garbage and freed
- Because the scan is conservative, some dead objects may survive a cycle if random integer values happen to look like pointers to them (this is rare in practice but worth noting)
GC Configuration and Tuning
Crystal exposes several GC-related constants and functions through the GC module. You can query collection statistics, manually trigger collections, and adjust thresholds.
# Query GC statistics
puts "Heap size: #{GC.stats.heap_size} bytes"
puts "Free bytes: #{GC.stats.free_bytes}"
puts "Total collections: #{GC.stats.collections}"
# Manually trigger a full collection (use sparingly)
GC.collect
# Enable or disable the GC (dangerous – use only in critical sections)
GC.disable
# ... perform timing-critical operations ...
GC.enable
# Register a finalizer callback (called before object is collected)
GC.add_finalizer(some_object) do
puts "Object is being collected – closing resources"
end
Weak References
For caches, observer patterns, or breaking circular references, Crystal provides WeakRef. A weak reference does not prevent the GC from collecting its target.
# Create a weak reference to a potentially large object
class CacheEntry
property data : String
def initialize(@data)
end
end
entry = CacheEntry.new("expensive_data")
weak = WeakRef.new(entry)
# Access the value – returns nil if already collected
if cached = weak.value
puts cached.data
else
puts "Entry was garbage collected – recompute"
end
# Drop the strong reference; the weak ref won't keep it alive
entry = nil
GC.collect
# Now weak.value likely returns nil
Manual Memory Management with Unsafe Blocks
When you need absolute control—for example, when implementing a custom data structure, interfacing with C libraries, or optimizing a hot path—Crystal allows direct memory manipulation inside unsafe blocks.
Pointer Operations
Inside an unsafe block, you can create pointers, dereference them, perform pointer arithmetic, and call LibC allocation functions.
# Manual allocation of a single Int32 on the heap
buffer_size = 4 # bytes
unsafe do
pointer = LibC.malloc(buffer_size).as(Int32*)
# Write a value
pointer.value = 42
# Read it back
puts pointer.value # => 42
# Pointer arithmetic – advance to the "next" Int32 (4 bytes ahead)
next_ptr = pointer + 1
# Always free to avoid leaks
LibC.free(pointer)
end
Allocating and Initializing Arrays of Structs
Manual allocation shines when you need a dense, cache-friendly layout not achievable with the standard Array (which stores pointers to heap objects).
struct Particle
getter x, y, z : Float64
getter velocity : Float64
def initialize(@x, @y, @z, @velocity)
end
end
# Allocate space for 10,000 particles as a contiguous block
count = 10_000
unsafe do
block = LibC.malloc(sizeof(Particle) * count).as(Particle*)
# Initialize each particle in place
count.times do |i|
particle_ptr = block + i
particle_ptr.value = Particle.new(
rand(100.0),
rand(100.0),
rand(100.0),
rand(10.0)
)
end
# Simulate: update velocities
count.times do |i|
particle_ptr = block + i
particle = particle_ptr.value
# Create a new struct with updated velocity (immutable pattern)
particle_ptr.value = Particle.new(
particle.x,
particle.y,
particle.z,
particle.velocity * 0.99
)
end
# Compute total kinetic energy
total_energy = 0.0
count.times do |i|
total_energy += (block + i).value.velocity ** 2
end
puts "Total energy: #{total_energy}"
# Free the entire block
LibC.free(block)
end
Stack-Allocated Buffers via LibC alloca
For temporary buffers that should never escape the current scope, alloca allocates on the stack, automatically freeing when the function returns.
# Temporary buffer for string formatting
def format_with_buffer(values : Array(Int32)) : String
unsafe do
# Allocate a stack buffer – freed automatically on return
buffer = LibC.alloca(256).as(UInt8*)
# Write into the buffer manually (simplified)
offset = 0
values.each_with_index do |val, i|
# Extremely simplified – real code would use snprintf
str = val.to_s
str.each_byte do |byte|
(buffer + offset).value = byte
offset += 1
end
if i < values.size - 1
(buffer + offset).value = 44_u8 # comma
offset += 1
end
end
(buffer + offset).value = 0_u8 # null terminator
# Convert back to Crystal string (copies data)
String.new(buffer)
end
end
puts format_with_buffer([1, 2, 3, 4, 5]) # => "1,2,3,4,5"
Safety Boundaries
The unsafe keyword is infectious—any method that calls unsafe code must itself be marked unsafe or wrap the call in its own unsafe block. This creates a clear audit trail for potentially dangerous operations.
# This method signature warns callers about unsafe semantics
unsafe def dangerous_pointer_math(ptr : Int32*, offset : Int32) : Int32
(ptr + offset).value
end
# Safe wrapper – encapsulates the unsafe block
def safe_array_access(pointer : Int32*, index : Int32) : Int32
if index >= 0
unsafe do
pointer[index]
end
else
raise IndexError.new("Negative index not allowed")
end
end
Resource Management Patterns
Beyond raw memory, Crystal applications manage file descriptors, sockets, database connections, and other OS resources. The language provides idiomatic patterns that guarantee cleanup without explicit destructors.
The Block Pattern (RAII)
Crystal's standard library consistently uses a block-based resource management pattern: an object is yielded to a block and automatically closed when the block exits, even if an exception occurs.
# File.open with a block guarantees the file is closed
File.open("large_file.txt", "r") do |file|
# Process file line by line – memory efficient streaming
file.each_line do |line|
if line.includes?("ERROR")
puts line
end
end
end
# File is automatically closed here, even if an exception was raised
# The pattern works for any disposable resource
# Example: a custom resource wrapper
class ManagedConnection
def initialize(@host : String)
puts "Connecting to #{@host}..."
@connected = true
end
def query(sql : String) : String
raise "Not connected" unless @connected
"Result for: #{sql}"
end
def close
puts "Closing connection to #{@host}"
@connected = false
end
# The idiomatic Crystal pattern
def self.open(host : String)
conn = new(host)
yield conn
ensure
conn.close
end
end
ManagedConnection.open("db.example.com") do |conn|
result = conn.query("SELECT * FROM users")
puts result
end
# Connection is closed by the ensure clause
Ensure and Rescue for Cleanup
For ad-hoc resource management, ensure clauses guarantee cleanup regardless of exceptions.
# Manual resource cleanup with ensure
def process_data_file(path : String) : Array(String)
file = File.open(path, "r")
results = [] of String
begin
file.each_line do |line|
results << line.upcase
end
results
rescue e : IO::Error
puts "IO error: #{e.message}"
[] of String # Return empty array on failure
ensure
file.close # Always executed
end
end
Custom Finalizers with GC.add_finalizer
For heap objects that wrap native resources, you can register a finalizer that the GC calls before reclaiming the object. This is a safety net, not a primary cleanup strategy—the GC may delay collection arbitrarily.
class NativeBuffer
@pointer : Pointer(UInt8)
@size : Int32
def initialize(@size : Int32)
unsafe do
@pointer = LibC.malloc(@size).as(UInt8*)
end
# Register finalizer as safety net
GC.add_finalizer(self) do
if @pointer
unsafe { LibC.free(@pointer) }
@pointer = Pointer(UInt8).null
end
end
end
def dispose
if @pointer
unsafe { LibC.free(@pointer) }
@pointer = Pointer(UInt8).null
end
end
# Always prefer explicit disposal
def self.use(size : Int32)
buffer = new(size)
yield buffer
ensure
buffer.dispose
end
end
Working with FFI and C Libraries
Foreign Function Interface (FFI) calls often involve exchanging ownership of memory between Crystal and C. Getting this right prevents double-frees, leaks, and use-after-free bugs.
Passing Memory to C Functions
# Define a C function that fills a buffer
@[Link("c")]
lib LibC
fun snprintf(buffer : UInt8*, size : Int32, format : UInt8*, ...) : Int32
end
# Safe wrapper that manages the buffer
def format_message(temperature : Float64) : String
unsafe do
buffer_size = 128
buffer = LibC.malloc(buffer_size).as(UInt8*)
format_str = "Temperature: %.2f°C\0"
ret = LibC.snprintf(buffer, buffer_size, format_str.to_unsafe, temperature)
if ret < 0
LibC.free(buffer)
raise "Formatting failed"
end
result = String.new(buffer)
LibC.free(buffer)
result
end
end
puts format_message(23.5) # => "Temperature: 23.50°C"
Receiving Allocated Memory from C
When a C function returns a pointer to newly allocated memory, Crystal must take ownership and ensure it gets freed exactly once.
# Hypothetical C function that allocates a string
lib HypotheticalLib
fun get_error_message(code : Int32) : UInt8*
fun free_message(ptr : UInt8*) : Void
end
# Crystal wrapper that transfers ownership
def error_message_for(code : Int32) : String
unsafe do
c_ptr = HypotheticalLib.get_error_message(code)
if c_ptr.null?
return "Unknown error"
end
# Copy the C string into a Crystal-managed String
result = String.new(c_ptr)
# Free the C-allocated memory (ownership transferred and discharged)
HypotheticalLib.free_message(c_ptr)
result
end
end
Pinning Objects for C Interoperability
When passing a Crystal object's data to C, ensure the GC does not move or collect the object during the call. Crystal's GC is non-moving, so objects stay at fixed addresses, but you still must keep a strong reference alive.
# Crystal's GC is non-moving, so addresses remain stable
# But you must keep the object reachable during the C call
def send_buffer_to_c(data : Bytes) : Int32
# data is a Bytes (slice of UInt8) – keep reference alive
unsafe do
# The pointer is valid because `data` is reachable on the stack
HypotheticalLib.process_data(data.to_unsafe, data.size)
end
# data reference held here ensures GC doesn't collect the backing array
end
Memory Profiling and Debugging Tools
Crystal provides built-in and external tools to inspect memory usage, detect leaks, and profile allocation patterns.
Using GC Module for Statistics
# Profile memory usage over time
def track_memory
initial = GC.stats
yield
final = GC.stats
allocated = final.heap_size - initial.heap_size
freed = final.free_bytes - initial.free_bytes
puts "Memory delta: #{allocated} bytes allocated"
puts "Free bytes delta: #{freed}"
end
track_memory do
# Operation that might allocate heavily
strings = Array.new(100_000) { |i| "String #{i}" }
strings.map(&.upcase)
end
Heap Dump and Leak Detection
# Enable verbose GC logging (compile-time flag)
# Build with: crystal build -Dgc_stats program.cr
# At runtime, check for potential leaks
def check_leaks
GC.collect # Force collection
GC.collect # Second pass catches cycles
stats = GC.stats
puts "Heap after full collection: #{stats.heap_size} bytes"
puts "Unmapped (returned to OS): #{stats.unmapped_bytes} bytes"
if stats.heap_size > expected_threshold
puts "WARNING: Possible memory leak detected"
end
end
Best Practices
Mastering memory management in Crystal means adopting habits that leverage the GC's strengths while sidestepping its weaknesses. Here are the most impactful practices:
-
Prefer structs for small, immutable data – Use
structfor types under ~64 bytes that represent values (points, vectors, timestamps, configuration options). They avoid heap allocation entirely and reduce GC scanning overhead. -
Use the block pattern for all resources – Design your APIs with block-based resource management. The
ensureclause inside the method guarantees cleanup regardless of exceptions or early returns. -
Minimize allocations in hot loops – Pre-allocate buffers, reuse arrays with
clear, and avoid creating temporary strings or objects inside tight loops. UseString.buildandIO::Memoryfor efficient string construction. -
Keep unsafe blocks small and auditable – Isolate
unsafecode in tiny, well-documented functions. The caller should not need to reason about pointer safety unless they explicitly invoke anunsafemethod. -
Always pair malloc with free – Inside
unsafeblocks, everyLibC.mallocmust have a correspondingLibC.freeon all code paths, including exception handlers. Consider wrapping manual allocations in a helper with anensureclause. -
Register finalizers as safety nets only – Use
GC.add_finalizerfor native resource cleanup, but never rely on it as the primary cleanup mechanism. The GC may delay collection indefinitely. Always provide an explicitdisposeorclosemethod. -
Understand allocation sites – String concatenation (
+),Array#mapwith a block, andHashinsertions all allocate. UseString.build,Array#map!(mutating), and pre-sized collections to reduce allocation churn. -
Profile before optimizing – Use
GC.statsand external tools likevalgrind(with Crystal's debugging flags) to identify actual memory bottlenecks before reaching forunsafeor manual memory management. -
Leverage StaticArray for fixed-size collections – When you know the exact size at compile time,
StaticArraypacks elements inline on the stack or directly within the containing struct, offering zero-overhead storage. - Keep strong references alive during FFI calls – When passing Crystal data to C, ensure the originating object remains reachable on the stack until the C function returns. Assign it to a local variable that persists beyond the call.
Practical Example: Applying Best Practices
# BEFORE: Heap-heavy, allocation-intensive approach
def process_log_lines_naive(path : String) : Hash(String, Int32)
counts = {} of String => Int32
File.read(path).split("\n").each do |line|
parts = line.split(" ")
key = parts[0]? || "unknown"
counts[key] = (counts[key]? || 0) + 1
end
counts
end
# Problems: File.read loads entire file into memory; split creates arrays;
# string keys allocate; Hash resizes multiple times
# AFTER: Memory-conscious, streaming approach
def process_log_lines_optimized(path : String) : Hash(String, Int32)
counts = Hash(String, Int32).new(initial_capacity: 1024)
File.open(path, "r") do |file|
file.each_line do |line|
# Avoid allocating a full array of parts
first_word_end = line.index(' ') || line.size
key = line[0...first_word_end]
# Use Hash#update for single lookup + mutation
counts.update(key) { |count| count + 1 } do
1
end
end
end
counts
end
# Improvements: streaming avoids full-file allocation; no split arrays;
# pre-sized hash reduces resizes; update() does one hash lookup
Struct-Based Data Pipeline
# Use structs for intermediate data to avoid heap churn
struct LogEntry
getter timestamp : Int64
getter level : String
getter message : String
def initialize(@timestamp, @level, @message)
end
end
# Process entries without allocating per-item class instances
def filter_entries(entries : Array(LogEntry), min_level : String) : Array(LogEntry)
result = [] of LogEntry
entries.each do |entry|
if entry.level >= min_level
result << entry # Copying a struct is cheap (stack copy)
end
end
result
end
Conclusion
Memory management in Crystal is a thoughtfully designed system that rewards developers who understand its layers. The garbage collector handles the common case with minimal ceremony, while structs give you stack-allocated value types that sidestep GC overhead entirely. When you need absolute control, unsafe blocks and LibC bindings offer the same power as C—with the critical difference that unsafe regions are explicitly marked and auditable.
The most effective Crystal developers internalize the allocation semantics of their data types: they reach for struct when values are small and numerous, they design APIs around the block pattern for deterministic resource cleanup, and they reserve manual memory management for the rare cases where GC latency or layout constraints demand it. By combining these techniques with the profiling tools provided by the GC module, you can build applications that are both safe by default and blazingly fast when performance matters. The journey from understanding stack vs heap to confidently writing unsafe pointer code is one of the most rewarding paths in Crystal—and now you have the map to navigate it.