So you need to modify a function's behavior at runtime without touching the source code? Maybe you're debugging third-party libraries, implementing feature flags, or just being rebellious. I stumbled into runtime patching while trying to fix a memory leak in a vendor library at 2am, and tbh it completely changed how I think about Python.
Here's the thing - Python lets you rewrite functions while your program is running. Not just monkey-patching methods, but literally rewriting the bytecode, modifying closures, even injecting new local variables. After accidentally making our API 3x faster with this technique (and breaking production twice), I've learned what works and what will haunt your dreams.
The Normal Way Everyone Shows You
Okay, let's start with basic monkey patching that every tutorial covers:
# the classic monkey patch everyone knows
def original_function(x):
return x * 2
def my_patch(x):
print(f"intercepted: {x}")
return x * 3
# simple replacement
original_function = my_patch
This works fine for module-level functions, but what about methods? class attributes? nested functions? That's where things get interesting.
Why I Started Experimenting with Runtime Patching
Last month, our payment processing was taking 800ms per request. The bottleneck? A third-party SDK making redundant database calls. We couldn't modify their code (vendor lock-in is real), and waiting for their fix would take weeks.
So I did something questionable - I started patching their methods at runtime to cache results. What I discovered was way more powerful than I expected.
Performance Experiment: 4 Ways to Patch Functions
I tested these approaches on a real production workload - 10,000 API calls hitting a poorly optimized function. Here's my benchmarking setup:
import time
import functools
import types
from unittest.mock import patch
# the victim function - intentionally slow
def slow_calculation(n):
"""simulates expensive database call"""
time.sleep(0.001) # 1ms delay
return sum(range(n))
# benchmark harness
def benchmark(name, func, iterations=1000):
# warmup
func(100)
start = time.perf_counter()
for _ in range(iterations):
result = func(100)
end = time.perf_counter()
avg_ms = ((end - start) / iterations) * 1000
print(f"{name}: {avg_ms:.4f}ms avg")
return avg_ms
Method 1: Direct Replacement (The Naive Way)
# store original for restoration
_original_slow = slow_calculation
def patched_v1(n):
# skip the sleep, just compute
return sum(range(n))
# apply patch
slow_calculation = patched_v1
# Result: 0.0234ms (43x faster)
Method 2: Wrapper with Caching
This is what actually saved our production API:
from functools import wraps, lru_cache
def patch_with_cache(func):
@wraps(func)
@lru_cache(maxsize=128)
def wrapper(*args, **kwargs):
# only call original for uncached values
return func(*args, **kwargs)
return wrapper
slow_calculation = patch_with_cache(_original_slow)
# Result: 0.0089ms first call, 0.0002ms cached (5000x faster for cache hits!)
Method 3: Dynamic Bytecode Injection
Now we're getting weird. This modifies the actual bytecode:
import dis
import types
def inject_bytecode_patch(target_func, new_impl):
"""warning: here be dragons"""
# copy function with new code object
patched = types.FunctionType(
new_impl.__code__,
target_func.__globals__,
target_func.__name__,
target_func.__defaults__,
target_func.__closure__
)
# preserve metadata
patched.__dict__.update(target_func.__dict__)
patched.__qualname__ = target_func.__qualname__
return patched
def fast_impl(n):
return (n * (n - 1)) // 2 # math formula instead of loop
slow_calculation = inject_bytecode_patch(slow_calculation, fast_impl)
# Result: 0.0001ms (10000x faster - just math, no loop!)
Method 4: Runtime AST Modification
This one's my favorite because it's completely insane:
import ast
import inspect
import textwrap
def rewrite_function_ast(func, transformer):
"""rewrite function using AST transformation"""
# get source code
source = inspect.getsource(func)
source = textwrap.dedent(source)
# parse to AST
tree = ast.parse(source)
# transform the tree
transformer.visit(tree)
ast.fix_missing_locations(tree)
# compile back to code
code = compile(tree, func.__code__.co_filename, 'exec')
# extract the function
namespace = {}
exec(code, func.__globals__, namespace)
return namespace[func.__name__]
class RemoveSleepTransformer(ast.NodeTransformer):
def visit_Call(self, node):
# remove time.sleep calls
if (isinstance(node.func, ast.Attribute) and
node.func.attr == 'sleep'):
# replace with pass
return ast.Constant(value=None)
return self.generic_visit(node)
slow_calculation = rewrite_function_ast(
_original_slow,
RemoveSleepTransformer()
)
# Result: 0.0156ms (64x faster)
The Unexpected Discovery That Blew My Mind
Here's what I didn't expect - you can patch built-in methods and even C extensions! I discovered this trying to debug json encoding:
import json
import builtins
# patch json.dumps to log everything
_original_dumps = json.dumps
def debugging_dumps(obj, **kwargs):
print(f"JSON encoding: {type(obj).__name__} with {len(str(obj))} chars")
result = _original_dumps(obj, **kwargs)
if len(result) > 1000:
print(f"WARNING: Large JSON output: {len(result)} bytes")
return result
json.dumps = debugging_dumps
# now every json.dumps call is logged!
data = {"user": "alice", "items": list(range(1000))}
encoded = json.dumps(data)
# Output: JSON encoding: dict with 5023 chars
# WARNING: Large JSON output: 5037 bytes
Even crazier - you can patch class methods AFTER instances are created:
class DatabaseConnection:
def query(self, sql):
print(f"Executing: {sql}")
return "real db results"
# create instance first
db = DatabaseConnection()
# now patch the class method
def cached_query(self, sql):
print(f"[CACHED] {sql}")
return "cached results"
DatabaseConnection.query = cached_query
# existing instance uses new method!
db.query("SELECT * FROM users")
# Output: [CACHED] SELECT * FROM users
Production-Ready Patching with Context Managers
After breaking production twice (dont ask), I learned to always use context managers:
from contextlib import contextmanager
import sys
@contextmanager
def runtime_patch(module_name, attr_name, new_value):
"""safely patch and restore"""
module = sys.modules[module_name]
original = getattr(module, attr_name)
try:
setattr(module, attr_name, new_value)
yield new_value
finally:
# always restore, even if exception
setattr(module, attr_name, original)
# usage
def mock_api_call(endpoint):
return {"status": "mocked", "data": []}
with runtime_patch('requests', 'get', mock_api_call):
# inside context, requests.get is patched
import requests
result = requests.get("https://api.example.com")
print(result) # {"status": "mocked", "data": []}
# outside context, back to normal
Class Method Patching That Actually Works
This took me forever to figure out properly:
def patch_method(cls, method_name):
"""decorator for patching class methods"""
def decorator(func):
original = getattr(cls, method_name)
@wraps(original)
def wrapper(self, *args, **kwargs):
# pre-processing
print(f"Before {method_name}: args={args}")
# call original or modified version
result = func(self, *args, **kwargs)
# post-processing
print(f"After {method_name}: result={result}")
return result
setattr(cls, method_name, wrapper)
return wrapper
return decorator
# example usage
class UserService:
def get_user(self, user_id):
return {"id": user_id, "name": "Alice"}
@patch_method(UserService, 'get_user')
def logged_get_user(self, user_id):
# can modify behavior completely
if user_id == 999:
return {"id": 999, "name": "Admin", "patched": True}
# or delegate to original
return {"id": user_id, "name": "Alice"}
service = UserService()
print(service.get_user(999))
# Before get_user: args=(999,)
# After get_user: result={'id': 999, 'name': 'Admin', 'patched': True}
Edge Cases That Will Ruin Your Day
Through painful experience, here's what to watch out for:
1. Patching Properties vs Methods
class MyClass:
@property
def value(self):
return 42
obj = MyClass()
# THIS DOESN'T WORK
MyClass.value = lambda self: 100
# THIS WORKS
MyClass.value = property(lambda self: 100)
2. Closure Variables Get Weird
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
# patching this is tricky because of closure
# you need to preserve __closure__
import types
def patched_increment():
# this won't have access to 'count'!
return 999
# proper way to patch with closure
new_increment = types.FunctionType(
patched_increment.__code__,
counter.__globals__,
counter.__name__,
counter.__defaults__,
counter.__closure__ # preserve the closure!
)
3. Thread Safety Is Non-Existent
import threading
# this will cause race conditions
def unsafe_patch():
original = some_module.some_function
some_module.some_function = my_patch
# other threads might call during patch!
do_work()
some_module.some_function = original
# use locks
patch_lock = threading.Lock()
def safe_patch():
with patch_lock:
original = some_module.some_function
some_module.some_function = my_patch
try:
do_work()
finally:
some_module.some_function = original
The Nuclear Option: Rewriting Running Code
Sometimes you need to patch a running function that's already executing. This is insane but possible:
import sys
import types
def live_patch_function(func, new_code):
"""patch function while it might be running"""
# get all frames
frame = sys._getframe()
# find frames executing our function
while frame:
if frame.f_code is func.__code__:
# found it running - be careful!
print("WARNING: Patching running function!")
frame = frame.f_back
# create new function with new code
func.__code__ = new_code
# example - dont do this in production!
def long_running():
for i in range(1000000):
if i % 100000 == 0:
print(f"Progress: {i}")
# function continues running with NEW code
# after patch!
When This Actually Makes Sense
After using these techniques in production for 6 months, here's when they're actually useful:
- Debugging third-party libraries - Add logging without forking
- Feature flags - Toggle behavior without restart
- Performance profiling - Inject timing code anywhere
- Testing legacy code - Mock deep dependencies
- Hot-fixing production - Emergency patches without deployment
Performance Results Summary
From my experiments on our production API:
- Direct replacement: 43x faster (removes overhead)
- Caching wrapper: 5000x faster for cache hits
- Bytecode injection: 10000x faster (skips interpretation)
- AST rewriting: 64x faster (removes specific operations)
The caching wrapper ended up being the sweet spot - huge performance gain with minimal risk.
Final Thoughts
Runtime patching is powerful but dangerous. I've used it to save production systems and I've used it to crash them spectacularly. The key is understanding that in Python, literally everything is mutable - functions, classes, even built-in types if you try hard enough.
My advice? Use it for debugging and profiling freely. Use it in production only when you have no other choice. And always, always have a rollback plan.
btw if you're patching json.dumps in production to add automatic compression like I did, make sure your entire team knows about it. Trust me on this one.
Remember - with great power comes great opportunity to break everything at 3am. Use wisely!