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

Ruby object inspection is inherent to all objects — except BasicObject — for displaying information about the object you are working with. As per the official documentation, Object#inspect is meant for:

[Answering] a string containing a human-readable representation of [the object]. The default inspect shows the object’s class name, an encoding of its memory address, and a list of the instance variables and their values (by calling inspect on each of them). User defined classes should override this method to provide a better representation of [object]. When overriding this method, it should return a string whose encoding is compatible with the default external encoding.

Put another way, this means:

  1. 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. The ability to override default inspection with custom inspection as best appropriate for your object. There are some guidelines to be aware of that I’ll touch upon soon.

  3. String encoding must match default encoding which means if UTF-8 is the default encoding then the string answered back when inspecting an object must be UTF-8 too.

This article will take a closer look at each bullet point since you use object inspection every day whether you are conscious of it or not. For example, working in an IRB console, debugging, reading logs, reading stack dumps, and so much more.


Let’s set the stage by looking at 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.5)                 # 0.15e1                    # 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.define(:name, :handle).new name: "Mork", handle: "mork"
#<data  name="Mork", handle="mork">, :handle).new name: "Mindy", handle: "mindy"
#<struct  name="Mindy", handle="mindy">

class Person
  attr_reader :name, :handle

  def initialize name:, handle:
    @name = name
    @handle = handle
end name: "Mork", handle: "mork"
#<Person:0x000000010e7b2960 @handle="mork", @name="Mork">

There are a few key takeaways to point out in the above code:

  • The IRB console, 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 explore and 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::Arrays

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 that is perfect for error messages because knowing the difference between a string and symbol can speed up debugging when knowing the input type is wrong. 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.


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 or match the aesthetic of object being inspected so customization can provide clarity.

  • 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

For simplicity, I’ll only show each modified implementation with comments and corresponding result:

# Default behavior as a starting reference.

# "#<Person:0x00000001160919f8 @name=\"Mork\", @handle=\"mork\">"
# Equivalent to the above using Object ID.
# (memory address isn't available via Ruby but is available via C).
def inspect = "#<#{self.class}:#{object_id} @name=#{name.inspect} @handle=#{handle.inspect}>"

# "#<Person:1540 @name=\"Mork\" @handle=\"mork\">"
# Equivalent to the above using metaprogramming.
def inspect
  attributes = instance_variables.reduce [] do |info, attribute|
    info.append "#{attribute}=#{instance_variable_get(attribute).inspect}"

  %(#<#{self.class}:#{object_id} #{attributes.join ", "}>)

# "#<Person:1540 @name=\"Mork\", @handle=\"mork\">"
# No ID.
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}>"

s# "#<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\""

As you can see, 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_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

There are a few key takeaways with the above:

  • No type or ID information is used because the XDG key prefix identifies the information as an XDG object.

  • The 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 object for situations where the object you are inspecting is composed of several dependencies. Those dependencies will each be inspected when you inspect the primary object as noted in the Object#inspect specification shown above. As you can imagine, this can quickly become too 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."

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

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

def inspect
  attributes = { |key, value| "#{key}=#{value.inspect}" }
  %(#<#{self.class} @context=#{context.inspect} #{attributes.join ", "}>)

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, 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.


The last bullet point of the Object#inspect specification mentions encoding compatibility. 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.


Hopefully this has been helpful in learning more about object inspection and provides you with practical examples of how to not only use inspection but customize as make sense for your application. Enjoy!