rubyguides

Kernel#local_variables

local_variables -> [symbol]

The local_variables method returns an array of all local variable names defined in the current scope. It’s useful for debugging, metaprogramming, and understanding variable scope.

Basic usage

# In current scope
name = "Alice"
age = 30
city = "NYC"

puts local_variables  # => [:name, :age, :city]

Each method call creates its own local variable table, so the names reported by local_variables depend entirely on where you call it. The examples below show how the output changes across methods, blocks, and conditional branches — three situations where the visible variable names differ in ways that are worth understanding before you rely on this method in debugging code.

Practical examples

Each method call creates its own local variable table, so the names reported by local_variables depend entirely on where you call it. The examples below show how the output changes across methods, blocks, and conditional branches.

Debugging

def inspect_locals
  puts "Local variables: #{local_variables.inspect}"
end

name = "test"
inspect_locals  # Shows: [:name]

When you wrap local_variables in a debugging helper, the helper sees only its own local variables unless you pass a Binding object from the caller’s scope. This is a common gotcha when building introspection tools — the inspect_locals method above reports [:name] because name is defined in the top-level scope visible to the method call, but any variables local to inspect_locals itself would also appear in the output.

Dynamic variable handling

# Set variables dynamically
local_variables.each do |var|
  value = binding.local_variable_get(var)
  puts "#{var} = #{value}"
end

Scope Inspection

Ruby methods isolate their local variables from the caller. Variables defined at the top level or in an outer scope are not visible inside a method body unless passed as arguments or captured through a block:

x = 1
y = 2

def method_with_own_scope
  z = 3
  puts local_variables  # Only shows :z
end

method_with_own_scope
puts local_variables  # Shows :x, :y

Variable Capture

Combining local_variables with binding.local_variable_get lets you serialize the entire local state into a hash. This is useful for debugging helpers and REPL tools that need a snapshot of the current scope:

def capture_variables
  captured = {}
  local_variables.each do |var|
    captured[var] = binding.local_variable_get(var)
  end
  captured
end

name = "Alice"
age = 30
capture_variables  # => {:name=>"Alice", :age=>30}

Within Blocks

Blocks introduce their own local variable scope. Variables defined inside a block by assignment are not visible after the block ends:

# Blocks create new scope for local variables
x = 10
local_variables  # => [:x]

3.times do |i|
  y = i
end

# y not visible here (different scope)
puts local_variables  # => [:x]

Conditional Variables

Ruby parses local variable assignments at parse time, not at runtime. A variable assigned inside an if false branch is still visible to local_variables:

if false
  secret = "hidden"
end

puts local_variables  # [:secret] - variable exists but is nil!

The parse-time detection of assignments means local_variables can report names that have never been assigned a real value. This quirk is worth keeping in mind when using the method for introspection in code with conditional branches.

When you combine local_variables with binding.local_variable_get, you can build compact debug helpers that dump every local variable in scope without hard-coding the names. This is useful for logging, error reports, and development tools that need to surface runtime state.

Debugging Helpers

def debug_locals
  local_variables.each do |var|
    value = binding.local_variable_get(var)
    puts "#{var.inspect}: #{value.inspect}"
  end
end

The local_variables method also works on Binding objects captured with Kernel#binding. This lets you inspect the variable state of a different scope from the one you are currently in, which is essential for building metaprogramming tools and debuggers that need visibility across scope boundaries.

Binding Integration

# Use with Binding objects
b = binding
x = 5
puts b.local_variables  # => [:x]

# From other scopes
def get_binding
  local_var = "hello"
  binding
end

b = get_binding
puts b.local_variables  # => [:local_var]

Common use cases

  • Debugging variable states
  • Building REPL tools
  • Serializing scope
  • Dynamic method creation
  • Template engines

A concrete example of serializing scope into a portable form:

def snapshot
  local_variables.each_with_object({}) { |v, h| h[v] = binding.local_variable_get(v) }
end

x, y = 10, 20
snapshot  # => {:x=>10, :y=>20}

Inspecting scope carefully

local_variables is most helpful when you need to understand what names are visible, not when you want the values themselves. That makes it a good fit for debugging tools, console helpers, and reflective code that needs to map a scope before it reads from binding. It also reminds you that methods, blocks, and top-level code do not all expose the same names. When you use it in production code, keep the intent narrow so the inspection stays readable and does not become its own source of confusion. A second useful habit is to pair the name list with targeted lookups, so the code shows both what exists and why it matters.

Using bindings for values

Once you have the names, binding.local_variable_get is the bridge that turns a list of symbols into actual data. That can help with REPL-style tooling, data exporters, and test helpers that need to inspect state without hard-coding variable names. The key is to treat the reflection as a small, focused step rather than a general purpose shortcut. If a method starts depending on many hidden names, it is usually a sign that an explicit object or hash would be easier for future readers to follow.

Another helpful pattern is to keep the inspection at the edge of the code and the business logic in the middle. That way the reflective step stays easy to replace if the implementation changes later. The scope query can then do one job well: tell you what is visible right now, without becoming the main way the program moves data around.

That balance is especially useful in tooling and debug helpers, where the point is to surface the state, not to own it. When the scope lookup stays small, the rest of the code can still use ordinary objects and method calls, which are easier to understand once the debugging need has passed.

See Also