skip to content

In Ruby 4.0, why is OpenStruct discouraged, and what happens when a Bundler app requires ostruct without a Gemfile entry?

level: middleimportance: should knowfreq 45%

answer

  1. method_missing plus singleton methods
  2. typo reads nil, not NoMethodError
  3. keys can shadow built-in methods
  4. bundled gem since Ruby 4.0
  5. LoadError under Bundler

basics

~20 s

OpenStruct defines accessors per instance via method_missing and singleton methods: slow, keys can shadow built-in methods, typos return nil. Since Ruby 4.0 ostruct is a bundled gem, so under Bundler its require fails without a Gemfile entry.

solid answer

~40 s

`OpenStruct.new(h)` stores values in an internal Hash and, for each key, defines a getter and setter with `define_singleton_method`, so every instance carries its own singleton methods; unknown readers fall through `method_missing`. The ostruct documentation warns that this can be around 200 times slower than reading a Hash, that keys can override built-ins (`OpenStruct.new(class: :x).class` returns `:x`), and that untrusted keys become method names. A misspelt reader returns `nil` instead of raising `NoMethodError`, hiding bugs. Ruby 3.0 made the discouragement official, and Ruby 4.0 moved ostruct from a default gem to a **bundled gem**: under Bundler, `require "ostruct"` warns that it is no longer part of the default gems and fails with `LoadError` unless the Gemfile lists `gem "ostruct"`. Use `Data.define`, `Struct.new` or a Hash instead.

code

ruby · 14 lines
ruby
# Gemfile (Ruby 4.0): needed only while legacy code still uses OpenStruct
gem "ostruct"

# app code
require "ostruct"
cfg = OpenStruct.new(timeout: 5, class: :premium)
cfg.timeout   # => 5
cfg.timout    # => nil, the typo is silent
cfg.class     # => :premium, the key shadows Object#class

# replacement for a fixed set of fields
Config = Data.define(:timeout, :tier)
conf = Config.new(timeout: 5, tier: :premium)
conf.timout   # NoMethodError, the typo is caught

go deeper

for a junior

Recall that OpenStruct is discouraged, that typos return nil, and that Data.define, Struct.new or a Hash are the usual replacements.

for a middle

Explain how method_missing and per-instance singleton methods make OpenStruct slow and let keys shadow built-in methods.

for a senior

Anticipate the Ruby 4.0 upgrade breaking Bundler apps that require ostruct without a Gemfile entry, and plan the migration away from it.

for a principal

Treat bundled-gem promotions as an upgrade checklist item and set a policy that keeps OpenStruct and similar dynamic objects out of new code.

