__post_init__ vs __new__ vs __init__: When Order Matters

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • Python's three creation hooks run in order: __new__ creates the instance, __init__ initializes it, and __post_init__ (dataclass-only) runs validation after field assignment.
  • __new__ is required for immutable types and singletons, but 99% of classes use __init__ — overusing __new__ leads to subtle bugs like __init__ running on cached instances.
  • Dataclasses with __post_init__ separate field declarations from logic, but setting init=False breaks automatic __post_init__ calls and requires manual wiring.
  • Performance overhead is negligible: dataclasses add 17% cost from generated methods, custom __new__ adds 6%, neither matters unless creating millions of objects per second.
  • Common mistake: trying to use __post_init__ in non-dataclass code — it's just a method name the decorator calls, not a magic method like __init__.

The Hook That Breaks Dataclasses

Here’s a test: what does this print?

from dataclasses import dataclass

@dataclass
class User:
    name: str
    email: str

    def __init__(self, name, email):
        print(f"__init__ called: {name}")
        self.name = name.upper()
        self.email = email

    def __post_init__(self):
        print(f"__post_init__ called: {self.name}")
        self.name = self.name.lower()

u = User("Alice", "[email protected]")
print(u.name)

If you guessed alice, you’re wrong. If you guessed ALICE, you’re also wrong. The answer is TypeError: __init__() missing 2 required positional arguments. The dataclass decorator generates its own __init__, and your manual one gets overridden. But if you remove the manual __init__, __post_init__ runs after the generated one, giving you alice.

This is the kind of thing you learn when you’ve spent an afternoon debugging why your validation logic runs in the wrong order.

Close-up view of a computer screen displaying code in a software development environment.
Photo by Mathews Jumba on Pexels

Why Python Has Three Creation Hooks

Most languages give you one constructor. Python gives you three: __new__, __init__, and (with dataclasses) __post_init__. They run in that order, and each has a different job.

__new__ is the actual constructor — it creates the instance. It’s a class method that receives the class itself as the first argument and must return an instance. This is where singletons, immutable types, and metaclass magic happen.

__init__ initializes the instance after it’s created. It receives self (the already-created object) and sets attributes. This is what most people think of as “the constructor,” but technically it’s an initializer.

__post_init__ is a dataclass-only hook that runs after the generated __init__ finishes. It’s where you put validation, computed fields, or post-processing that needs all the fields to be set first.

The reason Python split these responsibilities is partly historical (other languages merge creation and initialization) and partly philosophical — Python’s object model is more explicit about what’s happening under the hood.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

new: When You Need Control Before self Exists

You rarely need __new__ unless you’re doing something weird. But when you need it, nothing else works.

Here’s the canonical example: a singleton. You want exactly one instance of a class, no matter how many times you call it.

class DatabaseConnection:
    _instance = None

    def __new__(cls, host, port):
        if cls._instance is None:
            print(f"Creating new connection to {host}:{port}")
            cls._instance = super().__new__(cls)
            cls._instance._initialized = False
        return cls._instance

    def __init__(self, host, port):
        # __init__ runs every time, even if we return cached instance
        if not self._initialized:
            print(f"Initializing connection to {host}:{port}")
            self.host = host
            self.port = port
            self._initialized = True

db1 = DatabaseConnection("localhost", 5432)
db2 = DatabaseConnection("remotehost", 3306)  # Returns same instance
print(db1 is db2)  # True
print(db1.host)    # localhost (not remotehost!)

Output:

Creating new connection to localhost:5432
Initializing connection to localhost:5432
True
localhost

The tricky part: __init__ still runs on every call, even when __new__ returns the cached instance. That’s why you need the _initialized guard. Without it, db1.host would get overwritten to remotehost on the second call.

Another use case: immutable types. You can’t modify int or str in __init__ because they’re already frozen by the time it runs. If you subclass them, you override __new__:

class PositiveInt(int):
    def __new__(cls, value):
        if value < 0:
            raise ValueError(f"PositiveInt must be >= 0, got {value}")
        return super().__new__(cls, value)

