Blocks, procs and lambdas: practical tips and trade-offs
Ruby gives us three related but distinct ways to represent chunks of code as objects, and picking the right one can make your code more elegant or save you from subtle bugs. Whether you're iterating through a collection, building a callback system, or writing methods that accept flexible behavior, understanding blocks, procs and lambdas and their trade-offs is essential for writing production Ruby code.
Let's walk through when to reach for each one and what gotchas to watch out for.
Blocks are passed to methods
A block is the most common pattern you'll encounter. It's a chunk of code passed to a method using either curly braces or do...end. The method receives the block implicitly via the yield keyword and can execute it zero or more times.
We use blocks constantly in Rails:
[1, 2, 3].each { |num| puts num }
User.where(active: true).map { |u| u.email }
5.times do
puts "Processing..."
endFrom the method definition side, blocks are received with yield:
def process_items(items)
items.each do |item|
yield(item) if block_given?
end
end
process_items([1, 2, 3]) { |item| puts "Item: #{item}" }The key insight: blocks are not objects. You can't store a block in a variable or pass it between methods directly. You can only pass it inline to a method that's expecting one.
Blocks shine in iteration and control flow. They're lightweight, they read naturally in the code, and they're idiomatic Ruby. Use them whenever you're building a method that operates on a collection or controls execution flow.
Procs are stored blocks
A Proc is a block converted into an object. You create one explicitly using Proc.new or the shorthand proc. The major win: you can store it, pass it around, and call it later.
greeting = Proc.new { |name| "Hello, #{name}!" }
puts greeting.call("Alice") # => "Hello, Alice!"
# Store it, pass it between methods
def greet_all(names, greeter)
names.each { |name| puts greeter.call(name) }
end
greet_all(["Bob", "Charlie"], greeting)Procs are forgiving with arguments. If you pass too few, missing ones become nil. If you pass too many, extras are ignored:
my_proc = Proc.new { |a, b| "#{a}, #{b}" }
puts my_proc.call(1) # => "1, "
puts my_proc.call(1, 2, 3) # => "1, 2"This flexibility is useful in some scenarios—callback systems, for instance—but it can hide bugs. The arity mismatch silently succeeds when you might have wanted to catch it.
Another difference: return inside a Proc returns from the enclosing method, not just the Proc:
def demo_proc_return
my_proc = Proc.new { return "early exit" }
my_proc.call
"never reached"
end
demo_proc_return # => "early exit"This can be handy for short-circuiting, but it's a footgun if you're not aware of it. Use Procs when you need to store executable behavior and flexibility around arguments is acceptable or even desired.
Lambdas check arguments strictly
A lambda is like a Proc, but stricter. Create one with the lambda keyword or the stabby lambda syntax ->:
greeting = lambda { |name| "Hello, #{name}!" }
# or
greeting = ->(name) { "Hello, #{name}!" }
puts greeting.call("Alice") # => "Hello, Alice!"The critical difference: lambdas enforce argument count. Pass the wrong number and you get an ArgumentError:
my_lambda = ->(a, b) { "#{a}, #{b}" }
my_lambda.call(1) # => ArgumentError: wrong number of arguments (given 1, expected 2)
my_lambda.call(1, 2, 3) # => ArgumentError: wrong number of arguments (given 3, expected 2)And unlike Procs, return inside a lambda returns only from the lambda itself:
def demo_lambda_return
my_lambda = -> { return "exits lambda only" }
my_lambda.call
"this is reached"
end
demo_lambda_return # => "this is reached"In production code, this strictness is often what you want. It makes your code more predictable. If someone calls your lambda with the wrong number of arguments, you find out immediately rather than debugging nil values later.
Use lambdas when you're writing APIs or complex callback systems where you want to enforce a contract. They're also better for functional-style programming in Ruby, where you compose small transformations together.
Real-world example: building a payment processor
Let's say we're building a payment processing system with validation, logging, and retry logic. Here's where each tool fits:
class PaymentProcessor
def initialize
# Store a validation lambda—strict argument checking matters
@validator = ->(amount) do
raise "Invalid amount" unless amount.positive?
amount
end
# Store retry logic as a proc—flexibility helps here
@retry_handler = Proc.new do |attempt, error|
puts "Retry #{attempt}: #{error.message}"
end
end
def process(amount, &block)
# Blocks are perfect for callbacks—natural, inline, idiomatic
validated = @validator.call(amount)
begin
puts "Processing $#{validated}"
yield validated if block_given? # Call the block
rescue => e
@retry_handler.call(1, e)
retry
end
end
end
processor = PaymentProcessor.new
processor.process(50) { |amt| puts "Recording transaction: $#{amt}" }Notice the separation: lambdas for strict contracts, Procs for flexible callbacks, blocks for inline control flow. Each choice makes the code clearer and more maintainable.
Comparing the three side by side
| Aspect | Block | Proc | Lambda |
|---|---|---|---|
| Is it an object? | No | Yes | Yes |
| Can you store it? | No | Yes | Yes |
| Argument checking | N/A | Loose (lenient) | Strict (arity checked) |
| Return behavior | N/A | Exits enclosing method | Exits lambda only |
| Common use | Iteration, control flow | Callbacks, stored behavior | Functional composition, strict APIs |
Common mistakes
Trying to store a block without converting to Proc: Blocks aren't objects, so you can't assign them to variables directly. Use &block in the method signature to capture it as a Proc, then store it:
# Wrong
def my_method(items)
my_block = { |item| puts item } # This is just a hash, not a block
end
# Right
def my_method(items, &block)
stored = block # Now it's a Proc
items.each { |item| stored.call(item) }
endForgetting to check block_given?: If you yield without a block passed, you get a LocalJumpError. Always guard with block_given? when yield is optional.
Mixing up Proc behavior with lambda behavior: Remember: lambdas are strict about arguments and have local returns. Procs are lenient and have non-local returns. Don't expect them to behave the same way.
Using Procs when you mean lambdas: If you're building an API or validation system, default to lambdas. The argument strictness catches bugs early. Only drop to Procs if you have a specific reason to be flexible.
Key takeaways
- Use blocks for iteration and inline control flow—they're idiomatic and lightweight.
- Use Procs when you need flexible callbacks or can't enforce strict contracts.
- Use lambdas for strict, composable behavior and formal APIs—they catch argument errors immediately.
- Remember
returnbehaves differently: Procs return from the enclosing method, lambdas return from themselves. - In production systems, lambdas are safer by default because their strictness prevents silent bugs.
- Always use
block_given?before callingyieldif the block is optional.






Comments
Loading comments…