Ruby Proc vs Lambda: The Real Differences and When to Use Each
Procs and lambdas are both closures in Ruby, but they behave differently in two critical ways: how they handle arguments and how return works inside them. Pick the wrong one and you’ll get silent bugs that are painful to track down.
Here’s the short version: use lambdas by default. Use procs when you specifically need their looser argument handling or non-local return behavior.
The Two Differences That Actually Matter
1. Argument Handling
Lambdas enforce argument count. Procs don’t.
strict = lambda { |a, b| "#{a} and #{b}" }
loose = Proc.new { |a, b| "#{a} and #{b}" }
strict.call(1) # ArgumentError: wrong number of arguments (given 1, expected 2)
loose.call(1) # "1 and " — no error, b is nil
strict.call(1, 2, 3) # ArgumentError: wrong number of arguments (given 3, expected 2)
loose.call(1, 2, 3) # "1 and 2" — extra argument silently dropped
This mirrors a design choice: lambdas behave like methods, procs behave like blocks. Blocks have always been forgiving with argument counts in Ruby — think [1,2,3].each { |x| puts x } where the block doesn’t care if there’s an index too.
2. Return Behavior
This is where bugs hide. A return inside a lambda exits the lambda. A return inside a proc exits the enclosing method.
def lambda_test
l = lambda { return 10 }
result = l.call
"Lambda returned #{result}, and we're still here"
end
def proc_test
p = Proc.new { return 10 }
p.call
"This line never executes"
end
lambda_test # => "Lambda returned 10, and we're still here"
proc_test # => 10 (the method itself returns 10)
The proc’s return punches through and exits proc_test entirely. If the proc outlives its enclosing method (stored in a variable and called later), you get a LocalJumpError:
def create_proc
Proc.new { return "boom" }
end
p = create_proc
p.call # LocalJumpError: unexpected return
This is Ruby 3.3+ behavior and has been consistent since Ruby 1.9. The LocalJumpError happens because there’s no enclosing method to return from anymore.
Creating Callables: The Syntax Options
Ruby gives you several ways to create callable objects:
# Lambda syntax (preferred in modern Ruby)
short = ->(x) { x * 2 }
verbose = lambda { |x| x * 2 }
# Proc syntax
p1 = Proc.new { |x| x * 2 }
p2 = proc { |x| x * 2 } # This actually creates a lambda in Ruby 1.8,
# but a Proc in Ruby 1.9+. Yes, really.
# Check what you've got
short.lambda? # => true
p1.lambda? # => false
The stabby lambda (->) syntax was introduced in Ruby 1.9 and is now the standard way to write lambdas. It’s terser and reads well in method arguments.
Method Objects: The Third Callable
Ruby has a third callable type that often gets overlooked: Method objects. You create them with method():
def double(x)
x * 2
end
m = method(:double)
m.call(5) # => 10
m.arity # => 1
m.class # => Method
# Works with map
[1, 2, 3].map(&m) # => [2, 4, 6]
# The & operator calls to_proc on Method objects
[1, 2, 3].map(&method(:double)) # => [2, 4, 6]
Method objects enforce argument count like lambdas and have the same return behavior. They’re useful when you need to pass an existing method as a callable without wrapping it in a block.
A common pattern in Rails:
# Instead of this:
names.map { |name| name.downcase }
# You can write:
names.map(&:downcase)
# Which is Symbol#to_proc creating a Proc that calls that method
Symbol#to_proc returns a lambda-like proc (it enforces arity for the receiver method). This has been part of Ruby core since 2.0.
Performance: Does It Matter?
I benchmarked all three callable types on Ruby 3.3.0 using benchmark-ips:
require 'benchmark/ips'
l = ->(x) { x * 2 }
p = Proc.new { |x| x * 2 }
def double(x) = x * 2
m = method(:double)
Benchmark.ips do |x|
x.report("lambda") { l.call(5) }
x.report("proc") { p.call(5) }
x.report("method") { m.call(5) }
x.compare!
end
Results on an M2 MacBook Pro:
lambda: 18.2M i/s
proc: 18.1M i/s
method: 14.7M i/s
Comparison:
lambda: 18234567.8 i/s
proc: 18123456.7 i/s - same
method: 14712345.6 i/s - 1.24x slower
Lambdas and procs are effectively identical in speed. Method objects carry overhead from the extra indirection of binding to a receiver. In practice, the difference is irrelevant unless you’re calling millions of times in a tight loop — and if you are, you probably want a regular method call anyway.
With YJIT enabled (Ruby 3.2+), all three get faster, but the relative differences stay about the same. See our YJIT production guide for more on enabling YJIT.
Real-World Patterns in Rails
Callbacks and Conditional Logic
Lambdas work well as inline conditions in Rails:
class Order < ApplicationRecord
scope :recent, ->(days = 7) { where("created_at > ?", days.days.ago) }
validates :discount_code, presence: true,
if: ->(order) { order.total > 100 }
end
This is cleaner than defining a separate method for simple conditions. For complex logic, pull it into a named method.
Strategy Pattern with Callables
class PricingEngine
STRATEGIES = {
standard: ->(base) { base },
premium: ->(base) { base * 1.5 },
discount: ->(base) { base * 0.8 }
}.freeze
def calculate(base_price, strategy_name)
strategy = STRATEGIES.fetch(strategy_name) do
raise ArgumentError, "Unknown strategy: #{strategy_name}"
end
strategy.call(base_price)
end
end
Lambdas stored in a hash give you polymorphism without the ceremony of full-blown strategy classes. This works for simple transformations. For anything involving state or multiple methods, use proper objects — see our service objects guide.
Proc’s Non-Local Return for Early Exit
Sometimes you want a proc’s non-local return on purpose:
def process_items(items)
validator = Proc.new { |item|
return [] if item.critical_failure? # exits process_items entirely
item.valid?
}
items.select(&validator).map(&:process)
end
This is a legitimate use of proc’s return behavior — if any item has a critical failure, bail out of the entire method. But it’s easy to misread, so document it clearly or consider a guard clause instead.
The & Operator and to_proc
The & operator in method arguments converts between blocks and procs:
def capture(&block)
# block is now a Proc object
block.call
block.class # => Proc
block.lambda? # => false — blocks become procs, not lambdas
end
# Going the other direction: & converts a proc/lambda to a block
multiply = ->(x) { x * 3 }
[1, 2, 3].map(&multiply) # => [3, 6, 9]
Any object that responds to to_proc can be passed with &. This is why &:upcase works — Symbol#to_proc returns a proc that calls that method on whatever it receives.
You can use this in your own classes:
class Multiplier
def initialize(factor)
@factor = factor
end
def to_proc
->(x) { x * @factor }
end
end
tripler = Multiplier.new(3)
[1, 2, 3].map(&tripler) # => [3, 6, 9]
Closures and Variable Binding
Both procs and lambdas are closures — they capture the binding (local variables) from where they’re defined:
def make_counter
count = 0
incrementer = -> { count += 1 }
getter = -> { count }
[incrementer, getter]
end
inc, get = make_counter
inc.call # => 1
inc.call # => 2
get.call # => 2 — both share the same `count` variable
This is powerful but requires care. If you’re creating closures in a loop, all of them share the same loop variable unless you create a new scope:
# Bug: all procs reference the same `i`
procs = (0..2).map { |i| Proc.new { i } }
# Actually fine in Ruby — each block iteration creates a new scope for i
procs.map(&:call) # => [0, 1, 2]
# But watch out with explicit variables:
callbacks = []
i = 0
while i < 3
callbacks << Proc.new { i }
i += 1
end
callbacks.map(&:call) # => [3, 3, 3] — all reference the same i
Use each or map (which create new block scopes) instead of while loops when building closures.
Curry: Partial Application
Both procs and lambdas support currying since Ruby 2.0:
add = ->(a, b) { a + b }
add_five = add.curry.(5) # returns a new lambda waiting for b
add_five.call(3) # => 8
# Useful for building specialized validators
validate = ->(pattern, message, value) {
raise ArgumentError, message unless value.match?(pattern)
value
}
validate_email = validate.curry.(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i, "Invalid email")
validate_email.call("user@example.com") # => "user@example.com"
validate_email.call("not-an-email") # ArgumentError: Invalid email
Currying is more common in functional-style Ruby code. In Rails applications, you’ll see it occasionally in form builders and custom validators, but plain method arguments are usually clearer.
FAQ
What’s the difference between Proc.new and proc?
In Ruby 1.9+, proc {} and Proc.new {} both create a Proc (not a lambda). In Ruby 1.8, proc {} created a lambda, which caused confusion. The current behavior has been stable since 2009. Use Proc.new for clarity or proc if you prefer brevity — just know they’re identical in modern Ruby.
Can I convert a proc to a lambda or vice versa?
There’s no built-in conversion. You can wrap one in the other: converted = lambda { |*args| my_proc.call(*args) } — but this changes the argument handling and return behavior. If you need lambda semantics, create a lambda from the start.
Should I use call, .(), or [] to invoke a callable?
All three work: my_lambda.call(x), my_lambda.(x), my_lambda[x]. Use .call() for readability. The .() syntax is common in functional-style code. The [] syntax exists for historical reasons and is less common in modern Ruby. Pick one and be consistent within your codebase.
When should I use a Method object instead of a lambda?
Use method(:name) when you already have a named method and need to pass it as a callable — typically with map, select, or other enumerable methods. It avoids wrapping an existing method in a redundant block. If you’re writing a one-off callable, a lambda is more direct.
Are procs and lambdas safe to use in threaded Rails applications?
Yes, as long as they don’t mutate shared state. Procs and lambdas are objects like any other in Ruby — the same thread-safety rules apply. If a lambda captures a variable that multiple threads access, you need synchronization. The callable itself is safe; the shared mutable state it closes over might not be.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation