You’ve probably hit the moment where two objects “mysteriously” share state. In my experience, that moment usually traces back to class members—variables or methods that live on the class rather than the instance. The behavior is subtle: read access looks the same from both places, but writes go somewhere else, and method binding changes what gets passed as the first argument. Once you see the rules, though, you can predict the outcome every time and design classes that are easier to maintain.
I’ll walk you through instance members vs class members, how attribute lookup really works, and where class attributes shine (and where they cause trouble). You’ll get runnable examples, patterns I use in production, and the edge cases that bite even experienced developers—like mutable class attributes and shadowing. By the end, you’ll know exactly when to put data on the instance, when to keep it on the class, and how to make intent obvious to the next person reading your code (which might be you in six months).
Mental Model: Two Layers of State
I like to picture a class as a blueprint plus a shared storage closet. Every instance gets its own room (instance attributes), and the class owns the closet (class attributes). You can read items from the closet through any room door, but if you put something in your room with the same name, you no longer reach into the closet—you grab your own copy.
In code, attribute lookup follows this rough order:
1) Instance dictionary (obj.dict)
2) Class dictionary (Class.dict)
3) Base classes (MRO)
That ordering explains why reading a class attribute from an instance works, and why assigning on the instance creates a new attribute that shadows the class attribute.
Here’s a minimal demo with a real-world name instead of foo:
class FeatureFlag:
# class member shared by all instances
rollout_percentage = 10
def init(self, name: str):
self.name = name # instance member
flaga = FeatureFlag("newcheckout")
flagb = FeatureFlag("newsearch")
print(flaga.rolloutpercentage) # 10 (read from class)
print(flagb.rolloutpercentage) # 10
flaga.rolloutpercentage = 50 # creates instance attribute
print(flaga.rolloutpercentage) # 50 (instance)
print(flagb.rolloutpercentage) # 10 (class)
print(FeatureFlag.rollout_percentage) # 10 (class)
This is the core rule that explains most surprises. When you write flaga.rolloutpercentage = 50, you are not changing the class variable; you are creating an instance variable with the same name.
Instance Members: Per-Object State and Behavior
Instance members represent state that belongs to each object: IDs, timestamps, configuration values, and any data that varies per instance. Instance methods operate on that instance state and use self to access it.
Here’s an example that mirrors the day-to-day pattern I use for data models:
from datetime import datetime, timezone
class Job:
def init(self, job_id: str, queue: str):
self.jobid = jobid
self.queue = queue
self.created_at = datetime.now(timezone.utc)
self.attempts = 0
def mark_attempt(self) -> None:
# instance method updates instance state
self.attempts += 1
job = Job("job_8431", "video-transcode")
print(job.job_id, job.queue, job.attempts)
job.mark_attempt()
print(job.attempts)
Instance members are the default choice. If you’re unsure, put it on the instance. It’s the safest option because there’s no unintended sharing across objects.
Common Mistake: Mutable Defaults on the Class
I see this bug a lot, especially with lists and dictionaries:
class Report:
# BAD: shared list across all instances
tags = []
def init(self, title: str):
self.title = title
report_a = Report("Monthly Revenue")
report_b = Report("Quarterly Retention")
report_a.tags.append("finance")
print(report_b.tags) # [‘finance‘]
If you need a new list per instance, create it in init:
class Report:
def init(self, title: str):
self.title = title
self.tags = [] # per-instance list
In my experience, this single fix removes 80% of “why did my data change over there?” bugs with class design.
Class Members: Shared State and Factory Behavior
Class members belong to the class itself. They’re great for constants, shared configuration, counters, and factory methods. I also use them for registries and caches, but I’m careful with mutability (more on that soon).
Class Variables
Class variables live at the top level of the class body:
class Currency:
# class members shared by all instances
symbol = "$"
decimals = 2
def init(self, code: str):
self.code = code
print(Currency.symbol)
print(Currency("USD").symbol)
Class variables are ideal for values that should be identical across all instances. If you ever see a value that should change per instance, move it into init.
Class Methods
Class methods receive cls and operate on the class instead of the instance. They’re my go-to for alternative constructors and registry patterns.
class Money:
symbol = "$"
def init(self, amount: int):
self.amount = amount
@classmethod
def from_cents(cls, cents: int) -> "Money":
# create instance using class, not hard-coded name
return cls(cents // 100)
price = Money.from_cents(2599)
print(price.amount, price.symbol)
Use class methods when you need polymorphism. If Money gets subclassed, cls ensures the subclass constructor is used without modification.
Attribute Lookup and Shadowing: Why Writes Behave Differently
Read access (obj.attr) can traverse from instance to class, but assignment (obj.attr = value) always writes to the instance. That’s why an instance can “shadow” a class attribute.
Here’s a more explicit version of the rule with dict inspection:
class DeviceConfig:
retry_limit = 3
def init(self, name: str):
self.name = name
cfg = DeviceConfig("sensor-A")
print(cfg.retry_limit) # 3, read from class
print(cfg.dict) # {‘name‘: ‘sensor-A‘}
cfg.retry_limit = 5
print(cfg.retry_limit) # 5, instance now
print(cfg.dict) # {‘name‘: ‘sensor-A‘, ‘retry_limit‘: 5}
print(DeviceConfig.retry_limit) # 3
If you want to actually update the class variable, do it on the class:
DeviceConfig.retry_limit = 10
I recommend being explicit with this. A single line can change behavior across your entire application, so prefer class-level writes in one place (like a configuration module) rather than scattered instance-level changes.
Self vs cls: When Each Is Appropriate
I think of self as the instance lens and cls as the class lens. self can reach both instance and class members (read access), while cls should be reserved for class-level state or alternate constructors.
Here’s a side-by-side pattern I use with constants and instance behavior:
class RetryPolicy:
defaultbackoffms = 250
def init(self, backoff_ms: int | None = None):
self.backoffms = backoffms or self.defaultbackoffms
@classmethod
def withslowbackoff(cls) -> "RetryPolicy":
# class-level factory
return cls(backoff_ms=1000)
policy = RetryPolicy()
print(policy.backoff_ms) # 250
print(RetryPolicy.defaultbackoffms) # 250
print(RetryPolicy.withslowbackoff().backoff_ms) # 1000
If the method’s purpose is to construct or manage class-wide state, use @classmethod. If it acts on a single object’s data, use an instance method.
When Class Members Shine (and When They Don’t)
I recommend class members in these cases:
- Constants and configuration defaults: e.g.,
maxretries,defaulttimeout_ms - Shared caches: e.g., a compiled regex or parsed schema used across all instances
- Registries: mapping names to classes or handlers
- Alternative constructors: factory methods like
fromjson,fromenv,from_file
I avoid class members for:
- Mutable state that varies per instance: lists, dicts, sets
- Values derived from instance input: IDs, timestamps, user-provided data
- Thread-local or request-scoped data: this should live on the instance or be passed explicitly
When you do use mutable class members, add a clear comment and strong tests. In a 2026 workflow, I also let static analyzers and AI assistants flag mutable class attributes, but I still prefer code that is obviously safe without external tooling.
Real-World Patterns I Use in Production
Here are patterns I see in modern codebases that use class members the right way.
Pattern 1: Registry with Class Method Hook
This is common in plugin systems and serializer frameworks:
class SerializerRegistry:
_registry: dict[str, type] = {}
@classmethod
def register(cls, name: str):
def decorator(serializer_cls: type):
cls.registry[name] = serializercls
return serializer_cls
return decorator
@classmethod
def get(cls, name: str) -> type:
return cls._registry[name]
@SerializerRegistry.register("json")
class JsonSerializer:
def dumps(self, payload: dict) -> str:
import json
return json.dumps(payload)
serializer_cls = SerializerRegistry.get("json")
print(serializer_cls().class.name)
The registry is shared across the system, so class-level state is appropriate. @classmethod keeps the registry attached to the class, not an instance.
Pattern 2: Counters for Diagnostics
Class members can track totals across all instances:
class RequestTracer:
total_requests = 0
def init(self, endpoint: str):
self.endpoint = endpoint
RequestTracer.total_requests += 1
@classmethod
def reset(cls) -> None:
cls.total_requests = 0
RequestTracer("/health")
RequestTracer("/api/v1/users")
print(RequestTracer.total_requests) # 2
This is clean for process-wide counters. If you need per-thread or per-request counters, keep the count on the instance or in a dedicated context object.
Pattern 3: Immutable Defaults with Override
This is a safe way to use class defaults while allowing instance overrides:
class HttpClient:
defaulttimeoutms = 3000
def init(self, baseurl: str, timeoutms: int | None = None):
self.baseurl = baseurl
self.timeoutms = timeoutms or self.defaulttimeoutms
client_a = HttpClient("https://api.example.com")
clientb = HttpClient("https://api.example.com", timeoutms=8000)
print(clienta.timeoutms) # 3000
print(clientb.timeoutms) # 8000
I prefer this pattern because it keeps defaults at the class level but ensures each instance gets its own value.
Edge Cases That Trip People Up
1) Class Variables in Inheritance
Subclassing can override class variables, but the base class remains unchanged:
class Vehicle:
wheels = 4
class Motorcycle(Vehicle):
wheels = 2
print(Vehicle.wheels) # 4
print(Motorcycle.wheels) # 2
If you update Vehicle.wheels, it does not automatically update Motorcycle.wheels if the subclass defined its own value. I recommend keeping shared class attributes in the base class only when you truly want them shared.
2) Class Variables with Dataclasses
Dataclasses treat class variables specially when annotated with ClassVar.
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class Metric:
unit: str
version: ClassVar[str] = "v1"
m = Metric("ms")
print(m.unit)
print(Metric.version)
Use ClassVar to make it obvious that version is not part of the instance fields. That prevents it from appearing in init or repr.
3) slots and Class Members
slots only affects instance attributes, not class attributes. So class members behave the same even when slots are used. This means you can still share class-level constants in a memory-efficient class.
4) Descriptors and @property
Descriptors live on the class but control access on the instance. That means they are class members that behave like instance members. This is powerful but subtle:
class Temperature:
def init(self, celsius: float):
self._celsius = celsius
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float) -> None:
self._celsius = (value - 32) * 5 / 9
reading = Temperature(0)
print(reading.fahrenheit) # 32.0
reading.fahrenheit = 212
print(reading._celsius) # 100.0
This pattern is class-level code that manages instance state. It’s one of the reasons class members matter even if you mostly think in instances.
Performance and Maintainability Considerations
Class members are fast to access, but the performance difference between class and instance attributes is usually negligible for most applications. In a data-heavy system, you might see tiny gains, typically in the low single-digit microseconds range per attribute access, which is rarely the bottleneck. I focus more on readability and correctness.
Where class members do help performance is memory usage. If a constant string or compiled regex is shared, you save per-instance duplication. This can be meaningful in large object graphs. For example, a web server with thousands of request objects can benefit from shared constants and cached regex patterns.
That said, shared mutable state is a source of bugs in concurrent programs. If you have async tasks or threads, a class-level cache can become a race condition unless it is protected. In those cases, I either:
- Use immutable shared data, or
- Use a dedicated cache object with clear locking, or
- Keep state per instance and accept slightly higher memory use
Traditional vs Modern Patterns
When I compare older class designs to modern patterns, the biggest change is clarity. Type hints, dataclasses, and static analysis make it easier to see intent. Here’s a quick contrast.
Modern (2026)
—
Class variables annotated with ClassVar
init boilerplate @dataclass with clear field definitions
Explicit shared state and constructor defaults
Linters and AI tooling flag risky patternsI still write plain classes often, but I like using ClassVar and type hints to make the class/instance boundary explicit.
Common Mistakes and How to Avoid Them
Here are the mistakes I see most often and how I prevent them:
1) Mutable class attribute used like instance data
– Fix: move it into init, or wrap it in classmethod access that returns a new object.
2) Accidentally shadowing a class attribute
– Symptom: changing obj.attr doesn’t change Class.attr.
– Fix: update the class explicitly with Class.attr = value when you truly want shared updates.
3) Using @staticmethod when you meant @classmethod
– Symptom: subclassing breaks alternative constructors.
– Fix: prefer @classmethod for constructors and factory patterns.
4) Storing request-scoped data on the class
– Symptom: one request’s data leaks into another in multi-threaded/async code.
– Fix: keep request data on the instance or in context managers.
5) Forgetting that class attributes participate in inheritance
– Symptom: unexpected values in subclasses or overrides that don’t behave the way you assume.
– Fix: document whether a class attribute is designed to be overridden; use ClassVar and clear naming.
6) Relying on shared cache without synchronization
– Symptom: intermittent bugs under load.
– Fix: use a dedicated cache with locks or switch to immutable shared values.
7) Misunderstanding descriptor behavior
– Symptom: properties or descriptors unexpectedly affect multiple instances.
– Fix: ensure the descriptor stores per-instance data (often via self.dict).
8) Mutating class attributes in tests without cleanup
– Symptom: tests pass individually but fail in suite.
– Fix: reset class attributes in setup/teardown or use fixtures.
Static Methods vs Class Methods (and Why It Matters)
I see a lot of confusion between @staticmethod and @classmethod. They look similar, but they solve different problems.
@staticmethod: no implicitselforcls. It’s just a function living in the class namespace.@classmethod: receivesclsand can construct or modify class-level state, including subclass behavior.
Here’s a concrete example of how subclassing breaks if you pick the wrong one:
class Parser:
def init(self, raw: str):
self.raw = raw
@staticmethod
def from_bytes(data: bytes) -> "Parser":
# WRONG for subclasses: hard-coded name
return Parser(data.decode("utf-8"))
class JsonParser(Parser):
pass
print(type(JsonParser.from_bytes(b"{}"))) # Parser, not JsonParser
Now the @classmethod version:
class Parser:
def init(self, raw: str):
self.raw = raw
@classmethod
def from_bytes(cls, data: bytes) -> "Parser":
return cls(data.decode("utf-8"))
class JsonParser(Parser):
pass
print(type(JsonParser.from_bytes(b"{}"))) # JsonParser
My rule: use @staticmethod only when the function is conceptually related to the class but does not need self or cls. Use @classmethod for constructors, registries, or anything that should respect subclassing.
Attribute Lookup: The Full Picture
The three-step lookup I listed earlier is a simplification. The real story includes descriptors and special methods. Here’s a more accurate mental model I use:
1) Check for data descriptors on the class (like properties with setters)
2) Check the instance dictionary (obj.dict)
3) Check for non-data descriptors or normal attributes in the class
4) Follow the MRO for base classes
This is why properties win over instance attributes. A property with a setter can intercept access even if a same-named value exists in dict.
Here’s a minimal example that demonstrates priority:
class Profile:
def init(self):
self.name = "Ava"
@property
def name(self) -> str:
return "(hidden)"
p = Profile()
print(p.name) # (hidden), property beats instance attribute
If that feels confusing, it’s because descriptors are powerful. The key takeaway: anything defined on the class can control access to instance data in advanced ways.
Managing Shared Caches Safely
Class-level caches are common, and they can be the right choice. The trick is to keep them immutable or thread-safe.
Example: Compiled Regex Cache
import re
class EmailValidator:
_regex = re.compile(r"^[^@]+@[^@]+\.[^@]+$")
def init(self, email: str):
self.email = email
def is_valid(self) -> bool:
return bool(self._regex.match(self.email))
The regex is immutable and safe to share. This is exactly where class members shine.
Example: Mutable Cache with Lock
import threading
class CountryLookup:
_cache: dict[str, str] = {}
_lock = threading.Lock()
@classmethod
def get_country(cls, code: str) -> str:
with cls._lock:
if code not in cls._cache:
# pretend this is an expensive API call
cls._cache[code] = f"Country({code})"
return cls._cache[code]
When I absolutely need shared mutable state, I wrap it with a lock and keep it small and well-scoped.
Practical Scenarios: How I Decide in Real Code
Here’s how I make the call in real applications:
Scenario 1: Web Client Defaults
- Default timeout and retry policy are class-level constants.
- Per-request headers and auth tokens are instance attributes.
class ApiClient:
defaulttimeoutms = 5000
def init(self, base_url: str, token: str):
self.baseurl = baseurl
self.token = token
def headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.token}"}
Scenario 2: Feature Flags with Global Overrides
- Class-level defaults for feature toggles.
- Instance-level overrides for tests or specific users.
class Flag:
default_enabled = False
def init(self, name: str, enabled: bool | None = None):
self.name = name
self.enabled = enabled if enabled is not None else self.default_enabled
Scenario 3: Metrics and Analytics
- Class-level registry of event names.
- Instance-level payload and metadata.
class Event:
_allowed = {"login", "logout", "signup"}
def init(self, name: str, payload: dict):
if name not in self._allowed:
raise ValueError("Unknown event")
self.name = name
self.payload = payload
The pattern is consistent: shared configuration lives at class level, per-request or per-user data lives on the instance.
Class Members and Type Hints: Communicating Intent
Type hints do more than help static checkers—they document your design decisions.
Here’s how I annotate class variables in larger projects:
from typing import ClassVar
class CacheConfig:
max_size: ClassVar[int] = 1024
evict_policy: ClassVar[str] = "LRU"
def init(self, namespace: str):
self.namespace = namespace
This tells anyone reading the code that max_size is class-wide and not instance-specific. It also keeps dataclasses and tooling from mistakenly treating them as fields.
Debugging Tips: How I Diagnose Class/Instance Confusion
When I’m unsure where an attribute lives, I check these three things:
1) obj.dict — shows instance attributes
2) Class.dict — shows class attributes
3) vars(obj) and vars(Class) — simple shortcuts for the above
Here’s a quick diagnostic helper I keep around:
def debug_attr(obj, name: str) -> None:
cls = obj.class
print("instance has:", name in obj.dict)
print("class has:", name in cls.dict)
print("value via obj:", getattr(obj, name))
print("value via class:", getattr(cls, name, None))
This small function tells me whether I’m looking at instance state or shared class state.
Modern Alternatives: Dataclasses, attrs, and Pydantic
While this guide focuses on core Python classes, modern tools make the class/instance boundary clearer.
Dataclasses
Dataclasses reduce boilerplate and make instance members explicit. ClassVar is key when you want a class attribute:
from dataclasses import dataclass
from typing import ClassVar
@dataclass
class User:
name: str
role: str
domain: ClassVar[str] = "internal"
attrs and Pydantic
These libraries offer similar benefits with validation and richer metadata. The same principles apply: instance fields are per-object; class attributes are shared.
My practical rule: use the tool that makes the instance/class split most obvious to your team.
Advanced Pattern: Class Properties
Sometimes you want a property-like interface at the class level. Python doesn’t have a built-in @classproperty, but you can build one. Use this sparingly; it can be confusing if overused.
class classproperty:
def init(self, fget):
self.fget = fget
def get(self, obj, owner):
return self.fget(owner)
class Config:
_env = "prod"
@classproperty
def env(cls) -> str:
return cls._env
print(Config.env)
I only use this when I need read-only computed values at the class level and want a clean API.
Testing Strategies for Class Members
Class members can bleed state across tests if you’re not careful. Here are two patterns I use:
Reset in Setup/Teardown
class TestCounter:
def setup_method(self):
RequestTracer.total_requests = 0
Isolate with Fixtures
import pytest
@pytest.fixture
def clean_registry():
SerializerRegistry._registry = {}
yield
SerializerRegistry._registry = {}
The key is to treat class members as global state for testing purposes.
Migration Guide: Turning a Buggy Class into a Clean One
When I inherit a class with messy class/instance boundaries, I follow this steps-based cleanup:
1) Inventory attributes: list all attributes on instances and on the class.
2) Classify: label each attribute as shared or per-instance.
3) Move: move per-instance mutable attributes into init.
4) Annotate: mark class attributes with ClassVar.
5) Lock or copy: for any shared mutable state, decide whether to lock or clone per instance.
Here’s a before/after example:
# BEFORE
class Session:
cache = {}
timeout = 3000
def init(self, user_id: str):
self.userid = userid
AFTER
from typing import ClassVar
class Session:
timeout: ClassVar[int] = 3000
def init(self, user_id: str):
self.userid = userid
self.cache = {}
This cleanup tends to eliminate subtle bugs immediately.
Quick Checklist I Use
If you want a fast decision tool, here’s the checklist I run through:
- Is this value identical for every instance? → Class member
- Does it depend on constructor inputs? → Instance member
- Is it mutable? → Instance member (unless you explicitly want shared state)
- Will subclasses override it? → Class member (documented and expected)
- Will this be accessed concurrently? → Avoid shared mutable class data
Summary: The Rules I Rely On
- Instance members are the safest default.
- Class members are for shared, immutable, or explicitly managed state.
- Reading can traverse from instance to class; writing on the instance always creates or updates an instance attribute.
@classmethodis the right tool for factories and class-wide operations;@staticmethodis only for related utility functions.- Descriptors and properties live on the class but control instance state.
When I follow these rules, class design stops being mysterious and becomes a predictable tool. If you want code that is easy to reason about and hard to break, put the data where it belongs: shared on the class only when it truly is shared, and per-instance everywhere else.
If you want, I can tailor examples to your specific domain (web services, data science, CLI tools, or plugins) and show how class members fit into those architectures.


