The letter A styled as Alchemists logo. lchemists
Published August 1, 2023 Updated July 18, 2026
Cover
Ruby Object Inspection

Ruby object inspection is inherent to all objects — except BasicObject — via the humble Object#inspect method. This allows you to see a human readable representation of the object you are working with along with the instance variables and/or injected dependencies that your object consists of. Even better, you can override any of your objects' #inspect method to best display relevent information for inspection and/or debugging purposes.

Let’s learn how you can make use of inspection within your own objects.

Basics

Native object inspection is handled via the following methods:

  • Object#inspect: A public method available to all objects except BasicObject.

  • Kernel#instance_variables_to_inspect: A private — introduced in Ruby 4.0.0 — which allows you to specify what instance variables you want to include.

You want to use one of the above methods, but not both. Let’s look at both next.

Inspect

Implementing your own #inspect method is the most common way to customize object inspection. For example, consider the following object:

class Person
  def initialize name:, handle:
    @name = name
    @handle = handle
  end
end

Person.new(name: "Mork", handle: "mork").inspect
#<Person:0x000000010e7b2960 @handle="mork", @name="Mork">

Notice how Ruby gives us object inspection by default. To modify the implementation, we’d only have to override the #inspect method as follows:

class Person
  def initialize name:, handle:
    @name = name
    @handle = handle
  end

  def inspect = "#<Person name=#{@name} handle=#{@handle}>"
end

Person.new(name: "Mork", handle: "mork").inspect
#<Person name=Mork handle=mork>

As you can see, we have customized the output of the inspect method. While useful for demonstration purposes, you wouldn’t want to use this in practice. This’ll be explained in more detail shortly.

Instance Variables To Inspect

What if you don’t want to override the #inspect method but only include specific attributes? The answer is to use the #instance_variables_to_inspect method as follows:

class Person
  def initialize name:, handle:
    @name = name
    @handle = handle
  end

  private

  def instance_variables_to_inspect = [:@handle]
end

Person.new(name: "Mork", handle: "mork").inspect
#<Person:0x000000012cdfdd88 @handle="mork">

Notice only the handle variable is included when inspecting. This easier than overriding the #inspect. You could also exclude all attributes by using an empty array (not recommended):

class Person
  # Truncated for brevity...

  def instance_variables_to_inspect = []
end

puts Person.new(name: "Mork", handle: "mork").inspect
#<Person:0x000000012eb6e2a0>

Use of #instance_variables_to_inspect is great for simple filtering but there are several issues with this approach:

  • Only works for classes, not Structs or Data objects.

  • Only allows you to filter attributes but doesn’t allow you to transform the output further.

  • Must always symbolize your instance variables (example: :@variable). This is ugly and would be much nicer to use bare symbols (example: :variable).

To solve the above limitations, use the Inspectable gem which provides advanced customization with minimal effort. We’ll take a closer look at the Inspectable gem later but, first, let’s look at more a few more examples of object inspection to paint a broader picture.

Examples

To set the stage, we can study the behavior of a few primitives via the IRB console:

require "bigdecimal"
require "json"

1                               # "1"
1.5                             # "1.5"
Rational 1, 5                   # "(1/5)""
BigDecimal 1                    # "0.1e1"
Time.now.utc                    # "2023-07-25 12:30:56.892912 UTC"
"Mork"                          # "Mork"
%w[Mork Mindy]                  # ["Mork", "Mindy"]
{name: "Mork", handle: "mork"}  # {name: "Mork", handle: "mork"}

JSON name: "Mindy", handle: "mindy"
# "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"

# Data (named)
User = Data.define :name, :handle
User[name: "Mork", handle: "mork"]
#<data User name="Mork", handle="mork">

# Data (anonymous)
Data.define(:name, :handle).new name: "Mork", handle: "mork"
#<data name="Mork", handle="mork">

# Struct (named)
Contact = Struct.new(:name, :handle)
Contact[name: "Mindy", handle: "mindy"]
#<struct Contact name="Mindy", handle="mindy">

# Struct (anonymous)
Struct.new(:name, :handle).new name: "Mindy", handle: "mindy"
#<struct name="Mindy", handle="mindy">

class Person
  def initialize name:, handle:
    @name = name
    @handle = handle
  end
end

Person.new name: "Mork", handle: "mork"
#<Person:0x000000010e7b2960 @handle="mork", @name="Mork">

The key takeaways from the above code are:

  • IRB, by default, implicitly messages #inspect on each of the above examples. This is an excellent quality of life improvement since you don’t have to manually type .inspect each time you interact with an object.

  • Each primitive provides a nice, human readable string representation of an Integer, Float, Rational, BigDecimal, Time, String, Array, Hash, JSON, Data, Struct, and Class.

  • For most primitives, no additional type information is included because the string representation implicitly includes those details. With Data, Struct, and Class objects, you begin to see type information because, without, you could easily confuse Data, Struct, and Hash as the same type when they definitely are not.

  • Only with a Class do you finally see type and memory address information because an instance of a Class is not a whole value object so equality is determined by the object ID in memory which is important to know when you have multiple instances of the same class in use.