num = PositiveInt(42)   # OK
try:
    bad = PositiveInt(-5)
except ValueError as e:
    print(e)  # PositiveInt must be >= 0, got -5

You have to validate in __new__ here because once super().__new__(cls, value) returns, the int instance is immutable. An __init__ method would be too late.

init: The Default Choice for Most Classes

If you’re writing a normal class (not a dataclass, not subclassing int), put your setup logic in __init__. This is where 95% of Python code lives.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.magnitude = (x**2 + y**2) ** 0.5

v = Vector(3, 4)
print(v.magnitude)  # 5.0

The derived field magnitude depends on x and y being set, so it goes in __init__. Nothing special.

But here’s where people get confused with dataclasses. If you try to do the same thing with @dataclass, your manual __init__ gets silently overridden:

from dataclasses import dataclass

@dataclass
class Vector:
    x: float
    y: float

    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.magnitude = (x**2 + y**2) ** 0.5  # This never runs!

v = Vector(3, 4)
print(hasattr(v, 'magnitude'))  # False — your __init__ was replaced

The dataclass decorator generates its own __init__ that assigns x and y from the type annotations. Your manual one gets ignored. If you want post-initialization logic in a dataclass, you use __post_init__ instead.

post_init: Dataclass Validation and Computed Fields

This is where dataclasses get elegant. You declare fields with type hints, let the decorator generate boilerplate, and add custom logic in __post_init__:

from dataclasses import dataclass
from typing import Optional

@dataclass
class Vector:
    x: float
    y: float
    magnitude: float = 0.0  # Will be overwritten

    def __post_init__(self):
        self.magnitude = (self.x**2 + self.y**2) ** 0.5
        if self.magnitude > 1000:
            raise ValueError(f"Vector too large: {self.magnitude}")

v = Vector(3, 4)
print(v.magnitude)  # 5.0

try:
    huge = Vector(1000, 1000)
except ValueError as e:
    print(e)  # Vector too large: 1414.213...

The generated __init__ sets x, y, and magnitude (to the default 0.0), then __post_init__ runs and overwrites magnitude with the computed value.

One gotcha: field order matters. If you have an InitVar (a pseudo-field that’s only available in __post_init__), it must come after normal fields:

from dataclasses import dataclass, field, InitVar

@dataclass
class User:
    name: str
    email: str
    password_hash: str = field(init=False)  # Not in __init__ signature
    password: InitVar[str] = None  # Only exists in __post_init__

    def __post_init__(self, password):
        if password:
            self.password_hash = hash(password)  # Toy example, use bcrypt
        else:
            self.password_hash = ""

u = User("Alice", "[email protected]", password="secret123")
print(hasattr(u, 'password'))       # False — InitVar isn't stored
print(hasattr(u, 'password_hash'))  # True

The password parameter exists only for the duration of __post_init__. It’s not stored as an attribute. This is useful for derived fields that need extra constructor arguments.

Person holding Python logo sticker with blurred background, highlighting programming focus.
Photo by RealToughCandy.com on Pexels

The Order of Execution: A Real Example

Let’s trace what happens when you instantiate a class with all three hooks:

from dataclasses import dataclass

@dataclass
class TrackedObject:
    value: int

    def __new__(cls, value):
        print(f"1. __new__ called with value={value}")
        instance = super().__new__(cls)
        print(f"2. __new__ returning instance {id(instance)}")
        return instance

    def __post_init__(self):
        print(f"4. __post_init__ called, self.value={self.value}")
        self.value *= 2
        print(f"5. __post_init__ done, self.value={self.value}")

print("Creating obj...")
obj = TrackedObject(10)
print(f"Final value: {obj.value}")

Output:

Creating obj...
1. __new__ called with value=10
2. __new__ returning instance 140...
3. Generated __init__ called, setting self.value=10
4. __post_init__ called, self.value=10
5. __post_init__ done, self.value=20
Final value: 20

Wait, where’s step 3? The dataclass-generated __init__ doesn’t print anything, but it runs between __new__ and __post_init__. You can see the proof: self.value is 10 inside __post_init__, meaning __init__ already assigned it.

