Understanding Ruby Blocks, Procs, and Lambdas: Closures in Practice
How Ruby implements closures, execution contexts, and the critical differences between Procs and Lambdas.
1. The Implicit Block and the 'yield' Keyword
Passing behavior without object overhead
In Ruby, every method can accept an implicit block without declaring it in its parameter list. When a block is passed (using do...end or curly braces {}), the method can transfer execution to that block using the yield keyword.
Invoking yield without checking whether a caller actually provided a block results in a LocalJumpError ('no block given'). Defensive Ruby code always verifies presence using block_given? before yielding.
# Safe block invocation pattern
def with_timing(operation_name)
return unless block_given?
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = yield
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
Rails.logger.info("[BENCHMARK] #{operation_name} completed in #{duration.round(4)}s")
result
end
# Usage:
data = with_timing("Fetch Remote Payload") do
ExternalApiClient.fetch_latest_records
end2. The Critical Differences: Proc vs Lambda
Arity rules and the danger of unexpected method returns
Both Proc and Lambda are instances of the Proc class, but their execution semantics differ in two fundamental ways: argument enforcement (arity) and control flow (the return statement).
A Proc created with Proc.new ignores surplus arguments and fills missing ones with nil. A Lambda created with lambda {} or ->(x) { x } enforces exact arity and raises an ArgumentError if the argument count mismatches.
Even more critically, writing 'return' inside a Proc attempts to return from the scope where the Proc was originally defined. If that enclosing scope has already exited, Ruby raises a LocalJumpError. Inside a Lambda, 'return' exits only the lambda itself and control returns to the caller.
# Return inside a Proc terminates the ENCLOSING method
def test_proc_return
p = Proc.new { return "Early exit from Proc" }
p.call
"This line will NEVER execute"
end
puts test_proc_return # => "Early exit from Proc"
# Return inside a Lambda exits only the lambda
def test_lambda_return
l = -> { return "Lambda exit" }
result = l.call
"Enclosing method continues. Result was: #{result}"
end
puts test_lambda_return # => "Enclosing method continues. Result was: Lambda exit"3. Real-World Rails Patterns: Around Actions and Custom DSLs
Managing database transactions and tenant contexts
Rails controllers and background jobs utilize blocks to wrap requests with environmental context, such as multi-tenant switching or database read replica routing.
When implementing an around_action in Rails, the controller invokes the action via block yield, ensuring cleanup logic runs reliably even if exceptions occur during action execution.
class ApplicationController < ActionController::Base
around_action :scope_current_tenant
private
def scope_current_tenant
tenant_id = request.headers["X-Tenant-ID"] || current_user&.tenant_id
Current.tenant = Tenant.find(tenant_id)
# Yield control to the controller action
yield
ensure
# Guarantee tenant cleanup to prevent thread leakage
Current.reset
end
endRelated Developer Tools & Products
Continue Reading
All Articles →Why Idempotency Matters When a User Clicks 'Pay' Twice
When a client sends a payment request and experiences a network drop, retrying blindly can result in duplicate transactions. Here is how idempotency keys and atomic locking solve this permanently.
Rails N+1 Queries Explained: Elimination, Preload, and Strict Loading
Understand how N+1 query patterns emerge in ActiveRecord, the nuanced differences between preload, eager_load, and includes, and how strict_loading prevents production regressions.