Implicit versus Explicit

Implicit versus explicit inspection varies depending on context. With IRB, we get implicit object inspection by default. This makes sense since because IRB provides an environment to experiment with Ruby code with minimal hassle. This is not always true with other methods. Consider the following:

require "amazing_print"
require "json"

json = JSON name: "Mindy", handle: "mindy"

p json     # "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
pp json    # "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
ap json    # "{\"name\":\"Mindy\",\"handle\":\"mindy\"}"
puts json  # {"name":"Mindy","handle":"mindy"}

The pretty print methods, p, pp, and ap (short for Amazing Print) make implicit use of #inspect while puts is meant for printing to standard output so #to_s is messaged instead of #inspect. Taken a step further, this dance between #inspect and #to_s plays nicely when using the Refinements gem. Example:

require "refinements"

using Refinements::Array

demo = ["apple", :blueberry]

puts demo.to_sentence  # apple and blueberry
puts demo.to_usage     # "apple" and :blueberry

With the Array#to_sentence refinement, we get a string representation (i.e. #to_s) of apple and blueberry which is perfect for sentence construction where type information isn’t necessary and more akin for display within documentation or a user interface.

On the flip side, the Array#to_usage uses inspection (i.e. #inspect) which gives you a sentence with type information which is perfect for error messages because knowing the difference between a string and symbol speeds up the debugging process. Without inspection, you’d not be able to intuit the wrong argument was used since only seeing apple or blueberry in the output misses the subtle difference between using a String or Symbol depending on your input requirements. Little touches like this can make a world of difference.

Customization

As powerful as Object#inspect is there are times where you might need to customize and manually override default behavior. Here are a few use cases:

  • The default format of @key=value doesn’t fit the aesthetic of the object being inspected so customization provides clarity.

  • The default behavior is too verbose so you need to simplify and reduce the amount of information provided to the bare essentials.

Let’s dive into each of the above. We can experiment with our Person class from earlier:

class Person
  attr_reader :name, :handle

  def initialize name: "Mork", handle: "mork"
    @name = name
    @handle = handle
  end
end

For simplicity, only each modified implementation with comments and corresponding result is shown below:

# Default behavior as a starting reference.
Person.new.inspect

# "#<Person:0x00000001160919f8 @name=\"Mork\", @handle=\"mork\">"
# Equivalent to the above by using Object ID with same formatting.
def inspect
  format "#<%<class>s:%<id>#018x @name=%<name>s @handle=%<handle>s>",
         class: self.class,
         id: object_id << 1,
         name: name.inspect,
         handle: handle.inspect
end

# "#<Person:0x0000000000000f60 @name=\"Mork\" @handle=\"mork\">"

💡 The bitwise left shift (i.e. object_id << 1) simulates type tagging because Ruby, internally, uses the least significant bit.

# Equivalent to the above using metaprogramming.
def inspect
  pattern = +""
  values = []

  instance_variables.each do |name|
    pattern << "#{name}=%s "
    values.push instance_variable_get(name).inspect
  end

  format "#<%s:%#018x #{pattern.strip}>", self.class, object_id << 1, *values
end

# "#<Person:0x00000000000012a0 @name=\"Mork\" @handle=\"mork\">"
# Equivalent to the above by only using Object ID (format is inconsistent, though).
def inspect = "#<#{self.class}:#{object_id} @name=#{name.inspect} @handle=#{handle.inspect}>"

# "#<Person:1896 @name=\"Mork\" @handle=\"mork\">"
# No ID. Discouraged.
def inspect = "#<#{self.class} @name=#{name.inspect}, @handle=#{handle.inspect}>"

# "#<Person @name=\"Mork\", @handle=\"mork\">"
# No type. Strongly discouraged.
def inspect = "#<@name=#{name.inspect}, @handle=#{handle.inspect}>"

# "#<@name=\"Mork\", @handle=\"mork\">"
# No `@` symbols or commas. Discouraged but acceptable depending on context.
def inspect = "#<#{self.class} name=#{name.inspect} handle=#{handle.inspect}>"

# "#<Person name=\"Mork\" handle=\"mork\">"
# Different format but has distinct and parsable structure.
def inspect = "#{self.class}: name=#{name.inspect} handle=#{handle.inspect}"

# "Person: name=\"Mork\" handle=\"mork\""

The above illustrates a few best practices and antipatterns. We can take this a step further by looking at the XDG gem which breaks the mold in a useful way that is appropriate for the environment XDG operates in.

XDG, for the unfamiliar, is a powerful directory specification for organizing your Dotfiles as well as general cache, configuration, state, data, and runtime information. The key concept to be aware of is XDG is purely environment focused which means XDG can represent itself as such. Example:

