Python OOP Introduction
Object-oriented programming (OOP) is a way of organizing code around objects — bundles of data (attributes) and behavior (methods) modeled after real-world things. Instead of scattering variables and the functions that act on them, you define a class as a blueprint and then create as many objects (instances) from it as you need. Python is a deeply object-oriented language — every string, list, and integer you have already used is an object — so understanding classes is essential to understanding how the language itself works, and it lets you model complex problems with cleaner, more reusable code.
What Is a Class? (Overview)
A class is a template describing what data an object will hold and what actions it can perform. An object (also called an instance) is a concrete thing built from that template. If Dog is the class, then my_dog = Dog("Rex", "Labrador") creates one specific dog living at its own place in memory, with its own name and breed — separate from any other Dog you create.
Every class bundles two kinds of members:
- Attributes — the data an object carries (for example
name,breed). These are just variables attached to the object. - Methods — functions defined inside the class that operate on that data (for example
bark()). A method is really just a regular function that Python automatically feeds the object itself into.
That “object itself” is the first parameter of every instance method, and by convention it is named self. When you write my_dog.bark(), Python translates this behind the scenes into Dog.bark(my_dog) — self is simply how the method knows which object’s data to read and modify. Nothing about the name self is a reserved keyword; it is a universal convention, and while you could technically call it something else, doing so would confuse every other Python programmer who reads your code.
Classes also distinguish between two attribute scopes:
- Instance attributes are set on
self(usually inside__init__) and belong to one specific object. TwoDogobjects each have their own independentname. - Class attributes are defined directly in the class body, outside any method, and are shared by every instance of that class — similar to a default value all objects can see unless a particular instance overrides it.
Under the hood, each object stores its instance attributes in a per-object dictionary, accessible as obj.__dict__. When you write my_dog.name, Python first looks in my_dog.__dict__; if the name is not found there, it falls back to looking on the class (and the class’s parent classes). That lookup chain is exactly why class attributes appear “shared”, and it is also the mechanism that makes inheritance work later on.
Syntax
class ClassName:
class_attribute = value # shared by every instance
def __init__(self, param1, param2):
self.attr1 = param1 # instance attribute
self.attr2 = param2
def method_name(self, arg):
# method body, can read/modify self.attr1, self.attr2
return self.attr1
instance = ClassName(value1, value2) # creates a new object
instance.method_name(arg_value) # calls the method on that object
| Part | Meaning |
|---|---|
class ClassName: |
Defines a new class. By convention, class names use PascalCase. |
__init__ |
The constructor — a special method Python calls automatically right after a new object is created, used to set up its initial attributes. |
self |
The first parameter of every instance method; refers to the specific object the method was called on. |
self.attr1 = param1 |
Creates or updates an instance attribute on the current object. |
ClassName(...) |
Calling the class like a function creates and returns a new instance, passing the arguments along to __init__. |
Examples
Example 1: A Basic Class
class Dog:
def __init__(self, name: str, breed: str):
self.name = name
self.breed = breed
def bark(self):
return f"{self.name} says Woof!"
my_dog = Dog("Rex", "Labrador")
print(my_dog.name)
print(my_dog.breed)
print(my_dog.bark())
Output:
Rex
Labrador
Rex says Woof!
Dog("Rex", "Labrador") calls __init__ automatically, storing "Rex" in self.name and "Labrador" in self.breed on the new object. Afterward, my_dog.name and my_dog.breed read those instance attributes directly, and my_dog.bark() runs the method with self automatically bound to my_dog.
Example 2: Methods That Change State
class BankAccount:
def __init__(self, owner: str, balance: float = 0.0):
self.owner = owner
self.balance = balance
def deposit(self, amount: float) -> float:
self.balance += amount
return self.balance
def withdraw(self, amount: float) -> float:
if amount > self.balance:
print("Insufficient funds.")
return self.balance
self.balance -= amount
return self.balance
def __str__(self) -> str:
return f"{self.owner}'s account: ${self.balance:.2f}"
account = BankAccount("Alice", 100.0)
account.deposit(50)
account.withdraw(30)
account.withdraw(1000)
print(account)
Output:
Insufficient funds.
Alice's account: $120.00
This example shows a method (deposit) mutating self.balance, and a guard clause inside withdraw that refuses an overdraft. The final print(account) does not print a memory address because the class defines __str__, a special (“dunder”) method that Python calls automatically whenever an object needs to be converted to a readable string.
Example 3: Class Attributes vs. Instance Attributes
class Employee:
company = "Acme Corp" # class attribute, shared by every instance
_employee_count = 0
def __init__(self, name: str, salary: float):
self.name = name
self.salary = salary
Employee._employee_count += 1
def give_raise(self, amount: float) -> None:
self.salary += amount
def __str__(self) -> str:
return f"{self.name} works at {self.company}, earns ${self.salary:,.2f}"
emp1 = Employee("Diana", 65000)
emp2 = Employee("Marco", 58000)
emp1.give_raise(5000)
print(emp1)
print(emp2)
print(f"Total employees: {Employee._employee_count}")
Output:
Diana works at Acme Corp, earns $70,000.00
Marco works at Acme Corp, earns $58,000.00
Total employees: 2
company is a class attribute: both employees see the same "Acme Corp" without it being copied into each object. _employee_count is also a class attribute, incremented every time __init__ runs, so it tracks how many Employee objects have ever been created — a common pattern for object counters and shared configuration.
How It Works Step by Step (Under the Hood)
When Python evaluates Dog("Rex", "Labrador"), several things happen in order:
- Python allocates a brand-new, empty object and determines its type — this is what
__new__does behind the scenes, though you rarely write it yourself for ordinary classes. - Python calls
__init__(self, "Rex", "Labrador")on that new object, withselfautomatically bound to it. Anyself.attr = valuelines inside__init__insert entries into the object’s private__dict__. - The fully initialized object is handed back and assigned to
my_dog. At this pointmy_dog.__dict__looks like{'name': 'Rex', 'breed': 'Labrador'}— just a regular dictionary. - Whenever you access
my_dog.some_attribute, Python checksmy_dog.__dict__first. If it is not there, it checkstype(my_dog).__dict__(the class), then that class’s parents, following the class’s Method Resolution Order (MRO). This is why methods, which live only on the class, are still reachable from every instance. - Calling a method like
my_dog.bark()works because attribute lookup findsbarkon the class as a function, and since it was accessed through an instance, Python wraps it into a bound method that automatically suppliesmy_dogasself.
You can inspect any of this yourself: type(my_dog) returns <class '__main__.Dog'>, and my_dog.__class__ gives the same thing. Every object also has a unique identity, visible via id(my_dog), which is why two Dog objects built from identical arguments are still considered different objects unless you define custom equality with __eq__.
Common Mistakes
Mistake 1: Forgetting the self Parameter
Every instance method must declare self as its first parameter, because Python always passes the calling object as the first argument. Omitting it causes a TypeError as soon as the method is called on an instance:
class Robot:
def greet(): # missing self
print("Beep boop!")
r = Robot()
r.greet() # TypeError: greet() takes 0 positional arguments but 1 was given
Python still silently passes r into greet because it was called through an instance, but greet was defined to accept zero arguments — the mismatch raises TypeError: greet() takes 0 positional arguments but 1 was given. The fix is simply to add self to the signature: def greet(self):.
Mistake 2: Using a Mutable Class Attribute as “Default” Instance Data
A list or dict defined directly in the class body is a single shared object, not a fresh one per instance. Appending to it from one object silently affects every other object:
class ShoppingCart:
items = [] # BUG: this list is a class attribute, shared by every instance
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("apple")
print(cart2.items)
Output:
['apple']
Even though cart2 never called add_item, its items list shows "apple", because self.items resolved to the one class-level list shared by both carts. The fix is to create the mutable value fresh inside __init__, so each object gets its own:
class ShoppingCart:
def __init__(self):
self.items = [] # instance attribute, unique to each object
def add_item(self, item):
self.items.append(item)
cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add_item("apple")
print(cart2.items)
Output:
[]
This is one of the most common bugs new Python OOP programmers write, and it applies to any mutable default — lists, dicts, sets — placed directly in the class body instead of inside __init__.
Best Practices
- Name classes with
PascalCase(BankAccount) and methods/attributes withsnake_case(give_raise), following PEP 8. - Initialize every instance attribute inside
__init__, even if only toNoneor an empty collection, so an object’s shape is predictable no matter which methods have been called on it. - Only use class attributes for truly shared, effectively constant data (configuration values, counters, defaults for immutable types) — never for mutable containers meant to differ per object.
- Prefer a leading underscore, like
_balance, to signal an attribute is “internal” and not part of the public interface, even though Python does not enforce true privacy. - Implement
__str__(and often__repr__) on classes you will print or debug, soprint(obj)and interactive sessions show something meaningful instead of a raw memory address. - Keep each class focused on one responsibility; if a class is doing several unrelated jobs, it usually belongs in multiple classes.
- Use type hints on
__init__parameters and attributes to document what each field is expected to hold, which pays off enormously as classes grow.
Practice Exercises
- Write a
Rectangleclass withwidthandheightinstance attributes and a methodarea()that returnswidth * height. Create two rectangles and print both areas. - Write a
Counterclass with a class attributetotal_created = 0that increments every time a newCounteris constructed, plus an instance methodreset(self)that sets that particular instance’s own count back to zero without affecting other instances. (Hint: think carefully about which attribute is on the class and which ends up on the instance once you assign inside a method.) - Take the buggy
ShoppingCartfrom the Common Mistakes section and extend the corrected version with aremove_item(self, item)method and atotal_items(self)method that returnslen(self.items). Test it with at least two separate cart instances to confirm their contents stay independent.
Summary
- A class is a blueprint; an object (or instance) is a concrete thing built from that blueprint, created by calling the class like a function.
- Attributes hold data, methods hold behavior, and
selfis how a method knows which specific object it is operating on. __init__is the constructor, automatically called right after an object is created, and is the standard place to set instance attributes.- Instance attributes (set via
self.x = ...) belong to one object; class attributes (set in the class body) are shared by all instances through Python’s attribute lookup chain. - Forgetting
selfin a method definition, and using mutable class attributes where instance attributes were intended, are two of the most common OOP bugs — both are easy to avoid once you understand how attribute lookup works. - Special methods like
__str__let your objects integrate naturally with built-in functions such asprint().
