
A monkeypatch reopens a class you don’t own, a gem or Ruby core, and changes its methods at runtime. Done carelessly, the last patch loaded silently wins and the original is gone. Done responsibly, you wrap the change in a named module, apply it with Module#prepend so super reaches the original, verify the target exists, and set an expiry date.
Ruby lets you redefine anything loaded into the virtual machine, including code you did not write: define and delete methods at will, call methods that don’t exist, reopen a class from a gem as easily as one of your own. That flexibility is what drew me to the language back in 2011, and it is also what cost my team a week of production debugging. This post is the set of rules I follow now so that a patch on someone else’s code stays safe.
What Are Monkeypatches?
Enter the monkeypatch.
In short, monkeypatches “monkey with” existing code. The existing code is often code you don’t have direct access to, like code from a gem or from the Ruby standard library. Patches usually alter the original code’s behavior to fix a bug, improve performance, and so on.
The most unsophisticated monkeypatches reopen Ruby classes and modify behavior by adding or overriding methods.
This reopening idea is core to Ruby’s object model. Whereas in Java, classes can only be defined once, Ruby classes (and modules, for that matter) can be defined multiple times. When we define a class a second, third, or fourth time, we say that we’re reopening it. Any new methods we define are added to the existing class definition and can be called on instances of that class:
class Sounds
def honk
"Honk!"
end
end
class Sounds
def squeak
"Squeak!"
end
end
sounds = Sounds.new
sounds.honk # => "Honk!"
sounds.squeak # => "Squeak!"Both #honk and #squeak are available on the Sounds class through the magic of reopening. Reopening is one of several ways to change a class at runtime; class_eval, which evaluates a block in the class’s context, and the rest of that toolkit are covered in our overview of advanced metaprogramming techniques.
Monkeypatching, then, is the act of reopening classes in third-party code.
Is Monkeypatching Dangerous?
If the previous sentence scared you, that’s probably a good thing. Monkeypatching, especially when done carelessly, can cause real chaos.
Consider what happens if we redefine Array#<<:
class Array
def <<(*args)
# do nothing 😈
end
end
a = []
a << 1 # => nil
a # => []
a.push(2) # => [2]
Array.instance_method(:<<).owner # => ArrayWith these four lines of code, every array in the program is broken, though only partly: push is a separate method and still works, so the damage is easy to miss. The original implementation of #<< is gone. Aside from restarting the Ruby process, there’s no way to get it back, and nothing records that the change happened; Array.instance_method(:<<).owner still answers Array.
When Monkeypatching Goes Horribly Wrong
Back in 2011, I worked for a prominent social networking company. The codebase was a massive Rails monolith running on Ruby 1.8.7. Several hundred engineers contributed to it daily, and the pace of development was fast.
At one point, my team decided to monkeypatch String#% to make writing plurals easier for internationalization. Stock String#% has no idea what a %{count:word} placeholder means:
# plurals.rb
replacements = {
horse_count: 3,
horses: {
one: "is 1 horse",
other: "are %{horse_count} horses"
}
}
"there %{horse_count:horses} in the barn" % replacements$ ruby plurals.rb
plurals.rb:10:in 'String#%': key{horse_count:horses} not found (KeyError)
Did you mean? :horse_count
from plurals.rb:10:in '<main>'
With our patch applied, the same call returned "there are 3 horses in the barn". We wrote the patch and deployed it to production, only to find that it didn’t work. Users saw strings with literal %{...} characters instead of pluralized text. The patch had worked in development on my laptop. Why wasn’t it working in production?
Initially, we suspected a bug in Ruby itself, until a production Rails console produced a different result than a development console on the same Ruby version. That ruled out the standard library. Something else was going on.
After several days of head-scratching, a co-worker tracked down a Rails initializer that added another implementation of String#% that none of us had seen before. That earlier implementation also contained a bug, so the results we saw in the production console differed from Ruby’s official documentation.
That’s not the end of the story, though. In tracking down the earlier monkeypatch, we found no less than three others, all patching the same method. We looked at each other in horror. How had this ever worked?
We eventually chalked the inconsistent behavior up to Rails’ eager loading. In development, Rails lazy loads Ruby files, only loading them when they are required. In production, Rails loads all of the app’s Ruby files at initialization. That throws a big monkey wrench into monkeypatching.
Consequences of Reopening a Class
In this case, each of the monkeypatches reopened the String class and replaced the existing #% method with another one. This approach has three major pitfalls:
- The last patch applied “wins”, so behavior depends on load order
- There’s no way to access the original implementation
- Patches leave almost no audit trail, which makes them hard to find later
Not surprisingly, we ran into all three.
At first, we didn’t even know other monkeypatches were in play. Because of the bug in the winning method, it appeared that the original implementation was broken. When we discovered the competing patches, it was impossible to tell which one won without adding copious puts statements.
Even when we did discover which method won in development, a different one won in production. It was also hard to tell programmatically which patch had been applied last, since Ruby 1.8 didn’t have the wonderful Method#source_location we have now.
I spent at least a week figuring out what was going on, time wasted chasing an entirely avoidable problem.
Eventually, we introduced a LocalizedString wrapper class with an accompanying #% method. Our String monkeypatch then shrank to:
class String
def localize
LocalizedString.new(self)
end
endwarning: method redefined; discarding old %
Ruby can tell you when a reopened class replaces a method, but only when you ask. Run a file that redefines String#% with -w (or with RUBYOPT=-w) and it prints:
$ ruby -w string_format.rb
string_format.rb:2: warning: method redefined; discarding old %
A default run prints nothing, and so does -W:deprecated, so this warning never shows up in a production log. When the previous definition is Ruby code rather than a C method, a second line names it: warning: previous definition of greet was here. One -w run of the test suite in 2011 would have listed all four competing String#% patches with file and line. Module#prepend never triggers this warning, because nothing is redefined; for prepended patches, ancestors is the audit trail instead. Run your suite under -w once to inventory the reopenings you already have.
can't modify frozen class: DateTimeSelector (FrozenError)
$ ruby frozen_class.rb
frozen_class.rb:6:in '<class:DateTimeSelector>': can't modify frozen class: DateTimeSelector (FrozenError)
from frozen_class.rb:5:in '<main>'
Someone froze the class you are reopening. Reopening, prepend, include, and class_eval all raise this on a frozen class. A frozen object raises the sibling message can't modify frozen object: hello (FrozenError) for def obj.x or obj.singleton_class.prepend, and a frozen string literal raises can't define singleton (TypeError) instead, because literals are interned. Core classes are not frozen (String.frozen? is false), and Ractor.make_shareable does not freeze a class, so this error only appears on something that was frozen on purpose. Treat it as a stop sign and wrap the object instead of patching it, the way LocalizedString wrapped String earlier in this post.
When Monkeypatching Fails
In my experience, monkeypatches fail for one of two reasons:
- The patch itself is broken. In the 2011 codebase, several implementations competed for the same method, and the one that “won” didn’t work.
- Assumptions are invalid. The host code has been updated and the patch no longer applies as written.
The second reason deserves a closer look.
Even the Best-Laid Plans...
Monkeypatching often fails for the same reason you reached for it in the first place: you don’t have access to the original code. For precisely that reason, the original code can change out from under you.
Consider this class in a gem that your app depends on:
class Sale
def initialize(amount, discount_pct, tax_rate = nil)
@amount = amount
@discount_pct = discount_pct
@tax_rate = tax_rate
end
def total
discounted_amount + sales_tax
end
private
def discounted_amount
@amount * (1 - @discount_pct)
end
def sales_tax
if @tax_rate
discounted_amount * @tax_rate
else
0
end
end
end
Sale.new(100, 0.1, 0.2).total # => 108.0Wait, that’s not right. Sales tax should be applied to the full amount, not the discounted amount: a 110, not $108. You submit a pull request to the project. While you wait for the maintainer to merge it, you add this monkeypatch to your app:
class Sale
private
def sales_tax
if @tax_rate
@amount * @tax_rate
else
0
end
end
end
Sale.new(100, 0.1, 0.2).total # => 110.0It works. You check it in and forget about it.
Everything is fine for a long time. Then one day the finance team sends you an email asking why the company hasn’t been collecting sales tax for a month.
Confused, you start digging into the issue and notice that one of your co-workers recently updated the gem that contains the Sale class. Here’s the updated code:
class Sale
def initialize(amount, discount_pct, sales_tax_rate = nil)
@amount = amount
@discount_pct = discount_pct
@sales_tax_rate = sales_tax_rate
end
def total
discounted_amount + sales_tax
end
private
def discounted_amount
@amount * (1 - @discount_pct)
end
def sales_tax
if @sales_tax_rate
discounted_amount * @sales_tax_rate
else
0
end
end
endOne of the project maintainers renamed the @tax_rate instance variable to @sales_tax_rate. The monkeypatch still checks the old @tax_rate, which is now always nil, so with the patch loaded every sale collects no tax at all:
Sale.new(100, 0.1, 0.2).total # => 90.0Nobody noticed because no error was ever raised. The app chugged along as if nothing had happened. The three errors that follow are what a patch raises when the host changes in a way Ruby can detect.
super: no superclass method 'build_hidden' for an instance of DateTimeSelector (NoMethodError)
$ ruby super_missing.rb
super_missing.rb:9:in 'RenderDiscardedMonkeypatch#build_hidden': super: no superclass method 'build_hidden' for an instance of DateTimeSelector (NoMethodError)
Did you mean? build_hidden_field
from super_missing.rb:14:in '<main>'
The prepended method calls super, and the host class has no method by that name. Either the gem renamed or removed the method you patched, or the patch was prepended onto the wrong constant; the message is the same then, with a different class name after for an instance of. Did you mean? appears when a near-name exists. The fix is the find_method check: look the method up with instance_method before prepending, and print its owner and the class’s ancestors when the lookup fails.
wrong number of arguments (given 3, expected 2) (ArgumentError)
$ ruby arity_mismatch.rb
arity_mismatch.rb:13:in 'build_hidden': wrong number of arguments (given 3, expected 2) (ArgumentError)
from arity_mismatch.rb:8:in 'DateTimeSelector#select_year'
from arity_mismatch.rb:19:in '<main>'
The gem’s own callers now pass three arguments, and the patch still takes two. Because the prepended module sits first in the lookup chain, the gem’s internal call reaches the patch, not the original. The mirror case, where the patch passes two arguments to super and the host now takes one, reports wrong number of arguments (given 2, expected 1) from the host’s frame. The fix is the arity check; compare parameters as well when the host method uses optional arguments.
undefined method 'build_hidden' for class 'DateTimeSelector' (NameError)
$ ruby alias_missing.rb
alias_missing.rb:5:in 'Module#alias_method': undefined method 'build_hidden' for class 'DateTimeSelector' (NameError)
alias_method :build_hidden_without_patch, :build_hidden
^^^^^^^^^^^^
from alias_missing.rb:5:in '<class:DateTimeSelector>'
from alias_missing.rb:4:in '<main>'
An alias_method chain on a method the class does not have fails at load time, with the error_highlight carets under the alias_method call. instance_method(:missing) raises the same message, which is why the find_method check rescues NameError. Prefer prepend; if aliasing is unavoidable, guard it with method_defined? or private_method_defined?.
Why Monkeypatch?
Given these examples, it might seem like monkeypatching isn’t worth the potential headaches. So why do we do it? I see three major use cases:
- To fix broken or incomplete third-party code
- To quickly test a change, or several changes, in development
- To wrap existing functionality with instrumentation or annotation code
In some cases, the only viable way to address a bug or performance issue in third-party code is a monkeypatch.
But with great power comes great responsibility.
Monkeypatching Responsibly
I like to frame the monkeypatching conversation around responsibility instead of whether it’s good or bad. Sure, monkeypatching can cause chaos when done poorly. Done with some care and diligence, there’s no reason to avoid it when the situation warrants it.
Here’s the list of rules I try to follow:
- Wrap the patch in a module with an obvious name and use
Module#prependto apply it - Make sure you’re patching the right thing
- Limit the patch’s surface area (a refinement is the strictest form)
- Give yourself escape hatches
- Over-communicate
The rest of the rules build one patch for Rails’ DateTimeSelector so that it optionally skips rendering discarded fields. This is a change I tried to make to Rails a few years ago; the pull request has the details.
You don’t have to know much about discarded fields to follow the patch. At the end of the day, all it does is replace a single method called build_hidden with one that does nothing when asked. Everything from here on was verified against actionview 8.1.3.1 on Ruby 3.4, and the snippets are written to live in a Rails initializer such as config/initializers/render_discarded_monkeypatch.rb.
Use Module#prepend
In the 2011 codebase, all the implementations of String#% were applied by reopening the String class. Here’s an augmented list of the drawbacks I mentioned earlier:
- Errors appear to have originated from the host class or module instead of from the patch code
- Any method you define in the patch replaces the existing method with the same name, so there’s no way to invoke the original implementation
- There’s no way to know which patches were applied and therefore which methods “won”
- Patches leave almost no audit trail, which makes them hard to find later
Instead, wrap your patch in a module and apply it with Module#prepend. The module lands before the class in the method lookup chain, so a call to super reaches the original implementation, and ancestors shows the patch:
# greeter.rb
class Greeter
def greet(name)
"Hello, #{name}"
end
end
module LoudGreeting
def greet(name)
super.upcase + "!"
end
end
Greeter.prepend(LoudGreeting)
Greeter.new.greet("Ada") # => "HELLO, ADA!"
Greeter.ancestors.first(3) # => [LoudGreeting, Greeter, Object]
patched = Greeter.instance_method(:greet)
patched.owner # => LoudGreeting
patched.super_method.owner # => Greeter
patched.super_method.source_location # => ["greeter.rb", 3]
Greeter.prepend(LoudGreeting)
Greeter.ancestors.count(LoudGreeting) # => 1The original is one call away through super_method, file and line included. Prepending the same module twice is a no-op, so a reloaded initializer does no harm. Compare the pre-prepend idiom, an alias_method chain:
class Greeter
def greet(name)
"Hello, #{name}"
end
end
class Greeter
alias_method :greet_without_loudness, :greet
def greet(name)
greet_without_loudness(name).upcase + "!"
end
end
Greeter.new.greet("Ada") # => "HELLO, ADA!"
Greeter.ancestors.first(2) # => [Greeter, Object]
Greeter.instance_methods(false) # => [:greet, :greet_without_loudness]
Greeter.instance_method(:greet).super_method # => nilThe result is the same, but the class stays flat: ancestors is unchanged, the method list doubles, and super_method is nil. Plain reopening leaves the same nil; the original is gone. Class methods are patched the same way as instance methods, through the singleton class:
class Config
def self.load
"loaded"
end
end
module TracedLoad
def load
"traced(" + super + ")"
end
end
Config.singleton_class.prepend(TracedLoad)
Config.load # => "traced(loaded)"
Config.singleton_class.ancestors.first(3) # => [TracedLoad, #<Class:Config>, #<Class:Object>]A module can also log or gate its own application: Module#prepend_features runs before the prepended hook, and either one can check a condition or print a line. prepend is the third mixin verb next to include and extend; our post on mixins and modules covers where each one lands in the ancestor chain. Finally, a prepend statement is easy to comment out if you need to disable the patch for some reason.
Here are the beginnings of a module for our Rails monkeypatch:
module RenderDiscardedMonkeypatch
end
ActionView::Helpers::DateTimeSelector.prepend(
RenderDiscardedMonkeypatch
)Patch the Right Thing
Don’t apply a monkeypatch unless you know you’re patching the right code. In most cases, it should be possible to verify programmatically that your assumptions still hold (this is Ruby, after all). Here’s a checklist:
- Make sure the class or module you’re trying to patch exists
- Make sure methods exist and have the right arity
- If the code you’re patching lives in a gem, check the gem’s version
- Bail out with a helpful error message if assumptions don’t hold
Right off the bat, our patch code has made a pretty important assumption. It assumes a constant called ActionView::Helpers::DateTimeSelector exists and is a class or module.
Check Class/Module
Let’s ensure that constant exists before trying to patch it:
module RenderDiscardedMonkeypatch
end
const = begin
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
if const
const.prepend(RenderDiscardedMonkeypatch)
endGreat, but now we’ve leaked a local variable (const) into the global scope. Let’s fix that:
module RenderDiscardedMonkeypatch
def self.apply_patch
const = begin
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
if const
const.prepend(self)
end
end
end
RenderDiscardedMonkeypatch.apply_patchKernel.const_get raises NameError when the constant is missing, with the message uninitialized constant Kernel::ActionView. Object.const_get behaves the same and gives the cleaner message, uninitialized constant ActionView.
Check Methods
Next, let’s introduce the patched build_hidden method. Let’s also add a check to make sure it exists and accepts the right number of arguments (i.e. has the right arity). If those assumptions don’t hold, something’s probably wrong:
module RenderDiscardedMonkeypatch
class << self
def apply_patch
const = find_const
mtd = find_method(const)
if const && mtd && mtd.arity == 2
const.prepend(self)
end
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
end
end
def build_hidden(type, value)
''
end
end
RenderDiscardedMonkeypatch.apply_patchinstance_method finds private methods too, and it raises NameError when the method is missing, which is why find_method rescues it. That matters here: on actionview 8.1.3.1, build_hidden is private, so method_defined?(:build_hidden) returns false; use private_method_defined? if you prefer a predicate. When the arity check fails, print mtd.parameters, mtd.owner, and mtd.source_location; those three tell you what the gem changed.
Check Gem Versions
Finally, let’s check that we’re running the version of Action View the patch was written against. If the gem gets upgraded, we might need to update the patch too (or get rid of it entirely):
module RenderDiscardedMonkeypatch
class << self
def apply_patch
const = find_const
mtd = find_method(const)
if const && mtd && mtd.arity == 2 && actionview_version_ok?
const.prepend(self)
end
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
end
def actionview_version_ok?
Gem::Requirement.new("~> 8.1.0").satisfied_by?(ActionView.version)
end
end
def build_hidden(type, value)
''
end
end
RenderDiscardedMonkeypatch.apply_patchActionView.version returns a Gem::Version, so Gem::Requirement does the comparison without any string parsing. ~> 8.1.0 means any 8.1.x release and nothing else; 8.2.0 fails the check. The patched constant lives in actionview, so that is the gem to pin, and the version named is the one the patch was verified against.
Bail Out Helpfully
If your verification code uncovers a discrepancy between expectations and reality, it’s a good idea to raise an error or at least print a helpful warning message. The idea here is to alert you and your co-workers when something seems amiss.
Here’s how we might modify our Rails patch:
module RenderDiscardedMonkeypatch
class << self
def apply_patch
const = find_const
mtd = find_method(const)
unless const && mtd && mtd.arity == 2
raise "Could not find class or method when patching "\
"ActionView's date_select helper. Please investigate."
end
unless actionview_version_ok?
puts "WARNING: It looks like Action View has been upgraded since "\
"ActionView's date_select helper was monkeypatched in "\
"#{__FILE__}. Please re-evaluate the patch."
end
const.prepend(self)
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
end
def actionview_version_ok?
Gem::Requirement.new("~> 8.1.0").satisfied_by?(ActionView.version)
end
end
def build_hidden(type, value)
''
end
end
RenderDiscardedMonkeypatch.apply_patchA missing class or method raises, because the patch cannot work without them. A version mismatch only warns: the patch may still apply, and a hard failure at boot after a minor upgrade is a worse outcome than a log line.
Limit Surface Area
Every instance method in a prepended module lands in the host’s lookup chain and overrides a method of the same name, whether or not you meant it to. While it might seem as though a host class or module doesn’t define a particular method, it’s difficult to know for sure. For this reason, I only define the methods I intend to patch in the module that gets prepended, and I keep them in a nested InstanceMethods module so the boundary is explicit. The same holds when you prepend onto a singleton class to patch class methods.
Visibility is a second surface to watch. build_hidden is private in Action View, and a public build_hidden in the prepended module makes it public: public_method_defined? flips to true, and anyone can call it from outside. Put private at the top of InstanceMethods; the gem’s implicit-receiver call still reaches the patch, and super still works.
Here’s how to modify our Rails patch to replace only the one #build_hidden method:
module RenderDiscardedMonkeypatch
class << self
def apply_patch
const = find_const
mtd = find_method(const)
unless const && mtd && mtd.arity == 2
raise "Could not find class or method when patching "\
"ActionView's date_select helper. Please investigate."
end
unless actionview_version_ok?
puts "WARNING: It looks like Action View has been upgraded since "\
"ActionView's date_select helper was monkeypatched in "\
"#{__FILE__}. Please re-evaluate the patch."
end
const.prepend(InstanceMethods)
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
end
def actionview_version_ok?
Gem::Requirement.new("~> 8.1.0").satisfied_by?(ActionView.version)
end
end
module InstanceMethods
private
def build_hidden(type, value)
''
end
end
end
RenderDiscardedMonkeypatch.apply_patchAfter apply_patch, the lookup chain reads exactly as intended:
ActionView::Helpers::DateTimeSelector.ancestors.first(3)
# => [RenderDiscardedMonkeypatch::InstanceMethods,
# ActionView::Helpers::DateTimeSelector,
# ActionView::Helpers::TagHelper]Scope the Patch with Refinements
The strictest way to limit a patch’s surface area is to make it a refinement. A refinement, defined with refine inside a module, changes a class only for the files that opt in with using:
# string_shout.rb
module StringShout
refine String do
def shout
upcase + "!"
end
end
end# shout_helper.rb
require_relative "string_shout"
module ShoutHelper
def self.call(string)
string.shout
end
end# main.rb
require_relative "shout_helper"
using StringShout
"hi".shout # => "HI!"
["a", "b"].map(&:shout) # => ["A!", "B!"]
"hi".send(:shout) # => "HI!"
"hi".respond_to?(:shout) # => true
Module.used_modules # => [StringShout]
StringShout.refinements.first.target # => String
ShoutHelper.call("hi")$ ruby main.rb
/app/shout_helper.rb:6:in 'ShoutHelper.call': undefined method 'shout' for an instance of String (NoMethodError)
string.shout
^^^^^^
from main.rb:14:in '<main>'
ShoutHelper lives in a file that never says using, so for that file the refinement does not exist. It raises the same NoMethodError that "hi".shout raises in main.rb before the using line. That lexical scope is the whole point, and it is also why the Rails patch in this post cannot be a refinement. Action View’s own file calls build_hidden, and that file will never call using on your module, so a refinement changes nothing there. Refinements fit patches to core classes that your code calls (String, Hash, Integer), not a gem’s internals.
A few rules and gotchas, all verified on Ruby 3.4:
usingbelongs at the top level of a file. Inside a method it raisesmain.using is permitted only at toplevel (RuntimeError).- Indirect calls honor refinements at the call site:
send,public_send,respond_to?, and&:shoutall seeshoutwhereverusingis active. Refinement#target(Ruby 3.3 and later) returns the refined class. The olderrefined_classwas removed in Ruby 3.4 and now raisesundefined method 'refined_class' for module #<refinement:String@StringShout> (NoMethodError).import_methods(Ruby 3.1 and later) pulls a plain module’s methods into a refinement, so shared helpers need no duplication.
The refinements syntax reference lists the remaining scope rules, and Refinement#target documents the introspection side.
Give Yourself Escape Hatches
When possible, I like to make my monkeypatch’s functionality opt-in. That’s only an option if you have control over where the patched code is invoked. In the case of our Rails patch, it’s doable via the @options hash in DateTimeSelector:
module RenderDiscardedMonkeypatch
class << self
def apply_patch
const = find_const
mtd = find_method(const)
unless const && mtd && mtd.arity == 2
raise "Could not find class or method when patching "\
"ActionView's date_select helper. Please investigate."
end
unless actionview_version_ok?
puts "WARNING: It looks like Action View has been upgraded since "\
"ActionView's date_select helper was monkeypatched in "\
"#{__FILE__}. Please re-evaluate the patch."
end
const.prepend(InstanceMethods)
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
end
def actionview_version_ok?
Gem::Requirement.new("~> 8.1.0").satisfied_by?(ActionView.version)
end
end
module InstanceMethods
private
def build_hidden(type, value)
if @options.fetch(:render_discarded, true)
super
else
''
end
end
end
end
RenderDiscardedMonkeypatch.apply_patchNice! Now callers opt in by passing the new option to the date_select helper, or to the form builder’s version of it. No other code paths are affected:
date_select(:user, :date_of_birth, order: [:month, :day], render_discarded: false)<%= f.date_select(:date_of_birth, order: [:month, :day], render_discarded: false) %>Without the option, the discarded year still renders as a hidden input, exactly as before:
<input type="hidden" id="user_date_of_birth_1i" name="user[date_of_birth(1i)]" value="1990" autocomplete="off" />
<select id="user_date_of_birth_2i" name="user[date_of_birth(2i)]">
With render_discarded: false, the output starts at the month select:
<select id="user_date_of_birth_2i" name="user[date_of_birth(2i)]">
Over-Communicate
The last piece of advice I have for you is perhaps the most important: communicate what your patch does and when it’s time to re-examine it. Your goal with monkeypatches should always be to eventually remove the patch altogether. To that end, a responsible monkeypatch includes comments that:
- Describe what the patch does
- Explain why the patch is necessary
- Outline the assumptions the patch makes
- Specify a date in the future when your team should reconsider alternative solutions, like pulling in an updated gem
- Include links to relevant pull requests, blog posts, Stack Overflow answers, and so on
You might even print a warning or fail a test on a predetermined date to urge the team to reconfirm the patch’s assumptions and consider whether or not it’s still necessary.
Here’s the final version of our Rails date_select patch, complete with comments and a date check. Outside a Rails app, Date needs require "date" first; Rails loads it for you:
# ActionView's date_select helper provides the option to "discard" certain
# fields. Discarded fields are (confusingly) still rendered to the page
# using hidden inputs, i.e. <input type="hidden" />. This patch adds an
# additional option to the date_select helper that allows the caller to
# skip rendering the chosen fields altogether. For example, to render all
# but the year field, you might have this in one of your views:
#
# f.date_select(:date_of_birth, order: [:month, :day])
#
# or, equivalently:
#
# f.date_select(:date_of_birth, discard_year: true)
#
# To avoid rendering the year field altogether, set :render_discarded to
# false:
#
# f.date_select(:date_of_birth, discard_year: true, render_discarded: false)
#
# This patch assumes the #build_hidden method exists on
# ActionView::Helpers::DateTimeSelector and accepts two arguments, and
# that actionview is an 8.1.x release.
#
module RenderDiscardedMonkeypatch
class << self
EXPIRATION_DATE = Date.new(2027, 3, 1)
def apply_patch
if Date.today > EXPIRATION_DATE
puts "WARNING: Please re-evaluate whether or not the ActionView "\
"date_select patch present in #{__FILE__} is still necessary."
end
const = find_const
mtd = find_method(const)
# make sure the class we want to patch exists;
# make sure the #build_hidden method exists and accepts exactly
# two arguments
unless const && mtd && mtd.arity == 2
raise "Could not find class or method when patching "\
"ActionView's date_select helper. Please investigate."
end
# if Action View has been upgraded, make sure this patch is still
# necessary
unless actionview_version_ok?
puts "WARNING: It looks like Action View has been upgraded since "\
"ActionView's date_select helper was monkeypatched in "\
"#{__FILE__}. Please re-evaluate the patch."
end
# apply the patch
const.prepend(InstanceMethods)
end
private
def find_const
Kernel.const_get('ActionView::Helpers::DateTimeSelector')
rescue NameError
# return nil if the constant doesn't exist
end
def find_method(const)
return unless const
const.instance_method(:build_hidden)
rescue NameError
# return nil if the method doesn't exist
end
def actionview_version_ok?
Gem::Requirement.new("~> 8.1.0").satisfied_by?(ActionView.version)
end
end
module InstanceMethods
private
# :render_discarded is an additional option you can pass to the
# date_select helper in your views. Use it to avoid rendering
# "discarded" fields, i.e. fields marked as discarded or left out
# of date_select's :order array. For example, specifying
# order: [:day, :month] will cause the helper to "discard" the
# :year field. Discarding a field renders it as a hidden input.
# Set :render_discarded to false to avoid rendering it altogether.
def build_hidden(type, value)
if @options.fetch(:render_discarded, true)
super
else
''
end
end
end
end
RenderDiscardedMonkeypatch.apply_patchConclusion
I totally get that some of these suggestions might seem like overkill. Our Rails patch contains far more defensive verification code than patch code!
Think of all that extra code as a sheath for your broadsword. It’s a lot easier to avoid getting cut if it’s enveloped in a layer of protection.

What matters, though, is that I feel confident deploying responsible monkeypatches into production. Irresponsible ones are time bombs waiting to cost you or your company time, money, and developer health.
P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Frequently asked questions
- Is monkey patching bad in Ruby?
- Not inherently. Monkeypatching means reopening a class you don’t own to change its methods, and Ruby allows it by design. It turns dangerous when patches reopen classes directly: the last patch loaded silently wins, the original is unreachable, and nothing records the change. Prepended modules with checks keep it manageable.
- What is the difference between prepend and alias_method in Ruby?
- alias_method copies the original method under a new name, then you redefine the method to call the copy; it leaves the class flat and shows nothing in ancestors. Module#prepend inserts your module before the class in the lookup chain, so super reaches the original and ancestors reveals the patch.
- How do refinements work in Ruby?
- A refinement, defined with refine inside a module, changes a class only in files that call using on that module. Code elsewhere, including the gem you patched, keeps the original behavior. Refinements suit patches to core classes in your own code; they cannot alter a gem’s internal calls.
- How do I safely monkey patch a gem in Ruby?
- Wrap the patch in a module with an obvious name and apply it with Module#prepend so super reaches the original. Before prepending, confirm the constant exists, the method has the expected arity, and the gem version matches; raise or warn if anything differs. Comment why, and set a removal date.
- Why does Ruby raise super: no superclass method after Module#prepend?
- The host class has no method with that name, so super has nowhere to go. Usually the gem renamed or removed the method you patched, or you prepended the wrong constant. Check instance_method(:name) and its owner before prepending, and raise a clear error when the lookup fails.
Published , Updated
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Cameron Dutro
Our guest author Cameron currently works on the Design Infrastructure team at GitHub. He's been programming in Ruby and using Rails for the better part of ten years. When he's not working with technology, Cameron can be found hiking around his neighborhood or hanging out at home with his wife, daughter, and cat.
All articles by Cameron DutroBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


