Python Class: Definition, Methods, and Practical Use
python class: Learn how to define and use Python classes: attributes, methods, inheritance, encapsulation, and when a dataclass or plain function is the better choice.
A Python class is a template for creating objects that bundle data with the functions that operate on that data. The minimal definition looks like this:
class Order: def __init__(self, order_id: str, amount: float): self.order_id = order_id self.amount = amount def total_with_tax(self, rate: float) -> float: return self.amount * (1 + rate)
When you call Order("A-1", 99.0), Python allocates a new instance, runs __init__ with that instance bound to self, and returns the object. The self parameter is how methods access the specific instance they were called on; it is never passed explicitly at the call site. Class syntax in Python is therefore a thin layer over two runtime operations: instance creation and attribute binding.
Class Attributes Versus Instance Attributes
An attribute assigned inside __init__ belongs to the instance. An attribute defined directly in the class body belongs to the class and is shared by every instance.
class Order: tax_rate = 0.2 def __init__(self, order_id: str, amount: float): self.order_id = order_id self.amount = amount
Reading self.tax_rate works because attribute lookup checks the instance first, then the class. Writing self.tax_rate = 0.3 creates a new instance attribute that shadows the class attribute for that one object. If the goal is to change the rate for all orders, assign to the class attribute directly: Order.tax_rate = 0.25.
Use class attributes for values that are constant across instances, such as configuration defaults. Use instance attributes for data that varies per object. Mutable class attributes are a common source of bugs: a list defined in the class body is shared, so appending to it through one instance affects every instance.
Instance, Class, and Static Methods
class Order: currency = "USD" def __init__(self, order_id: str, amount: float): self.order_id = order_id self.amount = amount def total_with_tax(self, rate: float) -> float: return self.amount * (1 + rate) @classmethod def from_legacy_row(cls, row: dict) -> "Order": return cls(row["id"], float(row["amount"])) @staticmethod def is_valid_amount(value: float) -> bool: return value > 0
An instance method receives the instance as its first argument. A class method receives the class instead, which lets subclasses override the factory and still construct the correct subclass. A static method receives neither; it is a function that lives in the class namespace because it is conceptually related to the class.
The class method is the right tool for alternate constructors. The static method is right when the function does not need instance or class state.
What __init__ Actually Does
__init__ is not a constructor in the C++ or Java sense. The instance already exists when __init__ runs; __init__ only initializes its attributes. The actual allocation happens in __new__, which is rarely overridden. Understanding this distinction matters when you see code that returns early from __init__ or relies on side effects: the object still exists even if __init__ raises, and the exception propagates to the caller.
Validation in __init__ keeps invalid objects from being created:
class Order: def __init__(self, order_id: str, amount: float): if amount <= 0: raise ValueError("amount must be positive") self.order_id = order_id self.amount = amount
This keeps validation logic in one place and prevents the same checks from being duplicated across call sites. It does not protect against later mutation; a caller can still assign order.amount = -5 after construction. For immutable data, use dataclasses with frozen=True or a named tuple.
Inheritance and Method Resolution
class DiscountedOrder(Order): def __init__(self, order_id: str, amount: float, discount: float): super().__init__(order_id, amount) self.discount = discount def total_with_tax(self, rate: float) -> float: return super().total_with_tax(rate) * (1 - self.discount)
super() delegates to the next class in the method resolution order (MRO). For single inheritance that is the parent class; for multiple inheritance it follows the C3 linearization, which Python computes when the class is defined. The MRO is visible through ClassName.__mro__ and is worth checking when a class participates in multiple inheritance and method calls behave unexpectedly.
Overriding a method changes behavior for all callers of the subclass. If the override must preserve the parent's contract, call super() with the same arguments the parent expects. Changing the signature of an overridden method is legal in Python but breaks callers that use the parent type.
Encapsulation Through Naming Conventions
Python has no private or protected keywords. A single leading underscore, as in self._amount, is a convention that means "internal, do not touch." A double leading underscore triggers name mangling: self.__secret becomes self._ClassName__secret at compile time. Name mangling exists to prevent accidental collisions in inheritance hierarchies, not to enforce access control.
class Order: def __init__(self, amount: float): self._amount = amount
The practical rule: use a single underscore for internal attributes and methods, and document the public surface of the class in its docstring. If a caller needs to read an attribute, expose it as a public attribute or a property rather than forcing access to a mangled name.
The Runtime Cost of Attribute Lookup
Every attribute access on an instance performs a lookup in the instance dictionary, then the class dictionary, then the parent classes. For most code this cost is negligible. When a class is instantiated millions of times or accessed in a hot loop, the per-instance dictionary adds memory and lookup overhead.
__slots__ replaces the per-instance dictionary with a fixed set of descriptors:
class Order: __slots__ = ("order_id", "amount") def __init__(self, order_id: str, amount: float): self.order_id = order_id self.amount = amount
Instances of this class no longer have a __dict__, so they use less memory and attribute access is faster. The tradeoff is that you cannot add new attributes to an instance, and the class cannot be combined with other classes that use __slots__ without careful layout planning. Measure before adopting __slots__; it is a memory optimization, not a readability feature.
When a Plain Function or Dataclass Is Better
A class is not always the right container for data. A function that operates on a few values is clearer without a class wrapper. For data-only objects, dataclasses remove the boilerplate of __init__, __repr__, and equality:
from dataclasses import dataclass @dataclass(frozen=True) class Order: order_id: str amount: float
The frozen=True variant makes instances immutable, which is the default expectation for value objects in many codebases. Use a class when the object has behavior that depends on its internal state and that behavior is reused. Use a dataclass when the object is primarily a collection of fields. Use a plain function when the operation has no state to carry.
The decision is not about syntax but about where the state lives. If the state is passed in and returned, a function is simpler. If the state must persist across calls and be shared through a single object, a class is the appropriate structure.