Back to Blog
Python

Python Instance Attributes: Storage, Lookup, and Pitfalls

python instance attributes: Understand how Python stores instance attributes, how lookup order works, and where class attributes, properties, and slots change behavior.

instance attributespython classesattribute lookuppython slotsproperties
Diagram showing a Python object's instance attributes mapping to its __dict__ while class attributes remain shared.

Python instance attributes are the per-object values that distinguish one instance from another. When you create two objects from the same class, each carries its own copy of these attributes, while the class itself remains shared. Understanding how Python stores and resolves instance attributes matters for debugging, for designing clean class interfaces, and for controlling memory use in long-running services.

How Instance Attributes Are Stored at Runtime

Every Python object carries a namespace for its instance attributes. By default, that namespace is a plain dictionary stored in the object's __dict__ attribute. When you assign self.name = "ada" inside a method, Python adds the key name to that instance's __dict__.

class User: def __init__(self, name): self.name = name u = User("ada") print(u.__dict__) # {'name': 'ada'}

The dictionary is what makes Python instance attributes dynamic. You can add, remove, or replace attributes at any point, even outside the class definition:

u.role = "admin" del u.name

This flexibility is useful for scripting and for frameworks that need to attach metadata to objects, but it also means every attribute access goes through a dictionary lookup. For most applications that cost is negligible, but it becomes visible in tight loops that touch the same attribute millions of times.

Defining Instance Attributes in __init__

The conventional place to create instance attributes is the __init__ method. Doing so makes the object's shape predictable: anyone reading the class can see which attributes exist after construction.

class Order: def __init__(self, order_id, total): self.order_id = order_id self.total = total

Setting attributes outside __init__ works, but it makes the object's state harder to reason about. A reader cannot tell from the class body alone which attributes an instance will have. If the attribute is only set in a rarely used method, code that accesses it earlier will raise AttributeError.

A related risk is typo-based bugs. Assigning self.orderid in one method and reading self.order_id in another silently creates two separate attributes. Tools like type checkers and linters can catch some of these cases, but they only work if the attributes are declared in a predictable place.

Instance Attributes vs. Class Attributes

A class attribute lives on the class object and is shared by every instance. An instance attribute lives in the instance's own __dict__ and shadows the class attribute of the same name.

class Counter: count = 0 # class attribute c1 = Counter() c2 = Counter() c1.count = 5 # instance attribute, shadows the class attribute print(c1.count) # 5 print(c2.count) # 0 print(Counter.count) # 0

The assignment c1.count = 5 does not modify the class attribute. It creates a new entry in c1.__dict__. If the intent is to mutate shared state, this is a common source of confusion. The rule to remember: reading an attribute checks the instance first, then the class; writing an attribute always writes to the instance unless the class defines a data descriptor such as a property.

Attribute Lookup Order and Descriptors

When you access obj.attr, Python follows a defined order. First it checks the type of the object for a data descriptor, which includes properties and __slots__ descriptors. Then it checks the instance __dict__. Then it checks the type for a non-data descriptor or a plain class attribute. Finally, it falls back to __getattr__ if defined.

class Temperature: def __init__(self, celsius): self._celsius = celsius @property def fahrenheit(self): return self._celsius * 9 / 5 + 32

Here fahrenheit is a data descriptor on the class. Even if you assign obj.fahrenheit = 100, the property's setter logic runs instead of writing to the instance dictionary. If the property has no setter, the assignment raises AttributeError. This is why properties are the right tool when attribute access needs validation or derived values.

Dynamic Attribute Access with getattr, setattr, and hasattr

Python provides built-in functions for working with instance attributes when the attribute name is only known at runtime.

def apply_config(obj, key, value): if hasattr(obj, key): setattr(obj, key, value) else: raise ValueError(f"{key} is not a valid attribute")

getattr also accepts a default value, which avoids the exception path:

role = getattr(user, "role", "guest")

These functions are common in serialization code, configuration loaders, and test utilities. They are also slower than direct attribute access because the attribute name must be resolved as a string. In performance-sensitive paths, prefer direct access or cache the result.

Memory Considerations and __slots__

Because each instance normally carries a __dict__, a class with many instances can consume significant memory. The dictionary itself has overhead beyond the stored values. For classes that create millions of short-lived objects, this matters.

class Point: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y

Declaring __slots__ prevents the creation of __dict__ and instead allocates a fixed set of descriptors. The result is a smaller memory footprint per instance and faster attribute access. The tradeoff is that you can no longer add arbitrary attributes, and the class cannot be used with some features that rely on __dict__, such as certain pickling paths or tools that introspect instance state.

__slots__ is not a default choice. Use it when you have measured memory pressure from many instances, or when you deliberately want to restrict the attribute surface of a class.

Common Pitfalls: Mutable Defaults and Shared State

A classic mistake is using a mutable class attribute as a default value. Because the list is shared, every instance that mutates it affects all others.

class TaskList: tasks = [] # shared def add(self, task): self.tasks.append(task) a = TaskList() b = TaskList() a.add("deploy") print(b.tasks) # ['deploy']

The fix is to create the mutable value inside __init__ as an instance attribute:

class TaskList: def __init__(self): self.tasks = []

This is the most common instance attribute bug in Python codebases, and it follows directly from the distinction between class-level and instance-level storage.

Properties for Controlled Instance Attributes

When an instance attribute needs validation, transformation, or lazy computation, a property keeps the logic at the attribute boundary rather than scattering checks across callers.

class Account: def __init__(self, balance): self._balance = balance @property def balance(self): return self._balance @balance.setter def balance(self, value): if value < 0: raise ValueError("balance cannot be negative") self._balance = value

The stored value lives in _balance, while the public interface is balance. This separation lets you change the internal representation later without breaking callers. It also centralizes invariants, which is easier to maintain than duplicating validation in every method that updates the balance.

python instance attributes: Practical Usage and Code Example | RYUSLOG DEV