Monkey Patching in Python: Complete Runtime Modification and Dynamic Programming Techniques Guide

Monkey Patching in python

Monkey Patching in python

The first time someone told me Python lets you modify a class or module after it’s already been defined — even classes from libraries you didn’t write — I assumed they were exaggerating. They weren’t. Monkey patching is real, it’s built into how dynamic Python’s object model is, and I’ve used it both to save myself from disaster during a production incident and to shoot myself in the foot during a debugging session that took way longer than it should have. This guide covers what monkey patching actually is, how it works under the hood, when it’s a legitimate tool, and when it’s a trap.

What Monkey Patching Actually Means

Monkey patching is modifying or extending code — a class, a module, or an object — at runtime, without touching its original source. Because Python classes and modules are just objects with mutable namespaces (usually backed by a __dict__), you can reach in and change them after the fact.

class Greeter:
    def hello(self):
        return "Hello"

def new_hello(self):
    return "Hola"

Greeter.hello = new_hello

g = Greeter()
print(g.hello())  # Hola

Nothing in Greeter‘s original definition changed. I reassigned the hello name inside Greeter‘s namespace to point at a different function object. Every existing and future instance of Greeter now uses the new behavior, because method lookup happens dynamically at call time, not baked in at class-definition time.

Why This Works: Python’s Object Model

This is possible because classes in Python are, themselves, instances of type (or a metaclass), and their attributes live in a mutable mapping. There’s no compile-time binding that locks Greeter.hello to a specific function forever — Greeter.hello is just a dictionary lookup at attribute-access time. The same applies to modules: sys.modules['some_module'] is a live object you can mutate.

import math

original_sqrt = math.sqrt

def patched_sqrt(x):
    print(f"Computing sqrt of {x}")
    return original_sqrt(x)

math.sqrt = patched_sqrt
print(math.sqrt(16))
# Computing sqrt of 16
# 4.0

Every piece of code in the process that does import math; math.sqrt(...) after this patch runs the patched version — because math is a single shared module object cached in sys.modules, not re-imported fresh each time.

Legitimate Uses

1. Testing — unittest.mock.patch

By far the most common and defensible use of monkey patching is in tests, where you temporarily replace a dependency (an API call, a database write, a file operation) with a controlled fake:

from unittest.mock import patch
import requests

def get_user(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    return response.json()

def test_get_user():
    with patch("requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"id": 1, "name": "Test User"}
        result = get_user(1)
        assert result["name"] == "Test User"

unittest.mock.patch handles the patch-and-restore cycle safely — it automatically undoes the change when the with block exits, even if the test raises an exception, which is exactly the discipline you want when mutating shared global state.

2. Fixing or Working Around Third-Party Bugs

If a library has a bug and you can’t wait for an upstream fix, monkey patching lets you override the specific broken method without forking the whole package:

import some_library

def fixed_method(self, *args, **kwargs):
    # corrected implementation
    return self._value * 2

some_library.SomeClass.buggy_method = fixed_method

This is legitimate, but it’s also fragile — it depends on the library’s internal structure staying stable across versions, and it’s invisible to anyone reading some_library‘s own source.

3. Adding Compatibility Shims

Monkey patching is a common technique for backporting or polyfilling functionality — adding a method that exists in a newer Python/library version to an environment running an older one:

if not hasattr(str, "removeprefix"):  # Python < 3.9 compatibility
    def removeprefix(self, prefix):
        return self[len(prefix):] if self.startswith(prefix) else self[:]
    # Note: built-in types like str can't actually be patched this way in CPython;
    # this pattern is typically applied to custom classes or wrapper types instead.

(Worth noting explicitly: CPython’s built-in types like str, int, and list are implemented in C and are immutable at the type level for performance and safety reasons — you cannot monkey patch str directly. Patching works on Python-defined classes, modules, and instances, not C-level built-ins.)

The Risks

Global, Invisible Side Effects

The core danger of monkey patching is that it changes behavior globally and silently. If you patch requests.get somewhere deep in a test helper and forget to restore it, every subsequent test in the same process can be affected — a classic source of “why does this test fail only when run after that other test” bugs.

Debugging Becomes Archaeology

When behavior doesn’t match the source code you’re reading, and the actual cause is a patch applied somewhere else entirely (maybe in a completely different file, imported for its side effects), tracking it down is genuinely painful. I’ve lost real hours to this exact scenario.

Fragility Across Versions

Patches that reach into private/internal attributes of a library are coupled to that library’s implementation details, not its public API. A minor version bump can silently break your patch — or worse, not break it loudly, just make it stop having the intended effect.

Doing It Safely

If you do need to monkey patch outside of tests, a few practices make it much less dangerous:

import contextlib

@contextlib.contextmanager
def patched_method(cls, method_name, new_func):
    original = getattr(cls, method_name)
    setattr(cls, method_name, new_func)
    try:
        yield
    finally:
        setattr(cls, method_name, original)

class Service:
    def call(self):
        return "real"

with patched_method(Service, "call", lambda self: "patched"):
    print(Service().call())  # patched

print(Service().call())      # real

Scoping the patch to a context manager (or using unittest.mock.patch directly, which does the same thing) ensures the original behavior is always restored, even on exceptions, and makes the scope of the modification explicit and visible in the code rather than a permanent, hidden global mutation.

Monkey Patching vs Subclassing vs Composition

Before reaching for a monkey patch, it’s worth asking whether subclassing or composition would achieve the same goal more safely:

# Subclassing instead of patching
class FixedSomeClass(some_library.SomeClass):
    def buggy_method(self, *args, **kwargs):
        return self._value * 2

Subclassing keeps the change local and explicit — only code that uses FixedSomeClass sees the new behavior, unlike a monkey patch that changes SomeClass for every piece of code in the process. Monkey patching is the right tool specifically when you can’t control instantiation (the object is created deep inside a third-party library you’re calling into) or when you’re deliberately isolating behavior for a test.

Common Mistakes

FAQs

Q: Is monkey patching considered Pythonic? It’s a well-established, sanctioned technique in specific contexts (testing above all), but it’s not something to reach for by default. Idiomatic Python favors explicit subclassing or composition when either is a real option.

Q: Can monkey patching affect performance? Generally the runtime cost of the patched method itself is what matters (same as any function call); the mechanism of patching adds no meaningful overhead. The real cost is maintenance and debugging complexity, not CPU cycles.

Q: Does monkey patching work the same way for instances as for classes? You can patch a single instance’s attribute too (instance.method = new_func.__get__(instance)), which affects only that one object rather than the whole class — useful when you want to override behavior for a single object rather than everything.

Summary

Monkey patching takes advantage of Python’s fundamentally dynamic object model — classes and modules are mutable namespaces you can modify after the fact. It’s an essential, well-supported technique for testing (unittest.mock.patch) and a legitimate, if riskier, tool for working around bugs or adding compatibility shims. Used without discipline, it produces global, invisible, hard-to-debug side effects; used with scoping (context managers, mock.patch, clear documentation) it’s a precise, powerful tool rather than a landmine.

References

Exit mobile version