Skip to content

Classes, Objects & State

What this lesson gives you

A class is a blueprint; an object is an instance. Encapsulating state and behavior together is the core idea of OOP.

Estimated time: 22 min read · Part: Object-Oriented Python

Learning objectives

  • Understand the full anatomy of a Python class: instance attributes, class attributes, methods
  • Distinguish @classmethod, @staticmethod, and instance methods — when to use each
  • Use @property to implement controlled attribute access with clean syntax
  • Avoid the class-attribute vs instance-attribute mutation trap
  • Model real-world domain objects with validation, invariants, and computed properties

A class bundles data (attributes) and behavior (methods) into a reusable blueprint. Each object is an independent instance of that class — with its own state but sharing the class's method definitions. This encapsulation — keeping related data and behavior together, and controlling how the outside world accesses them — is the core idea that makes large codebases navigable.

Python's OOP is deliberately flexible. Privacy is by convention rather than enforcement; methods are just functions with self as the first argument; everything, including classes themselves, is an object. Understanding these mechanics — rather than treating OOP as a set of rules imported from Java — is what distinguishes idiomatic Python OOP from verbose, un-Pythonic code.

Class Anatomy: Attributes and Methods

Python Class Structure
──────────────────────────────────────────────────────────────
class BankAccount:                    ← class definition
    bank_name = "PyBank"              ← class attribute: SHARED by all instances

    def __init__(self, owner, bal):   ← initializer (not "constructor")
        self.owner    = owner         ← instance attribute: per-object
        self._balance = bal           ← _ prefix = "internal by convention"

    def deposit(self, amount):        ← instance method: first arg = self
        ...

    @classmethod                      ← class method: first arg = cls
    def from_csv(cls, row):           ← alternative constructor pattern
        ...

    @staticmethod                     ← static method: no self or cls
    def validate_amount(amount):      ← utility, logically grouped here
        ...

    @property                         ← computed attribute
    def balance(self):                ← obj.balance reads like an attribute
        ...

Object in memory:
  account = BankAccount("Ada", 100)
  ┌──────────────────────────────────────┐
  │ account.__dict__                     │
  │  {"owner": "Ada", "_balance": 100}   │  ← only INSTANCE attributes stored here
  │                                      │
  │ account.__class__  →  BankAccount    │  ← pointer to the class
  └──────────────────────────────────────┘
  Method lookup: account.deposit → BankAccount.deposit (found in class, not instance)
bank_account.py
from dataclasses import dataclass
from datetime import datetime

class BankAccount:
    bank_name    = "PyBank"              # class attribute: shared by all instances
    _interest    = 0.025                 # class attribute: convention = internal

    def __init__(self, owner: str, initial_balance: float = 0.0):
        self.owner     = owner           # instance attribute
        self._balance  = initial_balance # _ = "internal, not public API"
        self._history: list[dict] = []

    # ── Instance methods ──────────────────────────────────────────────────────
    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError(f"Deposit amount must be positive, got {amount}")
        self._balance += amount
        self._history.append({"type": "deposit", "amount": amount,
                               "ts": datetime.utcnow().isoformat()})

    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self._balance:
            raise ValueError(f"Insufficient funds: {self._balance:.2f} < {amount:.2f}")
        self._balance -= amount
        self._history.append({"type": "withdraw", "amount": amount,
                               "ts": datetime.utcnow().isoformat()})

    # ── Property: controlled read access ─────────────────────────────────────
    @property
    def balance(self) -> float:
        return round(self._balance, 2)

    @property
    def transaction_count(self) -> int:
        return len(self._history)

    # ── Class method: alternative constructor ────────────────────────────────
    @classmethod
    def from_dict(cls, data: dict) -> "BankAccount":
        """Create an account from a serialized dict (e.g., from a database row)."""
        account = cls(data["owner"], data.get("balance", 0.0))
        return account

    # ── Static method: utility with logical coupling to this class ────────────
    @staticmethod
    def validate_amount(amount) -> bool:
        """Returns True if amount is a valid positive number."""
        return isinstance(amount, (int, float)) and amount > 0

    def __repr__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self.balance})"

