Object-oriented Python organizes a program around objects that combine data and behavior. A bank account can store a balance and provide deposit or withdrawal methods. A product can store a name and price and calculate a discounted value. A game character can hold health points and perform actions.
Python supports object-oriented programming without requiring every problem to use it. This guide explains classes, objects, attributes, methods, constructors, class attributes, properties, inheritance, composition, encapsulation, and practical design choices.
What is an object?
An object is a value with state and behavior. Python already provides many objects:
name = "Python"
numbers = [3, 1, 2]
print(name.upper())
numbers.sort()
print(numbers)The string object provides upper(), and the list object provides sort(). Their methods operate on the object’s data. The official Python classes tutorial explains the language model for classes, instances, methods, and inheritance.
Class and instance
A class defines the structure and behavior of a type. An instance is one concrete object created from that class.
class Product:
pass
book = Product()
course = Product()
print(type(book))
print(book is course) # Falsebook and course are distinct instances, even though both belong to the Product class.
Initialize objects with __init__
The __init__ method initializes instance data after an object is created:
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
book = Product("Python Handbook", 39.90)
print(book.name)
print(book.price)The first parameter is conventionally named self. It refers to the current instance. When Python executes Product("Python Handbook", 39.90), it creates an object and passes that object to __init__ as self.
Instance attributes
self.name and self.price are instance attributes. Each object has its own values:
book = Product("Python Handbook", 39.90)
course = Product("Python Fundamentals", 89.00)
print(book.name) # Python Handbook
print(course.name) # Python FundamentalsAttributes should represent state that belongs to the object. A local variable inside a method disappears after that method returns, while an instance attribute remains attached to the instance.
Instance methods
A method is a function defined inside a class:
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
def apply_discount(self, percentage: float) -> float:
if not 0 <= percentage <= 100:
raise ValueError("percentage must be between 0 and 100")
return self.price * (1 - percentage / 100)
book = Product("Python Handbook", 40.00)
print(book.apply_discount(15))Calling book.apply_discount(15) automatically supplies book as self. Validation keeps invalid state or arguments from silently producing misleading results. See the Python exception handling guide for error design.
Represent objects clearly with __repr__
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
def __repr__(self) -> str:
return f"Product(name={self.name!r}, price={self.price!r})"
book = Product("Python Handbook", 39.90)
print(book)A useful __repr__ makes debugging and logs easier. The f-string debugging guide explains !r and expression labels.
Class attributes
A class attribute is shared through the class rather than initialized separately for each object:
class Product:
currency = "USD"
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
book = Product("Python Handbook", 39.90)
course = Product("Python Fundamentals", 89.00)
print(book.currency)
print(course.currency)Class attributes work well for constants or defaults shared by all instances. Avoid placing a mutable list or dictionary there unless shared state is explicitly intended.
The mutable class attribute trap
class Cart:
items = [] # Shared by every instance: usually a bug.Both carts would refer to the same list. Create mutable instance state inside __init__:
class Cart:
def __init__(self) -> None:
self.items = []Class methods
A class method receives the class as its first argument and can provide an alternative constructor:
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
@classmethod
def from_string(cls, value: str):
name, price_text = value.rsplit(",", maxsplit=1)
return cls(name.strip(), float(price_text))
product = Product.from_string("Python Handbook, 39.90")
print(product)Using cls rather than writing Product directly supports subclasses correctly.
Static methods
A static method belongs conceptually to the class but does not need an instance or class reference:
class Product:
@staticmethod
def is_valid_price(value: float) -> bool:
return value >= 0
print(Product.is_valid_price(10))
print(Product.is_valid_price(-2))Use static methods sparingly. If a function does not strongly belong to the class, a normal module-level function may be clearer.
Encapsulation in Python
Encapsulation means keeping state and rules together behind a useful interface. Python relies more on conventions than strict private access modifiers:
nameis public;_namesignals an internal implementation detail;__nametriggers name mangling, mainly to avoid accidental conflicts in subclasses.
An underscore is not a security boundary. It communicates that callers should use the public methods or properties instead.
Validate attributes with properties
class Product:
def __init__(self, name: str, price: float) -> None:
self.name = name
self.price = price
@property
def price(self) -> float:
return self._price
@price.setter
def price(self, value: float) -> None:
if value < 0:
raise ValueError("price cannot be negative")
self._price = float(value)
book = Product("Python Handbook", 39.90)
book.price = 42
print(book.price)The caller uses normal attribute syntax while the class enforces a rule. Do not add properties automatically to every field; direct public attributes are appropriate when no additional behavior is needed.
Inheritance
Inheritance creates a specialized class based on another class:
class Employee:
def __init__(self, name: str) -> None:
self.name = name
def describe_role(self) -> str:
return "Employee"
class Developer(Employee):
def __init__(self, name: str, language: str) -> None:
super().__init__(name)
self.language = language
def describe_role(self) -> str:
return f"Developer working with {self.language}"
developer = Developer("Jordan", "Python")
print(developer.name)
print(developer.describe_role())super() calls the parent implementation. The subclass overrides describe_role() with more specific behavior.
Polymorphism
Polymorphism lets different object types respond to the same operation:
class EmailNotification:
def send(self, message: str) -> None:
print(f"Email: {message}")
class SmsNotification:
def send(self, message: str) -> None:
print(f"SMS: {message}")
def notify(channel, message: str) -> None:
channel.send(message)
notify(EmailNotification(), "Order shipped")
notify(SmsNotification(), "Order shipped")The notify() function depends on the behavior it needs, not a specific class hierarchy. This style is often called duck typing: if an object provides the expected method, it can participate.
Composition
Composition builds an object from other objects. It often models relationships more naturally than inheritance:
class Engine:
def start(self) -> str:
return "Engine started"
class Car:
def __init__(self, model: str, engine: Engine) -> None:
self.model = model
self.engine = engine
def start(self) -> str:
return f"{self.model}: {self.engine.start()}"
car = Car("City Car", Engine())
print(car.start())A car has an engine, so composition expresses the relationship clearly. Inheritance is better reserved for a genuine “is a” relationship with substitutable behavior.
Dataclasses for data-focused objects
When a class mainly stores data, the standard-library dataclasses module reduces boilerplate:
from dataclasses import dataclass
@dataclass
class Customer:
name: str
email: str
active: bool = True
customer = Customer("Taylor", "[email protected]")
print(customer)Python generates methods such as __init__ and __repr__. The official dataclasses documentation explains defaults, ordering, frozen instances, and post-initialization.
Example project: bank account
class BankAccount:
def __init__(self, owner: str, initial_balance: float = 0) -> None:
if initial_balance < 0:
raise ValueError("initial balance cannot be negative")
self.owner = owner
self._balance = float(initial_balance)
@property
def balance(self) -> float:
return self._balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("withdrawal must be positive")
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
def __repr__(self) -> str:
return (
f"BankAccount(owner={self.owner!r}, "
f"balance={self.balance:.2f})"
)The class protects its rules: callers cannot withdraw more than the balance through the public method, and deposits must be positive.
Test object behavior
import pytest
def test_deposit_increases_balance():
account = BankAccount("Taylor", 100)
account.deposit(25)
assert account.balance == 125
def test_withdraw_rejects_insufficient_funds():
account = BankAccount("Taylor", 50)
with pytest.raises(ValueError, match="insufficient funds"):
account.withdraw(75)The Pytest guide explains fixtures, exception tests, and test organization. Tests should focus on observable behavior rather than internal implementation details.
Document classes and methods
class BankAccount:
"""Represent an account with validated deposit and withdrawal operations."""
def deposit(self, amount: float) -> None:
"""Add a positive amount to the account balance."""
...The Python docstrings guide covers class, method, parameter, return, and exception documentation.
Organize classes into modules
As a project grows, separate responsibilities:
shop/
├── __init__.py
├── models/
│ ├── product.py
│ ├── customer.py
│ └── order.py
├── services/
│ └── checkout.py
└── tests/
├── test_product.py
└── test_order.pyA class should have a focused responsibility. A single class that manages users, databases, email, reports, and payments is difficult to test and change.
When object-oriented design helps
Classes are useful when:
- multiple values and operations belong to one concept;
- several independent instances must maintain their own state;
- rules should be enforced whenever state changes;
- different implementations share an interface;
- composition models a system of cooperating parts.
When a class is unnecessary
A small calculation or stateless transformation may be clearer as a function:
def celsius_to_fahrenheit(celsius: float) -> float:
return celsius * 9 / 5 + 32Do not create a class merely to hold one unrelated function. Python supports procedural, functional, and object-oriented styles, and a project can combine them.
Common beginner mistakes
- Forgetting
self: instance methods receive the current object as the first parameter. - Using class attributes for mutable instance data: lists and dictionaries can be shared accidentally.
- Creating deep inheritance hierarchies: composition is often easier to understand and change.
- Writing getters and setters automatically: public attributes and properties are more idiomatic when appropriate.
- Letting one class do everything: split unrelated responsibilities.
- Exposing invalid state: validate at object boundaries.
- Testing private details: test public behavior so implementation can evolve.
- Confusing identity and equality: the Python == versus is guide explains value and object identity.
A practical design checklist
- Name the real concept represented by the class.
- List the state each instance owns.
- Define the operations that preserve its rules.
- Keep constructor validation clear.
- Prefer composition unless inheritance expresses a genuine substitutable relationship.
- Expose a small public interface.
- Add useful representations and type hints.
- Test normal behavior, boundaries, and invalid operations.
- Refactor when a class gains unrelated responsibilities.
Conclusion
Object-oriented Python combines state and behavior through classes and instances. Begin with __init__, instance attributes, and methods. Add properties when validation or computed access is needed, use class methods for alternative constructors, choose composition for “has a” relationships, and apply inheritance only where substitution makes sense. The goal is not to maximize the number of classes, but to create code whose concepts, rules, and responsibilities are easy to understand and test.




