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. Two Dog objects each have their own independent name.
  • 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, with self automatically bound to it. Any self.attr = value lines inside __init__ insert entries into the object’s private __dict__.
  • The fully initialized object is handed back and assigned to my_dog. At this point my_dog.__dict__ looks like {'name': 'Rex', 'breed': 'Labrador'} — just a regular dictionary.
  • Whenever you access my_dog.some_attribute, Python checks my_dog.__dict__ first. If it is not there, it checks type(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 finds bark on the class as a function, and since it was accessed through an instance, Python wraps it into a bound method that automatically supplies my_dog as self.

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 with snake_case (give_raise), following PEP 8.
  • Initialize every instance attribute inside __init__, even if only to None or 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, so print(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 Rectangle class with width and height instance attributes and a method area() that returns width * height. Create two rectangles and print both areas.
  • Write a Counter class with a class attribute total_created = 0 that increments every time a new Counter is constructed, plus an instance method reset(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 ShoppingCart from the Common Mistakes section and extend the corrected version with a remove_item(self, item) method and a total_items(self) method that returns len(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 self is 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 self in 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 as print().