# Usage
acc = BankAccount("Ada", 500.0)
acc.deposit(250)
acc.withdraw(100)
print(acc.balance)         # 650.0
print(acc)                 # BankAccount(owner='Ada', balance=650.0)

# Alternative constructor
acc2 = BankAccount.from_dict({"owner": "Bob", "balance": 200.0})

# Class attribute access
print(BankAccount.bank_name)    # "PyBank"
print(acc.bank_name)            # "PyBank"  — instances inherit class attrs

650.0 BankAccount(owner='Ada', balance=650.0)

Class attributes vs instance attributes: the mutation trap

A class attribute shared by all instances becomes a problem when it's mutable. If you write MyClass.shared_list = [] and one instance appends to it, all instances see the change — it's the same object. When you assign self.shared_list = [...], you create a new instance attribute that shadows the class attribute for that instance only. Keep per-object state in __init__ and only use class attributes for constants or shared configuration that should genuinely be the same for every instance.

class_vs_instance.py
# Class attribute mutation trap
class Config:
    flags = []   # SHARED — dangerous if you append to it

c1 = Config()
c2 = Config()
c1.flags.append("debug")    # mutates the SHARED class attribute
c2.flags                    # ["debug"]  — c2 is affected!

# CORRECT: create per-instance in __init__
class Config:
    def __init__(self):
        self.flags = []      # each instance gets its own list

c1 = Config(); c2 = Config()
c1.flags.append("debug")
c2.flags                    # []  — unaffected

# Reading vs assigning class attributes through instances
class Counter:
    count = 0               # class attribute

c = Counter()
Counter.count += 1          # modifies the class attribute — all see it
c.count                     # 1

c.count = 99                # creates an INSTANCE attribute, shadowing the class one
Counter.count               # 1  — class attribute unchanged
c.count                     # 99 — instance attribute hides it

Properties: Controlled Attribute Access

Python has no built-in private access modifier. The convention is: single leading underscore (_name) means "internal implementation detail, don't rely on this from outside." The @property decorator lets you expose a read-only (or read-write) interface that looks like a plain attribute but runs code — validation, computation, logging — transparently.

The key advantage: you can start with a plain public attribute and later add a property without changing any calling code. Callers always write obj.balance, whether that's a direct attribute or a property method. This is the Pythonic alternative to Java-style getBalance()/setBalance() methods.

property_patterns.py
class Temperature:
    def __init__(self, celsius: float):
        self.celsius = celsius   # triggers the setter below if defined

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError(f"Temperature below absolute zero: {value}")
        self._celsius = float(value)

    @celsius.deleter
    def celsius(self) -> None:
        raise AttributeError("Temperature cannot be deleted")

    @property
    def fahrenheit(self) -> float:
        """Read-only computed property — no setter."""
        return self._celsius * 9/5 + 32

    @property
    def kelvin(self) -> float:
        return self._celsius + 273.15

t = Temperature(100)
t.fahrenheit      # 212.0   — computed, no stored state
t.kelvin          # 373.15

t.celsius = -10   # triggers setter, validates
t.celsius = -300  # ValueError: Temperature below absolute zero

# Property as a refactor safety net:
# Start with plain attribute: self.radius = r
# Later add validation: @property + setter
# ALL callers that wrote obj.radius += 1 continue to work unchanged

Properties as bank tellers

A plain attribute is a self-service ATM — you reach in and change the balance directly, no checks. A @property with a setter is a bank teller: you make a request, the teller validates it (positive amount? sufficient funds?), executes it, and records the transaction. Callers still use the same natural syntax (account.balance = x), but the class controls what actually happens. This is encapsulation in practice: the API stays stable while the implementation can verify invariants.

classmethod vs staticmethod vs Instance Method

Decorator First argument Accesses instance? Accesses class? Common use
(none) self — the instance Yes Via self.__class__ Normal behavior that works on the object's state
@classmethod cls — the class No Yes (directly) Alternative constructors, factory methods
@staticmethod (none) No No Utility functions logically grouped with the class
method_types.py
import json
from datetime import date

