Blocks, Procs, and Lambdas — A Practical Guide Without the Confusion
by Eric Hanson, Backend Developer at Clean Systems Consulting
Why this keeps tripping people up
The confusion isn't about syntax — it's about two specific behavioral differences that only surface at runtime: how each callable handles return, and how strictly each checks its argument count. Get those wrong and you get bugs that are genuinely hard to trace. Understand them precisely and the "which one do I use?" question mostly answers itself.
Blocks: not objects, just syntax
A block is not a first-class object. You can't assign it to a variable, pass it around, or store it. It exists only as the trailing chunk of code you hand to a method via do...end or {}:
[1, 2, 3].each { |n| puts n }
Inside the method, you invoke it with yield. The block inherits the local scope where it was written — closures in Ruby capture their enclosing binding at the point of definition, not execution.
threshold = 10
[5, 15, 20].select { |n| n > threshold }
# => [15, 20]
threshold is captured by reference. If it changes between definition and invocation, the block sees the new value. That's expected closure behavior, but it bites people in loops where a loop variable is captured and then mutated.
To capture a block explicitly — store it, pass it to another method, call it multiple times — you use &:
def run_twice(&block)
block.call
block.call
end
The & prefix converts the block to a Proc object. That's the only way to hold onto a block past a single method call.
Procs: first-class, lenient, dangerous with return
A Proc is a block that's been promoted to an object. You can store it, pass it, call it later:
logger = Proc.new { |msg| puts "[LOG] #{msg}" }
logger.call("connected")
Two behaviors set Proc apart from Lambda and cause most of the real-world bugs:
Argument leniency. A Proc doesn't care how many arguments you pass. Extra arguments are silently discarded; missing ones are bound to nil:
p = Proc.new { |a, b| [a, b] }
p.call(1) # => [1, nil]
p.call(1, 2, 3) # => [1, 2]
This is occasionally useful and frequently the source of subtle data bugs when you refactor the argument list and forget to update call sites.
return exits the enclosing method. This is the one that causes real production incidents:
def process
steps = [
Proc.new { return "early exit" },
Proc.new { puts "step 2" }
]
steps.each(&:call)
"done"
end
process # => "early exit"
The return inside a Proc returns from process, not from the Proc. If the proc outlives its defining method — stored in an instance variable, passed to a background job, called from a different context — you get a LocalJumpError at runtime. This is non-obvious and not caught by static analysis.
Lambdas: strict, self-contained, predictable
A lambda is a Proc with two differences that make it behave like a proper function.
Argument checking is strict. Wrong argument count raises ArgumentError, same as a method call:
fn = lambda { |a, b| a + b }
fn.call(1) # => ArgumentError: wrong number of arguments
fn.call(1, 2) # => 3
return is local. It returns from the lambda itself, not the enclosing method:
def process
fn = lambda { return "from lambda" }
result = fn.call
"done: #{result}"
end
process # => "done: from lambda"
The -> stabby lambda syntax (introduced in 1.9, standard by 2.0) is cleaner and the preferred form today:
fn = ->(a, b) { a + b }
fn.call(1, 2) # => 3
fn.(1, 2) # shorthand, identical behavior
fn[1, 2] # also valid, rarely used
Use -> for anything you're storing or passing around. Reserve lambda { } for cases where the longer form reads better — rare in practice.
The decision rule
The three choices map cleanly to three use cases:
Block: one-shot, inline, not stored. The overwhelming majority of Ruby callables — iterators, configuration DSLs, tap, then. If you're not storing it or passing it to a second method, it's a block.
Lambda: stored, passed, reused. Callbacks, strategy objects, middleware pipelines, anything that lives longer than a single method call. The argument strictness and local return make them behave predictably.
# A small middleware pipeline using lambdas
PIPELINE = [
->(req) { req.merge(authenticated: check_auth(req)) },
->(req) { req.merge(rate_limited: check_rate(req)) },
].freeze
def handle(request)
PIPELINE.reduce(request) { |req, step| step.call(req) }
end
Proc: when you explicitly need the lenient argument behavior, or when you're converting a block with & for storage. Also used when building DSLs where callers shouldn't have to care about exact arity — instance_eval and instance_exec patterns rely on this.
method() and symbol#to_proc
One more callable form worth knowing: method(:name) returns a bound Method object wrapping an existing method. It behaves like a lambda — strict arity, local return. Useful for passing existing methods as callbacks without wrapping them:
[1, -2, 3].select(&method(:positive?))
Symbol#to_proc (the &:method_name shorthand) works by calling to_proc on the symbol, producing a one-argument proc that calls that method on its argument. It's a proc, not a lambda, but argument leniency doesn't matter in the standard iterator patterns where it's used.
What to watch for in code review
Two patterns that warrant a comment when you see them:
First, Proc.new or proc { } with a return statement inside, especially if the proc is stored anywhere. Rewrite as a lambda unless the early-return-from-enclosing-method behavior is intentional and documented.
Second, lambdas being called with mismatched argument counts across a codebase refactor. Unlike procs, this will raise immediately, which is good — but it means changing a lambda's signature requires finding every call site. If the lambda is widely used, a method is often the cleaner choice.
The practical takeaway
Default to blocks. When you need to store or pass the callable, use a lambda. Use a Proc only when you specifically need lenient argument handling or are wrapping a block with &. If you're looking at a Proc.new with a return in it, rewrite it.
The Ruby docs describe procs and lambdas as "essentially the same," which is technically true in the same way that == and equal? are "essentially the same." The two behavioral differences — arity and return semantics — are exactly the ones that determine whether your code does what you think it does.