The Case Against Monkey Patching, From a Rails Core Team Member

Monkey patching is considered one of the more powerful features of the Ruby programming language. However, by the end of this post I’m hoping to convince you that they should be used sparingly, if at all, because they are brittle, dangerous, and often unnecessary. I’ll also share tips on how to use them as safely as possible in the rare cases where you do need to monkey patch.

What Is a Monkey Patch?

If you’re new to Ruby, you might not be familiar with monkey patching because some other languages make it difficult to change the behavior of existing code. A monkey patch is code that dynamically alters the behavior of existing objects, typically ones outside of the current program. Often applications that have monkey patches are changing the behavior of the Rails framework or another gem, but I’ve also seen applications that monkey patch themselves which doesn’t quite make sense since monkey patches are global. When I say monkey patch, I mean something more broad than just extending an existing class. In my opinion, a monkey patch is any time you change the behavior of the underlying library in a surprising way. Let’s look at an open source example of this to get a better idea of what I mean.

At Shopify we maintain an open source gem called activerecord-pedant-adapter. This adapter monkey patches Active Record’s MySQL2Adapter to change the behavior of the execute and exec_delete methods. The patch ensures that if enabled, the database connection will report query warnings. Without this patch, Rails didn’t have any support for reporting warnings issued by MySQL queries.

In terms of monkey patching, this gem is relatively mild because it calls super and doesn’t change the internal behavior of Active Record’s execute. But it can still be surprising because the behavior isn’t coming from Rails directly.

In an effort to reduce the monkey patches we have on Rails, we recently decided to upstream the behavior so we could archive this gem and help improve Rails. Thanks to Adrianna and Paarth at Shopify for doing the work to enable this upstream. Check out the pull request! This change to Rails means that we can remove this monkey patch from our codebase and be one step closer to being free of patches.

Why Is Monkey Patching Dangerous?

While monkey patching is incredibly powerful and adds a ton of flexibility to a language, being able to change the behavior of the open source tools you rely on is incredibly dangerous. Let’s go over some of the consequences of monkey patching libraries. We’re going to use patching the Rails framework as the main example because it’s much riskier to patch a major framework than a small gem. However, the majority of the problems caused by monkey patching are relevant to any kind of patch.

Monkey Patching Makes Upgrading Rails and Ruby More Difficult

Upgrading Rails is essential to getting new framework features, ensuring your application is secure, and getting the latest performance improvements and bug fixes. But if you monkey patch Rails, you could be locking yourself into a difficult or impossible upgrade. Sometimes a monkey patch breaks in an upgrade because the API changed. Rails only provides deprecations for the public API, so any monkey patches changing internal or private APIs won’t get a deprecation warning and you may be relying on behavior that changed or is removed completely. This is especially harmful when a monkey patch is in a critical path that the application relies on. In other cases the monkey patch will block an upgrade because the path you changed is no longer viable. Figuring out how to rewrite the patch for the latest Rails can be incredibly tedious and time consuming. 

Monkey Patching Can Leave You Vulnerable to Security Issues

One of the downsides of monkey patching is it can leave you vulnerable in a security release. If you’re patching code that is later found to have a vulnerability, you won’t be protected unless you make the same changes Rails does. Often monkey patches are forgotten after they are implemented, so unless someone knows that code is being patched, it could leave your application vulnerable even if you upgraded to the most recent security release.

Monkey Patching Adds to Your Technical Debt

Often monkey patches are added because Rails doesn’t have behavior a programmer wants or to fix a bug quickly in the application. For the most part the monkey patches I’ve seen haven’t been well documented or tested, leaving technical debt for someone to clean up later. When a patch exists it’s easy to say “well what’s one more change here and there, this is already patching Rails”. Those patches continue to grow and get worse and worse, making them harder to remove. These are often found in an upgrade or a production incident with no clear path towards removal.

Monkey Patching Means You’re Not Being a Good Open Source Citizen

When you choose to monkey patch instead of sending a patch and/or opening an issue upstream, you’re not being a good open source citizen. Fixing a bug internally with a monkey patch means that bug is fixed only in that one codebase. It’s not fixed for other Rails applications at your company. It’s not fixed in future Rails applications you work on. It’s not fixed in the framework. Submitting a fix or an issue upstream is a great way to contribute to the Rails community and to solve a problem others might be having too.

Monkey Patching May Change Behavior in Surprising Ways

While it’s certainly possible to make a minimal monkey patch that doesn’t add too much complexity, or too much change, or security vulnerabilities, that’s not often the case. Monkey patches change behavior globally, for all callers of classes or methods you’re patching. This means that a monkey patch can change behavior for a caller you didn’t identify before adding the patch.

