Python Classes Objects

A class is a blueprint for creating objects — it bundles together the data (called attributes) and the behavior (called methods) that describe a category of things. An object, also called an instance, is a specific, concrete thing built from that blueprint, with its own independent copy of the data. Classes are the foundation of object-oriented programming (OOP) in Python, letting you model real entities — a Dog, a BankAccount, an Employee — as self-contained units that carry their own state and know how to act on it. Understanding classes deeply matters because almost everything in Python, including strings, lists, and exceptions, is itself an object created from a class.

Overview: How Classes and Objects Work

When you write class Dog:, Python does not create any dogs. It creates a new type object named Dog and stores it in the current namespace, the same way a def statement creates a function object. This type object has its own namespace (its __dict__) that holds everything defined in the class body: methods, and any class attributes (variables assigned directly inside the class body, outside of any method).

Calling Dog("Buddy", 3) is what actually produces an object. Python allocates a brand-new, empty instance, then calls the class’s __init__ method to initialize it, passing the new instance in automatically as the first argument, conventionally named self. Whatever you assign to self.something inside __init__ becomes an instance attribute, stored in that specific object’s own __dict__ — completely separate from any other instance’s dictionary.

This distinction between class attributes and instance attributes is the single most important thing to internalize. A class attribute lives once, on the class, and is shared by every instance. An instance attribute lives separately on each object. When you write buddy.name, Python looks first in buddy.__dict__; if it is not found there, it falls back to looking in Dog.__dict__ (and then up the inheritance chain). This is why methods — which live only in the class’s dictionary — are accessible from every instance without being copied into each one, while data you set with self.x = ... stays private to that object.

Methods themselves are just functions defined inside the class body. What makes instance.method() work without you manually passing the instance is the descriptor protocol: functions implement a special __get__ method, so when you access buddy.bark, Python doesn’t hand you the raw function — it hands you a bound method, a small wrapper that remembers buddy and will automatically supply it as the first argument (self) when you call it. That’s the entire mechanism behind “methods know which object they belong to.”

Syntax

class ClassName:
    class_attribute = value          # shared by every instance

    def __init__(self, param1, param2):
        self.attr1 = param1           # instance attribute
        self.attr2 = param2           # instance attribute

    def method_name(self, arg):
        return self.attr1 + arg       # can read/write instance state


instance = ClassName(value1, value2)   # creates and initializes an object
instance.method_name(arg)              # calls a bound method
Part Meaning
class ClassName: Defines a new type; convention is CapWords naming.
class_attribute Assigned directly in the class body; one copy shared by all instances.
__init__ The initializer, automatically called right after an instance is created.
self The instance itself; always the first parameter of an instance method.
self.attr1 = ... Creates or updates an attribute on this specific instance.
ClassName(...) Instantiation: creates a new object and runs __init__ on it.

Examples

Example 1: A basic class with instance and class attributes

class Dog:
    species = "Canis familiaris"

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def bark(self) -> str:
        return f"{self.name} says Woof!"

    def __str__(self) -> str:
        return f"{self.name} ({self.age} years old)"


buddy = Dog("Buddy", 3)
miles = Dog("Miles", 5)

print(buddy.bark())
print(miles)
print(buddy.species, miles.species)

Output:

Buddy says Woof!
Miles (5 years old)
Canis familiaris Canis familiaris

Each Dog instance gets its own name and age, but both share the single species value that lives on the class itself. print(miles) implicitly calls str(miles), which Python routes to our __str__ method.

Example 2: A class that manages mutable state

class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0):
        self.owner = owner
        self.balance = balance
        self.transaction_log: list[str] = []

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount
        self.transaction_log.append(f"Deposited {amount:.2f}")

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self.transaction_log.append(f"Withdrew {amount:.2f}")

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


account = BankAccount("Grace", 100.0)
account.deposit(50)
account.withdraw(30)

print(account)
for entry in account.transaction_log:
    print(entry)

Output:

BankAccount(owner='Grace', balance=120.00)
Deposited 50.00
Withdrew 30.00

Notice how each method mutates self rather than returning a new object — this is typical for objects that represent evolving state. Because we didn’t define __str__, print(account) falls back to __repr__.

Example 3: Class attributes for shared, class-wide bookkeeping

class Employee:
    company = "Acme Corp"
    _count = 0

    def __init__(self, name: str, salary: float):
        self.name = name
        self.salary = salary
        Employee._count += 1

    def give_raise(self, percent: float) -> None:
        self.salary *= 1 + percent / 100

    @classmethod
    def total_employees(cls) -> int:
        return cls._count


alice = Employee("Alice", 70000)
bob = Employee("Bob", 65000)

alice.give_raise(10)