## How OpenStruct works `OpenStruct` (from the `ostruct` library) looks like a Struct you do not have to declare: `cfg = OpenStruct.new(timeout: 5)` and then `cfg.timeout`. Under the hood: 1. The values live in an internal **Hash** (`@table`). 2. For each key, the constructor calls **`define_singleton_method`** twice, creating a getter and a setter on **that one object's singleton class**. 3. Any other call without arguments is caught by **`method_missing`**, which returns the stored value or **`nil`** if the key was never set. 4. A call ending in `=` for a new key goes through `method_missing` too and stores the value. Every instance therefore gets its own singleton class with its own methods, which is the root of most of its problems. ## Why it is discouraged The ostruct documentation's own Caveats section ends with the advice to consider not using OpenStruct at all, and Ruby 3.0's release notes made that discouragement official. The reasons: - **Speed**: defining methods per instance is expensive. The docs note that creating an OpenStruct from a small Hash and reading a few entries can be about **200 times slower** than using the Hash directly. - **Silent typos**: `cfg.timout` returns `nil` through `method_missing` instead of raising `NoMethodError`, so a spelling mistake becomes a wrong value far from its cause. - **Shadowed built-ins**: a key named like an existing method replaces it on that object. `OpenStruct.new(class: :luxury).class` returns `:luxury`; OpenStruct keeps bang-suffixed aliases such as `class!` so it can still reach the originals. - **Untrusted input**: building one from request JSON turns attacker-chosen keys into method names, which the docs flag as a potential security issue. - **Version drift**: because keys compete with methods, a method that later appears on `Object` or `Kernel` can change what a key returns. Ruby itself can warn you: when performance warnings are enabled (`ruby -W:performance`), `OpenStruct.new` emits "OpenStruct use is discouraged for performance reasons". RuboCop's `Style/OpenStructUse` cop flags `OpenStruct` references; it ships as a **pending** cop, so it only runs once enabled or when new cops are turned on. ## Ruby 4.0: a bundled gem Through Ruby 3.4, `ostruct` was a **default gem**: always loadable, even from a Bundler-managed app whose Gemfile never mentioned it. Ruby 4.0 moved it to the **bundled gems** (ostruct 0.6.3), alongside logger, benchmark, irb, reline, rdoc and pstore. A bundled gem is installed with Ruby but is treated like any other gem: | Context | `require "ostruct"` in Ruby 4.0 | |---|---| | plain `ruby script.rb`, no Bundler | loads the installed bundled gem | | Bundler app, Gemfile lists `gem "ostruct"` | loads normally | | Bundler app, no Gemfile entry | warns that ostruct is not part of the default gems since Ruby 4.0.0 and suggests adding it to the Gemfile, then raises `LoadError` | The fix for code that still needs it is one line in the Gemfile (or a gemspec dependency for a library). The better fix is removing the dependency. ## What to use instead - **`Data.define`** for fixed sets of immutable fields, such as a GPS coordinate or a money amount. Missing members raise, typos raise `NoMethodError`, and instances are frozen. - **`Struct.new`** when the fields are fixed but must be mutable. - **A Hash** when keys are genuinely dynamic, read with `fetch` so a missing key raises `KeyError` rather than returning `nil`. - **Your test framework's doubles** instead of OpenStruct stand-ins in specs. | Need | Replacement | Typo behaviour | |---|---|---| | fixed fields, immutable | `Data.define` | `NoMethodError` | | fixed fields, mutable | `Struct.new` | `NoMethodError` | | dynamic keys | `Hash` with `fetch` | `KeyError` | ## Migrating existing uses 1. Search for `OpenStruct` and enable `Style/OpenStructUse` to keep new uses out. 2. For each use, list the keys actually read; if the list is fixed, define a Data or Struct class with those members. 3. Replace dynamic-key uses with a Hash and explicit `fetch` or `dig`. 4. Until the last use is gone, add `gem "ostruct"` to the Gemfile so Ruby 4.0 deployments do not fail at boot with `LoadError`.

  • Why does a misspelt OpenStruct reader return nil instead of raising NoMethodError?
    OpenStruct's `method_missing` treats any call with no arguments as a read of its internal table and returns the stored value, or `nil` for a key that was never set. Only calls with arguments that are not setters fall through to `super` and raise NoMethodError. A Data or Struct class has no `method_missing`, so a typo raises.
  • Why does code using OpenStruct work in a script but fail in the same app under `bundle exec` on Ruby 4.0?
    Outside Bundler, RubyGems can load the installed bundled ostruct gem. Under Bundler only gems in the lockfile are on the load path, and since Ruby 4.0 ostruct is no longer a default gem, so `require "ostruct"` warns and raises LoadError until the Gemfile lists it.

saying these in an interview costs you the question

  • ostruct is still a default gem in Ruby 4.0, so require works under any Gemfile.
  • A mistyped OpenStruct attribute raises NoMethodError like on any object.
  • OpenStruct defines its accessors once on the class, so it is as fast as Struct.
  • Building an OpenStruct from request JSON is harmless because keys stay plain data.
  • RuboCop's Style/OpenStructUse runs by default without enabling new cops.