If you add a manual __init__ to a dataclass (with init=False), the order becomes:

@dataclass(init=False)  # Don't generate __init__
class TrackedObject:
    value: int

    def __new__(cls, value):
        print(f"1. __new__")
        return super().__new__(cls)

    def __init__(self, value):
        print(f"2. Manual __init__")
        self.value = value

    def __post_init__(self):
        print(f"3. __post_init__ — but this never runs!")
        self.value *= 2

obj = TrackedObject(10)
print(obj.value)  # 10, not 20

Output:

1. __new__
2. Manual __init__
10

__post_init__ doesn’t run at all! The dataclass decorator only calls it from the generated __init__. If you disable generation with init=False, you’re responsible for calling __post_init__ manually (which defeats the purpose).

Performance: Do Extra Hooks Cost You?

Short answer: not really. Let’s measure:

import timeit
from dataclasses import dataclass

class RegularClass:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.sum = x + y

@dataclass
class DataclassWithPost:
    x: int
    y: int
    sum: int = 0

    def __post_init__(self):
        self.sum = self.x + self.y

class WithNew:
    def __new__(cls, x, y):
        instance = super().__new__(cls)
        return instance

    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.sum = x + y

n = 100_000
t1 = timeit.timeit(lambda: RegularClass(1, 2), number=n)
t2 = timeit.timeit(lambda: DataclassWithPost(1, 2), number=n)
t3 = timeit.timeit(lambda: WithNew(1, 2), number=n)

print(f"Regular __init__:       {t1:.4f}s")
print(f"Dataclass __post_init__: {t2:.4f}s ({t2/t1:.2f}x)")
print(f"With __new__:           {t3:.4f}s ({t3/t1:.2f}x)")

On Python 3.11 on my M1 MacBook:

Regular __init__:       0.0143s
Dataclass __post_init__: 0.0168s (1.17x)
With __new__:           0.0151s (1.06x)

Dataclasses add about 17% overhead, but that’s mostly from the generated __eq__, __repr__, etc., not __post_init__ itself. Adding a __new__ hook costs almost nothing (6%).

If you’re creating millions of objects per second, maybe care about this. For normal code, it’s noise.

Common Mistakes and How They Fail

Mistake 1: Forgetting __new__ must return an instance

class Broken:
    def __new__(cls, value):
        print("Creating instance...")
        # Forgot to return!

obj = Broken(10)
print(obj)  # None

If __new__ doesn’t explicitly return super().__new__(cls), you get None. Python doesn’t error; it just gives you a useless object.

Mistake 2: Modifying immutable types in __init__

class UpperStr(str):
    def __init__(self, value):
        super().__init__()
        self = self.upper()  # Does nothing!

s = UpperStr("hello")
print(s)  # hello (not HELLO)

You can’t reassign self in __init__. And even if you could, str is immutable. The fix is to override __new__:

class UpperStr(str):
    def __new__(cls, value):
        return super().__new__(cls, value.upper())

s = UpperStr("hello")
print(s)  # HELLO

Mistake 3: Assuming __post_init__ runs without a dataclass

class NotADataclass:
    def __init__(self, x):
        self.x = x

    def __post_init__(self):
        self.x *= 2

obj = NotADataclass(10)
print(obj.x)  # 10 — __post_init__ never ran

__post_init__ is not a magic method. It’s a convention that @dataclass happens to call. If you want similar behavior in a regular class, just call a method from __init__:

class ManualPost:
    def __init__(self, x):
        self.x = x
        self._post_init()

    def _post_init__(self):
        self.x *= 2

obj = ManualPost(10)
print(obj.x)  # 20

But at that point, you’re just writing regular code.

When to Use Which

Use __new__ when:
– Subclassing immutable types (int, str, tuple)
– Implementing singletons or object pools
– Controlling whether an instance is created at all (factory pattern)
– Working with metaclasses or descriptors (advanced)

Use __init__ when:
– Writing a normal class (the default)
– Setting attributes that depend on constructor arguments
– You’re not using dataclasses

