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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

How Singapore Scaleups Are Cutting Backend Overhead the Smart Way

You raised your Series A. You tripled your engineering team. Somehow, your backend ships slower than it did when there were four of you.

Read more

Java Optional — What It's For, What It's Not For, and How to Use It Well

Optional is a return type that signals absence explicitly. It's not a null replacement, not a container to store in fields, and not a way to avoid NullPointerException everywhere. Used correctly, it improves API clarity. Used incorrectly, it adds allocation and verbosity without benefit.

Read more

A Good API Is One Developers Never Have to Ask Questions About

APIs fail when they require interpretation instead of execution. The best APIs eliminate ambiguity through consistent design, predictable behavior, and self-evident contracts.

Read more

API Documentation Is Not an Afterthought. It Is Part of the Design.

Documentation written after the API is already built reflects the API that exists. Documentation written during design shapes the API that should exist.

Read more