class Report:
    default_format = "pdf"

    def __init__(self, title: str, data: list, fmt: str = None):
        self.title  = title
        self.data   = data
        self.format = fmt or self.__class__.default_format

    # Instance method: works on self
    def render(self) -> str:
        return f"[{self.format.upper()}] {self.title}: {len(self.data)} rows"

    # @classmethod: alternative constructors — the most common use case
    @classmethod
    def from_json(cls, json_str: str) -> "Report":
        """Deserialize from JSON — uses cls so subclasses work correctly."""
        payload = json.loads(json_str)
        return cls(payload["title"], payload["data"],
                   payload.get("format", cls.default_format))

    @classmethod
    def daily_template(cls) -> "Report":
        """Factory for today's daily report template."""
        return cls(f"Daily Report {date.today()}", [])

    # @staticmethod: pure utility with no class/instance access needed
    @staticmethod
    def sanitize_title(title: str) -> str:
        """Remove special characters from a report title."""
        return "".join(c for c in title if c.isalnum() or c in " _-")

# Usage
r1 = Report("Sales Q2", [1, 2, 3])
r2 = Report.from_json('{"title": "Marketing", "data": [], "format": "html"}')
r3 = Report.daily_template()
safe = Report.sanitize_title("Q2 Report! (Final)")   # "Q2 Report Final"

Python has no real 'private' — it's convention

Single underscore _name: "internal — don't use this from outside the class." This is a social contract, not enforced. Double underscore __name (name mangling): Python rewrites it to _ClassName__name at compile time, preventing accidental subclass clashes. It's still accessible; it's not truly private. Don't use double underscores as a privacy mechanism — use them only to avoid accidental name collisions in class hierarchies. The Pythonic approach is: trust your users, document your API, and use _name to signal "this is implementation detail."

WHY Why does Python use self explicitly? In Python, methods are just regular functions stored in the class — there's no special "method" syntax. When you call obj.method(arg), Python translates it to Class.method(obj, arg). The explicit self makes this transparent: there's no magic receiver, just a regular argument. Guido van Rossum has said this is intentional — explicit is better than implicit, and the mechanism is understandable without special knowledge.

HOW How does attribute lookup work? Python searches for an attribute in this order: (1) the instance's __dict__, (2) the class's __dict__, (3) base classes in MRO order. This is why instance attributes shadow class attributes: the instance dict is searched first. Properties and descriptors intercept the lookup before instance dicts are checked, which is how they work at all.

WHERE Where does @classmethod matter most? Alternative constructors. When you have a class that can be built from a CSV row, a database record, a JSON string, or an environment variable, each deserves a named constructor: Config.from_env(), Config.from_file(path), Config.from_dict(d). Using cls instead of hardcoding the class name means subclasses get the right type back — a critical detail when building class hierarchies.

Knowledge check

InterviewWhat does @property let you do, and why is it preferred over get_x()/set_x() methods in Python?

  • It makes the attribute truly private and inaccessible from outside.
  • It lets a method be called with attribute syntax (obj.x), so you can add validation or computation without changing calling code — preserving the clean attribute interface.
  • It saves the attribute automatically to a database.
  • It makes the class faster because it avoids method call overhead.
Answer

It lets a method be called with attribute syntax (obj.x), so you can add validation or computation without changing calling code — preserving the clean attribute interface.

@property turns a method into a managed attribute: callers write obj.x , but behind it you control reads (and with a setter, writes) — adding validation, computation, or logging. The benefit is backward compatibility: you can start with a plain attribute and later promote it to a property without changing a single line of calling code. Java-style getX() methods require callers to change from obj.x to obj.getX() .

Knowledge check

GotchaClass Team has members = [] as a class attribute. Two instances each call instance.members.append(name). What happens?

  • Each instance has its own independent list.
  • Both instances share the same list — appending to it from one instance changes what the other sees, because they're both referencing the same class attribute object.
  • A TypeError is raised because class attributes are read-only.
  • Python creates a copy of the class attribute for each instance on first access.
Answer

Both instances share the same list — appending to it from one instance changes what the other sees, because they're both referencing the same class attribute object.