If a patch changes behavior that doesn’t cause any exceptions, it can be difficult to track down the source to the patch. At a previous job we had code that was monkey patching Active Record’s query cache. It wasn’t version protected so it was forcing the application to use the query cache behavior similar to a Rails version that was end of life, rendering it useless since it wasn’t aware we had multiple database connections. We lost weeks to figuring out how to fix it and ultimately ended up deleting the entire gem from the application. This is just one of many examples I have of monkey patches silently changing behavior in surprising ways.

What to Do Instead of Monkey Patching

The important takeaway from this is that monkey patching is dangerous, brittle, and can change behavior in ways you don’t expect. So what do you do instead? As developers, it’s important that we dig into what is broken, why it is broken, and how to fix it without monkey patching.

Before monkey patching, ask yourself the following:

  • Will prioritizing an upgrade fix this? Sometimes if we find a bug in a framework or library it’s already fixed upstream. In that case, instead of monkey patching, do an upgrade.
  • Is there actually a bug? It’s possible that you’re using a library incorrectly and revisiting the documentation can help you understand how it’s supposed to work to avoid a patch that could cause bigger problems later on.
  • Can I fix this upstream? We should think of the open source tools we use every day as an extension of our applications and fix bugs upstream instead of monkey patching.

A Note on Refinements

When I bring up how dangerous monkey patching is, I’m often asked what I think about using refinements instead. A refinement is a feature of Ruby that lets you modify programs by providing a way to extend the class locally rather than globally like monkey patching. While they are more contained, I still don’t think refinements should be used and should definitely not be seen as “much better than monkey patching”.

The main reason is that refinements are slow—like really slow. In our own codebase at Shopify we no longer allow refinements because we found they were slowing down our Rails monolith. During an investigation we found that refinements were responsible for adding 13 seconds(!!) to boot time because refinements bust method caches. If you're using refinements anywhere in your codebase, the Ruby VM will become slower because it can’t make the same optimizations it does when not using refinements.

Even if they weren’t slow, refinements really aren’t much better than a monkey patch. If we look at the list of reasons monkey patching is bad, most of the same issues apply to refinements. Code altered by a refinement can still break in an upgrade, cause security issues, and it doesn’t fix any of the issues present in upstream code.

Why Are Monkey Patches Used So Often If They Are Dangerous?

At this point I have hopefully convinced you that monkey patching is dangerous. You’re probably wondering why it gets used so often if it so easily goes wrong.

I often see lovers of the Ruby language talking about how powerful monkey patching is without addressing the dangers it poses. In the early days of Ruby’s popularity, monkey patching was seen as a way to quickly change the behavior of a program. It’s seen as giving more freedom and flexibility to programs, compared to languages that either don’t allow monkey patching or make it very hard.

Now don’t get me wrong, I love Ruby and without monkey patching debugging would be harder, and we’d be stuck forking libraries or waiting for releases. The problem for me comes in when we deploy monkey patches to production with no plan for removal. Monkey patches should be used as temporary fixes while we wait for a release, not a permanent fixture of our applications. In addition, this means that we should never monkey patch code we own and control. Everywhere I’ve worked, I’ve seen teams monkey patch gems the company owns. This is entirely unnecessary and it invites all the problems we discussed earlier into our applications when we do this.

I think that often monkey patching happens because it’s easier than contributing upstream. I get it, first you need a reproduction, then open an issue, then hope a maintainer fixes the issue or merges your pull request. I’m not suggesting we wait for an infinite time to get problems fixed in libraries we don’t control. However, at a minimum we should be letting maintainers know that there is a problem, otherwise we risk hitting the same problems in our applications over and over again.

What If I Must Use a Monkey Patch?

Although it’d be amazing if we never monkey patched anything, we don’t live in a perfect world, so if you must monkey patch behavior in Rails or any other gem, there are some steps you can follow to limit the problems monkey patching causes. A couple years ago, Cameron Dutro from GitHub wrote a great blog post on Responsible Monkey Patching. Check if you're interested in some tactical ways to write less dangerous monkey patches.

When you absolutely must write a monkey patch follow these guidelines to ensure that they are less dangerous and don’t become a larger problem over time.

Notify Upstream Maintainers There’s an Issue

Before writing your patch, open an issue on the upstream project to get feedback from the maintainers. It’s possible that the bug you’re working on is fixed in a newer version and you just need to upgrade. It’s also possible that you’re implementing the feature incorrectly and changing how you’re using it will avoid the bug. Lastly, if it is a bug the maintainers will be able to confirm this for you and you’ve ensured that there is a public report documenting the incorrect behavior.

