Default Values for Instance Variables in Python: Complete Class Initialization and Attribute Management Guide

Default values for instance variables in python

I still remember the exact moment this bit me. I had a class with a tags argument that defaulted to [], everything worked in my first test, and then two objects later my “empty” list somehow had three items in it that belonged to a completely different object. That bug sent me down a rabbit hole into how Python actually handles default values, and it fundamentally changed how I initialize classes. This guide covers everything I learned, from the basics of __init__ defaults to the mutable default trap and the more advanced patterns Python gives you to avoid it.

The Basics: Setting Defaults in __init__

The most common way to give an instance variable a default value is through the constructor:

class User:
    def __init__(self, name, role="member"):
        self.name = name
        self.role = role

u1 = User("Amina")
u2 = User("Bilal", role="admin")

print(u1.role)  # member
print(u2.role)  # admin

This is straightforward for immutable defaults — strings, numbers, tuples, None, booleans. The default is evaluated once when the function is defined, and each call that doesn’t override the parameter gets that same value. Because strings and numbers are immutable, “sharing” the same object is harmless; nobody can mutate it out from under another instance.

The Mutable Default Trap

Here’s the bug that got me, reproduced on purpose:

class ShoppingCart:
    def __init__(self, items=[]):
        self.items = items

cart1 = ShoppingCart()
cart1.items.append("apple")

cart2 = ShoppingCart()
print(cart2.items)  # ['apple']  <-- surprise!

Default argument values in Python are evaluated once, at function definition time, not once per call. That empty list [] is created a single time when the class body executes, and every call to ShoppingCart() that doesn’t pass items reuses the exact same list object. Mutating it through one instance mutates it for all of them. This applies to any mutable default: lists, dicts, sets, and any custom mutable object.

The Fix: None as a Sentinel

The standard, Pythonic fix is to default to None and create the mutable object fresh inside the function body:

class ShoppingCart:
    def __init__(self, items=None):
        self.items = items if items is not None else []

cart1 = ShoppingCart()
cart1.items.append("apple")

cart2 = ShoppingCart()
print(cart2.items)  # []

Now __init__ runs its body on every call, and [] is a brand-new list object each time items isn’t supplied.

Dataclasses and field(default_factory=...)

If you’re using dataclasses (available since Python 3.7), attempting a mutable default raises an error immediately instead of silently causing a bug — which I genuinely appreciate:

from dataclasses import dataclass, field

@dataclass
class ShoppingCart:
    items: list = field(default_factory=list)

cart1 = ShoppingCart()
cart1.items.append("apple")

cart2 = ShoppingCart()
print(cart2.items)  # []

Trying items: list = [] directly in a dataclass raises ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory. This is one of the clearest ergonomic wins dataclasses give you over hand-written __init__ methods — the trap becomes structurally impossible instead of something you have to remember.

default_factory accepts any zero-argument callable, so you can use dict, set, or even your own factory function:

from dataclasses import dataclass, field
import uuid

@dataclass
class Record:
    id: str = field(default_factory=lambda: str(uuid.uuid4()))
    metadata: dict = field(default_factory=dict)

Class Attributes as “Shared” Defaults — On Purpose

Sometimes you actually want shared mutable state, and the class-attribute default is the right tool, not a bug:

class Counter:
    total_created = 0  # class attribute, shared

    def __init__(self):
        Counter.total_created += 1
        self.id = Counter.total_created

Counter()
Counter()
c3 = Counter()
print(c3.id)                 # 3
print(Counter.total_created) # 3

This is a legitimate pattern for counters, registries, or caches meant to be shared across all instances. The key distinction is intent: shared state on the class is fine when you mean it to be shared; the mutable-default bug happens when you don’t.

Default Values with __slots__

If your class uses __slots__ to save memory (by skipping the per-instance __dict__), you can’t assign a class-level default directly to a slot name — __slots__ reserves the name as a descriptor, so a plain assignment like x = 0 alongside __slots__ = ('x',) will fail because the descriptor and the class attribute collide. Defaults still belong in __init__:

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

p = Point()
print(p.x, p.y)  # 0 0

Using **kwargs for Flexible Defaults

For classes with many optional attributes, a common professional pattern is combining explicit defaults with **kwargs overrides, often paired with dict.setdefault or dict.get:

class Config:
    DEFAULTS = {"timeout": 30, "retries": 3, "verbose": False}

    def __init__(self, **overrides):
        settings = {**self.DEFAULTS, **overrides}
        for key, value in settings.items():
            setattr(self, key, value)

c = Config(retries=5)
print(c.timeout, c.retries, c.verbose)  # 30 5 False

This is a workflow I use a lot for configuration-heavy classes — API clients, parsers, anything with many optional knobs — because adding a new default doesn’t require touching every call site.

Performance Notes

Evaluating defaults once at function-definition time is actually a performance feature, not just a quirk — it avoids re-evaluating (potentially expensive) default expressions on every call. The cost of the mutable-default trap is a correctness issue, not a performance one; the fix (None sentinel or default_factory) trades a tiny bit of per-call work (a conditional or a factory call) for correctness, which is almost always the right trade.

Common Mistakes

  • Using a mutable literal ([], {}, set()) directly as a default parameter value. Always covered above — use None plus a conditional, or default_factory in dataclasses.
  • Confusing class attributes with instance defaults. self.x created in __init__ is per-instance; a bare x = 5 in the class body is shared until an instance attribute of the same name shadows it.
  • Mutating a class attribute through an instance and expecting it to stay per-instance. self.total_created += 1 on a plain int actually creates a new instance attribute rather than mutating the class attribute (integers are immutable), which is subtly different from the list case — worth testing if you rely on this behavior.
  • Forgetting dataclasses require default_factory for mutables. This raises at class-definition time, so it’s caught early, but only if you’re using dataclasses.

FAQs

Q: Does the None-sentinel pattern have any downsides? The main one is that None can no longer be a legitimate value for that parameter without extra handling. If callers genuinely need to pass None as a meaningful value, you may need a separate sentinel object instead of None.

Q: Are tuples safe as default values? Yes — tuples are immutable, so def __init__(self, coords=(0, 0)) is perfectly safe; there’s nothing to mutate.

Q: Does this issue affect function defaults generally, or just __init__? It affects any function or method with a mutable default argument, not just constructors — __init__ is just where it bites people most often because it’s tied to object state.

Summary

Default values for instance variables look simple but hide one of Python’s most well-known gotchas: mutable defaults are created once and shared across every call that doesn’t override them. The fix is consistent everywhere — default to None and build the mutable object inside the function body, or, in dataclasses, use field(default_factory=...). Understanding why this happens (defaults are evaluated at definition time, not call time) turns this from a mysterious bug into a predictable rule you can design around.

References

Total
0
Shares

Leave a Reply

Previous Post
Multiple Inheritance in python

Multiple Inheritance in Python: Complete Method Resolution Order and Super() Implementation Guide

Next Post
Descriptors and Dotted Lookups in python

Descriptors and Dotted Lookups in Python: Complete Attribute Access and Property Management Guide

Related Posts