A class attribute is a single object shared by all instances. Calling instance.members.append(x) mutates the list object in-place — it doesn't create a new instance attribute. All instances still point to the same list, now with an extra element. Fix: create the list in init so each instance gets its own: self.members = [] .

Knowledge check

InterviewWhen would you choose @classmethod over @staticmethod?

  • Use @classmethod for all utility functions; @staticmethod is never needed.
  • Use @classmethod when the method needs to create instances (factory/alternative constructor) or access class-level state via cls; use @staticmethod for pure utilities that have no need for class or instance access.
  • They are interchangeable; it's purely stylistic.
  • Use @staticmethod for subclasses and @classmethod for base classes.
Answer

Use @classmethod when the method needs to create instances (factory/alternative constructor) or access class-level state via cls; use @staticmethod for pure utilities that have no need for class or instance access.

The key distinction is cls . A @classmethod receives the class (or subclass, when called on a subclass) as its first argument — essential for alternative constructors that must return the correct type in a hierarchy. A @staticmethod gets no implicit argument; it's just a function scoped inside the class for organizational clarity. If you need to create an instance or access class-level state, use classmethod. If not, staticmethod is cleaner.

Exercise: Validated Domain Model

Implement a Product class for an e-commerce system. It should have: sku (string, uppercase, no spaces), name (string), _price (float, must be > 0), exposed via a property with a setter that validates. Add: a @classmethod from_dict(cls, d) alternative constructor, a @staticmethod is_valid_sku(s) validator, a discounted_price(pct) method, and a useful __repr__.

Show solution
import re

class Product:
    _SKU_PATTERN = re.compile(r"^[A-Z0-9\-]{3,20}$")

    def __init__(self, sku: str, name: str, price: float):
        if not self.is_valid_sku(sku):
            raise ValueError(f"Invalid SKU {sku!r}: must be 3-20 uppercase alphanumeric/dash chars")
        if not name or not name.strip():
            raise ValueError("Product name cannot be empty")
        self.sku    = sku.upper()
        self.name   = name.strip()
        self.price  = price                  # triggers the setter

    @property
    def price(self) -> float:
        return self._price

    @price.setter
    def price(self, value: float) -> None:
        if not isinstance(value, (int, float)) or value <= 0:
            raise ValueError(f"Price must be a positive number, got {value!r}")
        self._price = round(float(value), 2)

    def discounted_price(self, pct: float) -> float:
        """Return price after applying discount percentage (0-100)."""
        if not 0 <= pct <= 100:
            raise ValueError(f"Discount must be 0-100%, got {pct}")
        return round(self._price * (1 - pct / 100), 2)

    @classmethod
    def from_dict(cls, data: dict) -> "Product":
        return cls(
            sku=data["sku"],
            name=data["name"],
            price=data["price"],
        )

    @staticmethod
    def is_valid_sku(s: str) -> bool:
        return bool(Product._SKU_PATTERN.match(s)) if isinstance(s, str) else False

    def __repr__(self) -> str:
        return f"Product(sku={self.sku!r}, name={self.name!r}, price={self.price})"

# Tests
p = Product("PEN-001", "Fountain Pen", 19.99)
print(p)                                   # Product(sku='PEN-001', name='Fountain Pen', price=19.99)
print(p.discounted_price(20))              # 15.99
p.price = 24.99                            # setter validates
p2 = Product.from_dict({"sku": "PAD-002", "name": "Notebook", "price": 8.50})
print(Product.is_valid_sku("inv alid"))    # False
try:
    Product("bad sku!", "test", 1.0)
except ValueError as e:
    print(e)

Key takeaways

  • Class attributes are shared across all instances; instance attributes (in __init__) are per-object.
  • @property exposes controlled attribute access with clean obj.x syntax — add validation without breaking callers.
  • @classmethod receives cls: use for factory/alternative constructors. @staticmethod: pure utility with no class/instance access.
  • Python privacy is by convention (_name), not enforcement — no truly private attributes.
  • Always implement __repr__ — it makes debugging far easier in logs and the REPL.