print(f"{alice.name}: {alice.salary:.2f}")
print(f"{bob.name}: {bob.salary:.2f}")
print(f"Total employees: {Employee.total_employees()}")
print(f"Company: {alice.company}, {bob.company}")

Output:

Alice: 77000.00
Bob: 65000.00
Total employees: 2
Company: Acme Corp, Acme Corp

Here _count is deliberately updated through the class (Employee._count), not through self, so every instantiation increments one shared counter instead of accidentally creating a separate instance attribute.

Under the Hood: What Happens Step by Step

  • Python evaluates the class body once, top to bottom, storing every name it defines (methods, class attributes) into a fresh namespace dictionary.
  • Calling Employee("Alice", 70000) first invokes Employee.__new__(Employee), which allocates a blank instance with an empty __dict__.
  • Python then calls Employee.__init__(instance, "Alice", 70000). Every self.x = ... line inserts a key into that instance’s own __dict__.
  • When you later access alice.company, Python checks alice.__dict__ first (not found), then checks type(alice).__dict__ — found, returns "Acme Corp".
  • When you access alice.give_raise, Python finds the function in the class dictionary and, thanks to the descriptor protocol, wraps it into a bound method tied to alice before returning it — that’s why alice.give_raise(10) works without manually passing alice as an argument.

Common Mistakes

Mistake 1: Forgetting the self parameter

A very common beginner error is defining a method without a first parameter to receive the instance:

class Dog:
    def bark():
        return "Woof!"

d = Dog()
d.bark()  # TypeError: bark() takes 0 positional arguments but 1 was given

Python always passes the instance as the first argument to an instance method, whether you declared a parameter for it or not — so calling d.bark() actually tries to call bark(d), which fails because bark was defined to take zero arguments. The fix is to always accept self:

class Dog:
    def bark(self):
        return "Woof!"


d = Dog()
print(d.bark())

Output:

Woof!

Mistake 2: Using a mutable default argument

Default argument values are evaluated exactly once, when the function is defined — not each time it’s called. Using a mutable object like a list as a default creates one shared list reused by every instance that doesn’t pass its own:

class ShoppingCart:
    def __init__(self, items=[]):
        self.items = items

cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.items.append("apple")
print(cart_b.items)  # ['apple'] -- unexpectedly shared between instances!

Because both cart_a and cart_b received the very same list object, appending to one silently affects the other. The fix is to default to None and create a fresh list inside __init__:

class ShoppingCart:
    def __init__(self, items: list | None = None):
        self.items = items if items is not None else []


cart_a = ShoppingCart()
cart_b = ShoppingCart()
cart_a.items.append("apple")
print(cart_a.items)
print(cart_b.items)

Output:

['apple']
[]

Best Practices

  • Always name the first parameter of an instance method self — it’s a universal convention that every Python reader relies on.
  • Initialize every instance attribute inside __init__ so all instances of a class have a consistent, predictable shape.
  • Use type hints on __init__ parameters and attributes to make the expected data clear to readers and editors.
  • Reserve class attributes for values that are genuinely constant and shared (like configuration or lookup tables); use instance attributes for anything that varies per object.
  • Never use a mutable object (list, dict, set) as a default argument value — default to None and build the mutable object inside the method body.
  • Implement __repr__ on classes you’ll debug with, so print() and the interactive shell give you a useful, unambiguous representation.
  • Use @classmethod for alternate constructors and @staticmethod for helper logic that doesn’t need self or cls at all.
  • Keep each class focused on a single responsibility; if a class is juggling many unrelated concerns, split it.

Practice Exercises

  • Exercise 1: Write a Rectangle class whose __init__ takes width and height, with methods area() and perimeter(). Create two rectangles and print both values for each.
  • Exercise 2: Write a Counter class that uses a class attribute to track how many instances have been created in total, exposed through a @classmethod called total_created(). Create five instances and print the total.
  • Exercise 3: Take the buggy ShoppingCart example from the Common Mistakes section and rewrite it so that three separately created carts can each add items without affecting one another. Prove it works by printing all three carts’ items after modifying only one.

Summary

  • A class is a blueprint; an object (instance) is a concrete thing built from that blueprint via a call like ClassName(...).
  • __init__ runs automatically right after an instance is created and is where you set up instance attributes via self.attr = value.
  • Instance attributes live in each object’s own __dict__; class attributes live once on the class and are shared by all instances.
  • Attribute lookup checks the instance’s dictionary first, then falls back to the class’s dictionary.
  • Methods are ordinary functions stored on the class; accessing them through an instance produces a bound method thanks to the descriptor protocol, which is why self is supplied automatically.
  • Avoid mutable default arguments and always give self as the first parameter of instance methods to sidestep the two most common class-related bugs.