Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 086 / 100

Blocks, procs and lambdas: practical tips and trade-offs

Ruby's function objects.

Expona Admin6 min read

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:

ruby
[1, 2, 3].each { |num| puts num }

User.where(active: true).map { |u| u.email }

5.times do
  puts "Processing..."
end

From the method definition side, blocks are received with yield:

ruby
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.

ruby
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:

ruby
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:

ruby
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 ->:

ruby
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:

ruby
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:

ruby
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:

ruby
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:

ruby
# 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) }
end

Forgetting 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 return behaves 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 calling yield if the block is optional.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles