Monkey Patching in Ruby: Powerful, but a Poor Long-Term Strategy

Monkey patching is often touted as one of Ruby's most powerful features. The ability to dynamically alter the behavior of existing objects—especially those from external libraries or the Rails framework itself—offers a level of flexibility many languages lack. However, a strong case exists for using this technique sparingly, or not at all. It is brittle, dangerous, and in many cases, entirely unnecessary.

Defining a Monkey Patch

A monkey patch is code that dynamically changes the behavior of existing objects, usually ones external to your current program. While it can technically be used to modify your own code, doing so defeats the purpose, as the changes are global and can cause unexpected side effects. The concept is broader than simply extending a class; it's any code that alters the behavior of an underlying library in a surprising or non-obvious way.

Take, for instance, a public gem maintained by Shopify, activerecord-pedant-adapter. This adapter monkey patched Active Record's MySQL2Adapter to modify its execute and exec_delete methods, ensuring the database connection could report query warnings. This is a relatively mild example because it calls super and doesn't change the internal logic of Active Record, but it still creates surprising behavior since the modification doesn't originate from Rails itself. Recognizing the drawbacks, Shopify eventually decided to upstream this behavior directly into Rails, allowing them to archive the gem and reduce their reliance on patches.

The Primary Risks of Monkey Patching

The dangers of monkey patching grow exponentially when applied to a central framework like Rails, though the core issues apply to patching any library.

Complicates Framework Upgrades

Upgrading Rails is essential for new features, security fixes, and performance improvements. However, monkey patches can turn this routine process into a major project. Rails only provides deprecation warnings for its public API. A patch that relies on private APIs could break silently with a new release, leaving you with a critical path that depends on changed or removed behavior. In some cases, the patched code path might no longer exist, making an upgrade impossible without a significant effort to rewrite the patch.

Creates Security Vulnerabilities

If you patch a section of code that a security release later fixes, your application will remain vulnerable unless you manually apply the same changes. Since monkey patches are often forgotten after they are written, your app can stay exposed even after you upgrade to the latest secure version of Rails.

Accumulates Technical Debt

Many patches are written as quick fixes for missing features or bugs. Because they are often poorly documented and untested, they become hidden technical debt. A culture of "one more change here" can develop, leading to a sprawling set of patches that grow in complexity and become harder to remove, eventually surfacing as a major problem during an upgrade or a production incident.

Monkey patching also makes you a poor open source citizen. Fixing a bug locally prevents others—including your future projects—from benefiting from the solution. Submitting an issue or a patch upstream helps the entire community.

Leads to Unexpected Behavioral Changes

Since monkey patches are global, they affect all callers of the patched method, including those you didn't anticipate. If the change doesn't raise an exception, tracing the source of the new behavior can be incredibly difficult. The author of the original post describes removing an entire gem that was silently forcing a query cache behavior from an end-of-life Rails version, costing their team weeks of debugging time.

Alternatives to Patching

Before you write a monkey patch, take a step back to understand the root problem. Ask yourself these questions:

  • Will upgrading fix this? The bug you're encountering may already be fixed in a newer version of the library. Upgrading is a much cleaner solution than patching.
  • Is it actually a bug? You might be using the library in an unintended way. Revisiting the documentation may reveal the correct usage and help you avoid a dangerous patch.
  • Can you contribute a fix upstream? Consider the open source tools you use as part of your application. Sending a fix upstream solves the problem for everyone, not just your codebase.

Refinements are Not a Safe Alternative

Some might suggest using Ruby's refinements as a more contained alternative to full-blown monkey patching. While they are local rather than global, they are still not a recommended solution. Refinements are remarkably slow and can severely impact the Ruby VM's performance. At Shopify, an investigation found that refinements added 13 seconds to boot time because they break method caches. Furthermore, the code altered by a refinement suffers from most of the same issues as a monkey patch: it can still break in upgrades, cause security problems, and doesn't fix the issue upstream.

When Patching is Necessary

Despite these dangers, there are legitimate, temporary reasons to use a monkey patch—often while waiting for an upstream fix. The key is to use them as a stopgap, never as a permanent feature.

Notify Maintainers First

Before writing any code, open an issue on the upstream project. The maintainer may confirm that a fix is already in a newer version, help you identify a user error, or acknowledge the bug and provide feedback.

Submit Your Patch Upstream

Try to send a patch to the open source project. If it's merged, you can remove your local patch once you upgrade. If you can't upgrade immediately, wrap your patch in a version check so it automatically disables itself when the fix is available in the version you're using. This prevents obsolete patches from lingering in your codebase and becoming security or performance liabilities.

The power of Ruby is undeniable, but with great power comes great responsibility. A monkey patch should never be your first—or even your second—tool of choice. It is a temporary workaround to help you transition to a more permanent, upstream solution. Before deploying a patch, make a plan for its removal. If you own and control the library you're thinking of patching, don't do it. Advocate for better solutions instead.

Where to Put a Monkey Patch If You Must Write One

If a monkey patch is unavoidable, keep it in a dedicated directory such as /lib/patches. Naming and grouping patches in one self-documenting location makes it obvious that the code is altering library behavior, rather than hiding the patch inside model, controller, or random lib/ files. This placement simplifies finding and removing patches later and reduces the surprise factor for anyone reading the codebase.

Keep Patches Minimal and Prefer Inheritance

A patch should only override what is strictly necessary. Copying more code than required increases the risk of conflict and makes the patch harder to reason about. Where possible, inherit from the original class and call super so that upstream behavior changes are still respected. A smaller, inheritance-based patch is less likely to break when the underlying library moves on.

Documentation and Testing Are Not Optional

Every patch needs clear documentation stating what is being changed, why, how the bug was found, and a link to the upstream issue or pull request. You may not be around to explain the patch when it causes a production incident, so the written record must stand on its own.

Tests are equally important. They verify the patch's behavior and serve as self-documenting evidence of the edge cases it addresses. If you remove the patch after an upstream fix, those tests should still pass (barring API changes). Without tests, the intent of the patch is left to inference, which becomes a problem the day someone tries to delete it.

Plan for Removal From Day One

The "write it and forget it" pattern is the root of most monkey patch debt. Any new patch should come with a removal plan, even if that plan is years away. Open a tracking issue in your team's repo as soon as you add the patch. Shopify uses a "TODO" gem to send Slack reminders for such follow-ups—adding a TODO for every monkey patch keeps removal on the radar instead of letting it sink into the codebase.

Cleaning Up an Existing Patch Mess

If your codebase is already littered with patches, you can burn them down incrementally. Start by locating every patch and moving each one into your patches directory as you find it. Then classify them: some can be upstreamed to Rails, others are obsolete because the bug was already fixed upstream. Work through them one at a time.

This cleanup requires a mindset change across engineering teams. Patches should be viewed as hazardous technical debt, not as quick fixes. Treating Rails as an extension of your own application—rather than as an external dependency—is key to keeping the codebase free of patches in the long run.

Running on Rails Main Reduces the Need to Patch

Shopify's main monolith runs on Rails main, which means bugs can be fixed upstream immediately without waiting for a release. This removes most of the incentive to monkey patch in the first place. Legacy patches still exist and can interfere with weekly Rails bumps, so the advice above is born from direct operational pain. The goal is to end each year with fewer patches than you started: send pull requests upstream and delete the local workarounds. Deleting code that is a recipe for future problems is its own reward.