← Back to DevBytes

Memory Management in Crystal: A Deep Dive

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

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:

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

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles