Ruby Introspection and Reflection: Inspecting Objects at Runtime
Ruby introspection and reflection give you direct access to an object’s structure and behavior at runtime. Ruby’s object model is unusually transparent: because classes are themselves objects, and everything from strings to integers to modules is an object, you can ask any object about itself at runtime. This capability falls into two distinct categories: introspection (examining what is there) and reflection (changing or invoking what is there). Understanding the difference matters, and knowing which tools to reach for will save you from reaching for eval when you do not need it.
intro context
The easiest way to think about these tools is to separate observation from action. Introspection asks questions, reflection answers them by doing something. That distinction keeps your code safer because you can inspect objects first and only act when the object actually supports the behavior you want.
This is also one of the most useful metaprogramming habits in Ruby. When you can inspect methods, variables, and ancestors directly, you do not have to guess how an object is built. That makes debugging, logging, and framework code much more practical.
Introspection: asking what’s there
Introspection is read-only discovery. You inspect an object’s structure without changing anything. This is the mode you want for debugging, serialization, building admin panels, or any tool that works with objects it didn’t write.
Discovering Methods with methods and instance_methods
The most common introspection task is asking “what can this object do?” Every object in Ruby responds to methods, which returns every public method callable on it.
"hello".methods
# => [:+, :*, :split, :gsub, :upcase, :downcase, :to_s, :inspect, ...]
"hello".methods.include?(:upcase)
# => true
You can narrow the scope with protected_methods and private_methods. The methods call on "hello" returns every available method, including those inherited from Object, Kernel, and BasicObject. That list is long, so for practical work you usually want to filter it with grep or select to find what you need.
When the question is about a class’s interface rather than a specific instance, Module#instance_methods gives you a cleaner view. It lists the instance methods that any object of that class would respond to.
Array.instance_methods
# => [:[], :<<, :push, :pop, :map, :select, :first, :last, :empty?, ...]
Array.instance_methods(false)
# => [:[], :<<, :push, :pop, :first, :last, :empty?] # only Array's own methods
Passing false to instance_methods excludes inherited methods, giving you only what that specific class defined. This distinction matters when you’re building something that needs to know the “own” methods of a class, not the full ancestral interface.
Checking what an object responds to
Before you call a method, especially when working with duck-typed APIs, you want to know if it’s safe. Object#respond_to? answers that directly.
[1, 2, 3].respond_to?(:map)
# => true
[1, 2, 3].respond_to?(:to_s)
# => true
obj.respond_to?(:process) # do something conditional on this
By default respond_to? only checks public methods. Pass true as the second argument to include private methods in the check. This second argument matters when you are building tools that need to call private helpers like initialize or framework internals. Knowing whether a method is public or private helps you avoid calling internal APIs that might change without notice in a future release of the library or framework:
[1, 2, 3].respond_to?(:to_s, true)
# => true (Object#to_s is a private method on Array's ancestor)
Inspecting instance variables
An object’s state lives in its instance variables. Object#instance_variables lists what’s currently set on an instance.
class Dog
def initialize(name, breed)
@name = name
@breed = breed
end
end
buddy = Dog.new("Buddy", "Labrador")
buddy.instance_variables
# => [:@name, :@breed]
One gotcha: only assigned instance variables appear. If you define an attr_accessor but never assign to it, that variable won’t show up in instance_variables. This matters for ORMs and serializers that try to persist “all” fields.
Module#class_variables does the same for class-level variables:
class Vehicle
@@wheels = 4
end
class Car < Vehicle
@@doors = 4
end
Car.class_variables
# => [:@@doors, :@@wheels]
Class variables are inherited, so Car.class_variables includes @@wheels from Vehicle. Class variables are shared across the inheritance hierarchy, which can lead to surprising behavior if a subclass accidentally overwrites a value the parent depends on. That is one reason many Ruby developers prefer class instance variables (@var defined on the class object itself) over @@var.
Constants with Module#constants
Every module holds constants. Module#constants returns them as an array of symbols. This is useful for discovering configuration values, version strings, or any named value stored inside a module.
Math.constants
# => [:PI, :E]
JSON.constants(false)
# => [:STATE_TABLE, :VERSION] # only what JSON itself defined
Pass false to exclude constants from included modules. This is useful when you want the “own” constants of a module without the noise from Kernel or Object.
The ancestor chain with Class#ancestors
Understanding Ruby’s method lookup requires knowing the ancestor chain. Class#ancestors walks the inheritance tree and included modules.
String.ancestors
# => [String, Comparable, Object, Kernel, BasicObject]
This is critical for understanding what will happen when you call a method. If String#upcase doesn’t exist on String itself, Ruby looks at Comparable, then Object, then Kernel, and finally BasicObject. This chain is also what makes mixins work: Comparable is a module, not a class, but it appears in String’s ancestor chain because String includes it.
Reflection: changing and invoking at runtime
Reflection goes beyond asking questions. It lets you invoke methods, read and write constants, and even define new behavior dynamically. This is where Ruby’s metaprogramming gets its real power.
Calling methods with Object#send
The most common reflection tool is Object#send. It invokes a method by name, accepting a symbol or string and any arguments.
class Calculator
def add(a, b)
a + b
end
private
def secret_formula(n)
n * 42
end
end
calc = Calculator.new
calc.send(:add, 3, 4)
# => 7
calc.send(:secret_formula, 10)
# => 420 (send bypasses private visibility — use carefully)
Because send works with method names as values, it’s the bridge between introspection (finding that a method exists) and actually calling it. Serialization libraries, test doubles, and RPC frameworks all depend on this. The private method access is worth noting: send will call private methods that normal dot-notation cannot. That’s powerful and dangerous in equal measure.
Reading and writing constants dynamically
Module#const_get retrieves a constant by name within a module’s scope:
Module.const_get(:String)
# => String
Math.const_get(:PI)
# => 3.141592653589793
Rails’ autoloader uses this to look up classes from configuration strings; when you reference a class name as a string, const_get resolves it.
Module#const_set does the opposite: it creates or overwrites a constant on a module:
MyModule = Module.new
MyModule.const_set(:DEFAULT_LIMIT, 100)
MyModule::DEFAULT_LIMIT
# => 100
Code generation tools use const_set to build classes dynamically. This is how ORMs create association methods, how service objects register themselves with a container, and how testing frameworks build mock classes on the fly.
Checking what exists before acting
Module#method_defined? returns a boolean for whether a module or class defines a specific instance method:
String.method_defined?(:upcase)
# => true
String.method_defined?(:non_existent)
# => false
String.method_defined?(:upcase, false)
# => false (String itself doesn't define it — it's inherited from Object)
Pass false as the second argument to exclude inherited methods. This is cleaner than comparing instance_methods(false) arrays when you just need a yes/no answer.
Introspection in Practice
Knowing these methods is one thing. Here’s where they actually show up in real Ruby code.
Serialization
YAML and JSON serialization libraries use introspection to convert any object to a hash or string representation without needing to know the class in advance.
require "json"
class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
end
def to_h
# Introspection: find what "accessor" methods this class defines
fields = self.class.instance_methods(false) & [:x, :y]
Hash[fields.map { |f| [f, send(f)] }]
end
end
Point.new(3, 4).to_h
# => {x: 3, y: 4}
The key here is using instance_methods(false) to find only the reader methods that were defined directly on Point, not inherited ones. Then send calls each one to get the current value.
Debugging Tools
Pry and byebug use introspection to show you the state of your program at a breakpoint. Here’s a simplified version of what Pry does when you inspect self:
def show_binding(binding)
binding.local_variables.each do |var|
value = binding.local_variable_get(var)
puts "#{var} = #{value.inspect}"
end
end
local_variable_get is itself a reflection method; it reads a local variable by name at runtime. local_variables lists all current local variables. Together they let you build a complete snapshot of the call site.
Flexible APIs with respond_to?
When you accept duck-typed objects, respond_to? guards against calling methods that don’t exist:
def process_item(item)
if item.respond_to?(:to_h)
# it's a hash-like object
item.to_h.each { |k, v| puts "#{k}: #{v}" }
elsif item.respond_to?(:each)
# it's an enumerable
item.each { |el| puts el }
else
puts item.to_s
end
end
This pattern appears throughout the Ruby standard library and gems. Hash#merge, Array#concat, and many other methods accept either a hash or something that quacks like one.
That kind of defensive branching is common in libraries that must accept many object shapes. respond_to? gives you a quick guardrail before you call a method, and it keeps a flexible API from becoming fragile.
Method stubs in tests
Testing frameworks like RSpec and Minitest use send and define_singleton_method to stub methods at runtime:
def stub(obj, method_name, return_value)
original = obj.method(method_name)
obj.define_singleton_method(method_name) { return_value }
yield
ensure
obj.define_singleton_method(method_name, original)
end
result = nil
stub([1, 2, 3], :sum, 999) do
result = [1, 2, 3].sum
end
result
# => 999
[1, 2, 3].sum
# => 6 (original method restored)
define_singleton_method defines a method only on that specific object, not on the class. Combined with send, this is the mechanism behind RSpec’s allow(obj).to receive(:method).
The big takeaway is that Ruby lets you inspect first and act second. That workflow is safer than guessing, and it gives you a clear place to add logging, allowlists, or fallback paths when a method is missing.
forward-link
If you want to see how Ruby shares behavior between classes before you start inspecting them, continue with Ruby Modules and Mixins. That tutorial shows how modules, includes, and extends fit into the larger metaprogramming picture.
The introspection/reflection line
The distinction isn’t always crisp in practice, and that’s fine. send sits right on the boundary; you discover a method name via introspection, then invoke it via reflection. instance_eval straddles it too: it introspects to find the context, then reflects to change what self means inside a block.
Where the line matters is safety. Introspection is always safe. Reflection, especially eval and const_set, introduces risk of surprising behavior. eval in particular is a last resort; if you can solve something with send, use send.
In day-to-day Ruby work, that usually means starting with introspection and only moving to reflection when you truly need dynamic behavior. A quick respond_to? check, a method list, or an ancestor lookup is often enough to solve the problem without changing state. When you do need to call or define something at runtime, keep the scope narrow so the metaprogramming stays understandable for the next person who reads the code.
That discipline is what keeps metaprogramming practical instead of magical. The more you can answer a question with methods, instance_variables, or respond_to?, the less likely you are to reach for a tool that changes behavior in ways you did not intend. In other words, introspection is the inspection step, and reflection should be the exception you use only when the inspection step is not enough.
# Bad: eval with user input — SQL injection analog for Ruby
eval(params[:code])
# Better: send with a known symbol
obj.send(params[:method].to_sym) # still risky without allowlisting
See Also
- Ruby method_missing deep dive; continuation of the Ruby Metaprogramming series
- Ruby metaprogramming basics; dynamic method definition,
define_method, and runtime code evaluation - Ruby classes and objects; foundational understanding of how Ruby objects work before exploring their reflective capabilities