require "xdg"

xdg = XDG.new
xdg.inspect

# "#<XDG::Environment:7220
#    XDG_CACHE_HOME=/Users/demo/.cache
#    XDG_CONFIG_HOME=/Users/demo/.config
#    XDG_CONFIG_DIRS=/etc/xdg
#    XDG_DATA_HOME=/Users/demo/.local/share
#    XDG_DATA_DIRS=/usr/local/share:/usr/share
#    XDG_STATE_HOME=/Users/demo/.local/state
# >"

The key takeaways are:

  • Attribute keys are represented in upcase and snake case to match environment syntax.

  • Each key/value pair is delimited by an equals sign to match environment syntax.

  • Each key/value pair is separated by a space to match environment syntax. This also means you can more easily copy and paste for direct use in your own environment if desired.

A final example has to do with nested objects in situations where the object you are inspecting is composed of several dependencies. Those dependencies will each be inspected when you inspect the primary object so we need to be careful we are not overly verbose. If we use the Sod gem --- a Domain Specific Language (DSL) for Command Line Interfaces (CLIs) --- we can see how this plays out:

require "sod"

class Demo < Sod::Action
  description "A demo."

  on "--demo"

  def call(*) = "For demonstration purposes only."
end

puts Demo.new.inspect
# <Demo:1200
#  @context=#<Sod::Context:0x00000001178e2b60>
#  aliases=["--demo"],
#  argument=nil,
#  type=nil,
#  allow=nil,
#  default=nil,
#  description="A demo.", ancillary=[]
# >

Notice there is an embedded context along with various attributes that define the CLI. The implementation looks like this:

def inspect
  attributes = record.to_h.map { |key, value| "#{key}=#{value.inspect}" }
  %(#<#{self.class}:#{object_id} @context=#{context.inspect} #{attributes.join ", "}>)
end

The reason the custom #inspect method is provided is because without it, you’d get the following output instead:

# <Demo:0x000000012e0562a0
#  @context=#<Sod::Context:0x000000012e053640>,
#  @record=#<data Sod::Models::Action
#    aliases=["--demo"],
#    argument=nil,
#    type=nil,
#    allow=nil,
#    default=nil,
#    description="A demo.",
#    ancillary=[]>
# >

Notice the extra verbosity of the record Data object. Despite using an internal Data object within the Sod::Action instance, this detail is not relevant for inspection purposes. Only the attributes that make up the record are, albeit minor this detail may seem. By only focusing on the attributes, not the record itself, this slims down what you have to sift through when looking at multiple actions at once. The reduced verbosity makes a big difference while not losing relevant details.

Encodings

The string output of your inspect object must always have compatibility encoding. All this means is that the encoding needs to match the default encoding. Example:

# Yes
Encoding.default_external   # #<Encoding:UTF-8>
"demo".encoding             # #<Encoding:UTF-8>

# No
Encoding.default_external   # #<Encoding:UTF-8>
demo = "A test: 1021."
demo.encoding               # #<Encoding:UTF-8>
demo.valid_encoding?        # false
demo.inspect                # ""A test: \x88\x91.""

Generally, this shouldn’t be a problem but can crop up when dealing with different languages or input sources to your application.

Inspectable

In situations where you need customization beyond what Object#inspect provides, like specific instance variable inclusion or transformation, then use the Inspectable gem. Inspectable provides an elegant solution which, in most cases, only requires a line of code to get the behavior you need without having to write custom code yourself. Here’s a quick example:

class Demo
  include Inspectable[token: :redact]

  def initialize token: "secret", uri: "https://demo.io"
    @token = token
    @uri = uri
  end
end

Demo.new.inspect
"#<Demo:0x0000000000000ea0 @token=\"[REDACTED]\", @uri=\"https://demo.io\">"

Inspectable not only handles the #inspect method but also takes care of the #instance_variables_to_inspect as well. See the gem documentation to learn more.

Guidelines

Before wrapping up, the following guidelines are important to adhere to when implementing object inspection:

  1. Must be a string representation of the object’s class name, memory address, and internal attributes (this can vary depending on how verbose or concise the details need to be).

  2. Must have the ability to override default inspection with custom inspection as best appropriate for your object.

  3. Must use string encoding that matches default encoding. This means if UTF-8 is the default encoding then the string answered back, when inspecting your object, must be UTF-8 too.

  4. Never use required parameters because that violates the Liskov Substitution Principle since the superclass (Object) requires no parameters.

Please adhere to the above in order to avoid surprising behavior when inspecting your objects.

Conclusion

No matter which path you take — native, custom, or using a gem like Inspectable — you have the ability to tailor object inspection to your needs, reduce verbosity, and/or protect sensitive information. Object inspection is powerful when applied judiciously. Otherwise, you can use default behavior which Ruby provides for free!