2025, Oct 21 18:00

Monkey patching Python __new__ in CPython: why TypeError appears and how to safely restore behavior

Learn why CPython raises TypeError after monkey patching Python __new__, how to restore default object construction, and when class recreation ensures rollback.

Monkey patching Python’s object construction can look straightforward until you touch __new__. A seemingly simple change like replacing a class’s allocator and then trying to revert it back may yield a TypeError in CPython, while doing the “same” operation from a clean state works fine. This guide shows the exact behavior, why it happens in practice, and how to recover the original semantics safely when __new__ was patched.

Reproducing the issue

Consider a third-party class you cannot change directly. You patch its __new__ to intercept object creation and later try to reset it to the default implementation. The first step works as expected.

class Alpha:
    def __init__(self, val):
        self.val = val

def hook_new(typ, *args, **kwargs):
    print("hook_new called")
    return object.__new__(typ)

Alpha.__new__ = hook_new

Alpha(10)
# hook_new called
# <__main__.Alpha object at 0x...>

Now you attempt to restore the original by assigning object.__new__ directly. The call fails with a TypeError.

Alpha.__new__ = object.__new__
Alpha(10)
# TypeError: object.__new__() takes exactly one argument (the type to instantiate)

The surprising part is that if you set __new__ to object.__new__ from the outset, without an earlier monkey patch, it works.

class Beta:
    def __init__(self, val):
        self.val = val

Beta.__new__ = object.__new__

Beta(10)
# works OK

What’s actually happening

This is a CPython-specific quirk. After a class’s __new__ is monkey patched, CPython internally marks the type’s allocation path as “touched.” From that point on, even if you later assign object.__new__ back, the call path no longer behaves like the pristine, never-patched class. The difference shows up in argument handling: object.__new__ is special-cased by CPython when it’s never overridden, but once the slot is marked used, CPython no longer ignores extra arguments in the same way.

Intuitively, you might expect that deleting the monkey-patched attribute would revert the behavior, since attribute lookup should fall back to object.__new__. In practice, deleting the attribute doesn’t restore the original semantics in CPython due to that internal “touched” state.

Attempting the obvious revert (and why it still fails)

The intent is to remove the override and fall back to the default allocator. In CPython, that still does not bring back the initial call semantics.

class Alpha:
    def __init__(self, val):
        self.val = val

def hook_new(typ, *args, **kwargs):
    print("hook_new called")
    return object.__new__(typ)

Alpha.__new__ = hook_new
Alpha(10)

# Try to revert
del Alpha.__new__
Alpha(10)
# Still raises TypeError in CPython after the type was touched

The outcome is tied to CPython internals and how it optimizes access to __new__. It’s an implementation side-effect rather than a property guaranteed by the language spec.

A practical workaround that reliably restores behavior

To get back to pre-patch semantics, recreate the class object. You can do this by calling type with the original class’s name, bases, and a shallow copy of its namespace. That produces a fresh type that hasn’t had its __new__ slot touched.

class RootBase:
    pass

class Alpha(RootBase):
    def __init__(self, val):
        self.val = val

def hook_new(typ, *args):
    print("hook_new called", *args)
    return object.__new__(typ)

# Patch
Alpha.__new__ = hook_new
Alpha(23)

# Remove the attribute; in CPython this is not enough
del Alpha.__new__
# Alpha(23) would still fail here in CPython

# Recreate the class from its pieces
Alpha = type(Alpha.__name__, Alpha.__bases__, dict(Alpha.__dict__))

# Works again with pristine semantics
Alpha(23)

Notes on implementations

This effect is specific to CPython’s implementation details and may even be considered a candidate for a bug report. Another well-known Python implementation, PyPy, does not exhibit this behavior. On PyPy, after monkey patching __new__, simply deleting the attribute restores normal construction.

class Gamma:
    def __init__(self, val):
        self.val = val

def hook_new(typ, *args):
    print("hook_new called", *args)
    return object.__new__(typ)

Gamma.__new__ = hook_new
Gamma(23)  # prints the message

del Gamma.__new__
Gamma(23)  # works again on PyPy

Why this matters for production code

If you’re instrumenting or hot-fixing third-party classes at runtime, the constructor path is the most fragile place to do it. CPython’s internal handling of __new__ means a seemingly reversible patch isn’t actually reversible by simple reassignment or deletion, and you might ship code that works on one interpreter and fails on another or vice versa. Being aware of this edge case prevents confusing TypeError crashes during object creation, especially in long-running processes that dynamically alter classes.

Practical guidance and takeaways

If you must intercept object creation via __new__, treat the class as tainted for the lifetime of the process in CPython. Do not assume that setting __new__ back to object.__new__ or deleting the attribute will faithfully restore the original behavior. When you need a clean slate, recreate the class object with type using its current name, bases, and a shallow copy of its dict. If your code targets multiple Python implementations, validate the behavior in your CI matrix, because PyPy does not suffer from the same issue and may pass where CPython fails.

In short, monkey patching __new__ is powerful but comes with interpreter-specific semantics. If you need reversibility, rely on class recreation rather than attribute deletion or reassignment.

The article is based on a question from StackOverflow by Gerges and an answer by jsbueno.