Try to Fix the Issue Upstream

Before deploying a monkey patch to your application, send a patch upstream. If it’s merged, even if you aren’t able to upgrade immediately, you’ll know which version the problem is fixed in. It also acts as public documentation for others on how the bug should be handled.

If your patch is accepted upstream, make sure to wrap your patch in a version check to avoid using it when it’s fixed upstream. This will prevent patches from getting left behind that are no longer valid or could cause a security incident later.

Store Patches in a Specific Location

If you must write a monkey patch, it’s best to store it in a directory, like /lib/patches that self-documents that it’s patching library code. Avoid patching in model or controller code or random files in lib/. This makes it easier to find monkey patches for removal later on. It also helps indicate that this is a directory for monkey patches and lessens the element of surprise.

Patches Should Be as Small as Possible—Use Inheritance Where Possible

When writing a monkey patch ensure that it only patches what is necessary. Avoid copying more code than you need to—the smaller the patch the less issues it will cause and the easier it will be to reason about.

When patching code, try to inherit from the original class and use super where possible. This will protect you from missing upstream behavior that has changed.

Document Monkey Patches Thoroughly

When adding a monkey patch to your codebase it should be well documented. Documentation should include what is being changed and why. It should clearly explain the behavior that is changing, how the bug was discovered, and include a link to the issue or pull request you opened upstream. There’s a non-zero chance that when that monkey patch causes an issue in production you won’t be available to answer questions about it. Good documentation on monkey patches is essential to ensuring that its purpose is clearly understood by anyone who looks at it in the future.

Test, and Test Well

If you monkey patch anything, that patch should be tested. This will help anyone trying to remove it in the future understand if the issue is properly fixed upstream. When upstream code is fixed and the monkey patch is removed the test you wrote should still pass (except in the case the API changed). While documentation is useful, tests are self-documenting and ensure that expected (and unexpected) behavior is well understood. Not testing the behavior of a monkey patch could mean that the edge cases it’s fixing aren’t clear and cause issues when the patch is removed.

Make a Plan for Removal

Too often when we monkey patch code we adopt a “write it and forget it” approach. If we must add a monkey patch there needs to be a plan for removal, even if that plan is years in the future. When a patch is added, open an issue in your team repo so you don’t forget to remove it later on. At Shopify we also have a handy “TODO” gem that will send a slack notification reminding us to do something. It’s a good idea to add a “TODO” for any monkey patch you add to the codebase.

Help! My Codebase Is Already Full of Patches!

If your codebase is already full of monkey patches and you want to remove them, don’t fret! You can slowly burn down patches to get your codebase to a better place. First, find all the patches and make a list of where they are located. If your patches aren’t all in one directory this can be difficult, but as you find them move them into your patches directory so they are easier to find. 

Identify which patches can be upstreamed to Rails and which patches are no longer necessary because the bug was fixed upstream. Tackle patches one by one and eventually your codebase will be much cleaner. This will require a mindset shift on your engineering teams to view monkey patches as dangerous and something to avoid, rather than a quick fix for any problems coming from outside your application. Thinking of Rails as an extension of your application and feeling a sense of ownership over it, instead of thinking of it as “someone else’s framework” is essential to having an application free of monkey patches.

Get Deleting!

I hope that this post has convinced you to avoid monkey patching in the future and given you tools to write better monkey patches in the cases you must absolutely patch. At Shopify we run our main monolith on Rails main. One of the benefits of this is when we find a bug we can fix it upstream and avoid having to add a monkey patch. We never have to wait for a release of Rails, which gives us the freedom to keep our codebase clean.

We of course still have legacy patches that we’re still trying to get rid of. Those legacy patches often get in the way of us bumping Rails weekly, so the advice in this post comes directly from experience. I hope that this post has shown you how dangerous monkey patching can be, how it wastes time, causes security issues, and in general just makes development harder. I implore you to look through your codebase and identify patches that should be removed. Send PRs upstream and end 2023 with less monkey patches than you started with.

There’s really nothing quite as satisfying as deleting code, especially code that is a recipe for future (and current) problems. Happy deleting!

Eileen Uchitelle is a Senior Staff Production Engineer on Shopify’s Ruby & Rails Infrastructure team, and a Rails core team member. Find her on Twitter or GitHub as @eileencodes, or at eileencodes.com.

Open source software plays a vital and integral part at Shopify. If being a part of an Engineering organization that’s committed to the support and stewardship of open source software sounds exciting to you, visit our Engineering career page to find out about our open positions and learn about Digital by Design.