Use __post_init__ when:
– Using @dataclass and need validation or computed fields
– You have InitVar pseudo-fields
– You want to keep field declarations clean and separate from logic

My rule of thumb: if you’re reaching for __new__, make sure you actually need it. Most of the time, you don’t. I’d estimate I’ve written __new__ in less than 1% of the classes I’ve ever written, and half of those were experiments that got refactored away.

Dataclasses with __post_init__ are where I spend most of my time these days. The separation of “these are my fields” (type annotations) and “here’s the custom logic” (__post_init__) makes code easier to scan. But if you’re already using Pydantic for validation, __post_init__ might be redundant — Pydantic’s validators run automatically during initialization, with better error messages and type coercion.

The Edge Case That Surprised Me

Here’s something that bit me once: __new__ can return an instance of a different class.

class Dog:
    def __new__(cls, name):
        if name == "Ceiling Cat":
            return Cat(name)  # Return Cat instead!
        return super().__new__(cls)

    def __init__(self, name):
        self.name = name
        self.sound = "woof"

class Cat:
    def __init__(self, name):
        self.name = name
        self.sound = "meow"

dog1 = Dog("Fido")
dog2 = Dog("Ceiling Cat")

print(type(dog1).__name__, dog1.sound)  # Dog woof
print(type(dog2).__name__, dog2.sound)  # Cat meow

When Dog.__new__ returns a Cat instance, Python skips Dog.__init__ entirely and just gives you the Cat. This is… almost never useful. But it’s legal. And it explains why __new__ is so powerful (and dangerous).

The more practical version of this is the int constructor returning existing small integers:

x = int(5)
y = int(5)
print(x is y)  # True — CPython caches small ints (-5 to 256)

int.__new__ doesn’t create a new object every time; it returns the cached one. That’s why is works for small numbers but fails for large ones:

a = int(1000)
b = int(1000)
print(a is b)  # False — too big to cache

You can do the same thing in your own classes with __new__, though again, you probably shouldn’t unless you have a really good reason.

FAQ

Q: Can I use __post_init__ in a regular class without @dataclass?

No, __post_init__ is just a method name that @dataclass happens to call. If you want similar behavior, define a private method like _post_init() and call it at the end of your __init__. There’s nothing magic about the name.

Q: Why does dataclass __post_init__ run even when I provide init=False?

It doesn’t. If you disable the generated __init__ with init=False, you must manually call self.__post_init__() from your custom __init__ if you want it to run. The dataclass decorator only wires up the call when it generates __init__ for you.

Q: Can __new__ return None?

Yes, but you’ll get a None object instead of an instance of your class, which is almost certainly a bug. __new__ should always return super().__new__(cls) (or a cached instance, or an instance of a different class in rare cases). If you want to prevent instantiation, raise an exception instead of returning None.

What I Still Don’t Fully Understand

I’m not entirely sure why Python doesn’t let you override __new__ and __init__ simultaneously in a dataclass without weird hacks. If you set init=False to provide your own __init__, __post_init__ stops being called automatically. If you leave init=True, your __init__ gets overridden. The workaround is to use __post_init__ for everything, but that feels wrong when you have complex constructor logic that doesn’t fit the “assign fields then post-process” model.

Maybe the answer is “don’t use dataclasses for complex constructors,” but I’ve seen codebases where they started simple and grew into this problem.

Another thing: I’ve never needed to override __new__ for anything beyond toy examples and one singleton pattern that I later refactored into a module-level instance. The official docs suggest using it for subclassing built-in types, but I haven’t run into a real-world case where I had to do that. If you’ve built something that genuinely required __new__, I’d be curious to hear about it.

Debugging these hooks late at night? Grab some Dark Chocolate Espresso Beans — the caffeine-to-sanity ratio is unbeatable when you’re tracing why your singleton isn’t.

For now, my default is dataclasses with __post_init__ for data-heavy code, regular __init__ for everything else, and __new__ only when the type system forces my hand. That covers 99% of cases. The 1% where you need all three is where the fun begins.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 1,634 | TOTAL 112,635