Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+150 XP

Classes and Objects — The Basics

What a class actually is, what an object actually is, how self really works under the hood, and when object-oriented Python genuinely earns its complexity.

40 min August 2026
// Part 01 — Welcome to Phase 3

Object-Oriented Python Starts Here

Everything in this track up to this point — variables, control flow, strings, lists, dicts, and especially functions in Module 07 — was building toward this phase. Object-oriented programming is not a separate topic bolted onto Python; it is the natural next step once you have functions that operate on data and you start noticing the same clusters of data and functions traveling together everywhere in your code. A shopping cart. A user account. A bank transaction. Each of those is really a bundle of related data (what it has) plus related behavior (what it can do). Phase 3, starting with this module, is about learning Python's tool for expressing that bundle explicitly: the class.

You have actually been using classes this entire time without necessarily thinking of them that way. Every string is an instance of the built-in str class. Every list is an instance of list. When you call "hello".upper(), you are calling a method defined on the str class, and "hello" is the object that method runs against. This module pulls back the curtain on that mechanism and shows you how to build your own classes, from scratch, that work the exact same way.

💡 Note
Six modules make up this phase: classes and objects (this one), constructors and attributes, inheritance and polymorphism, encapsulation and dunder methods, class/static methods and properties, and abstract base classes. By the end of Phase 3 you will be able to design real, production-shaped Python classes — not just toy examples.
// Part 02 — Class vs Object

A Class Is a Blueprint. An Object Is What Gets Built From It.

The cleanest mental model: a class is a blueprint — it describes what something looks like and what it can do, but it is not, itself, a real thing you can use. An object (also called an instance) is a real, concrete thing built from that blueprint. A blueprint for a house is not a house you can live in — it is the plan. Every actual house built from that blueprint is a separate, independent instance, each with its own address, its own paint color, its own furniture, even though they share the same underlying design.

A minimal class — the blueprint
class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

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

Dog by itself is just a definition sitting in memory — no dog exists yet. You create actual dog objects by calling the class, exactly like calling a function:

Creating objects (instances) from the class
rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Poodle")

print(rex.bark())     # Rex says Woof!
print(bella.bark())   # Bella says Woof!

print(type(rex))      # <class '__main__.Dog'>
print(isinstance(rex, Dog))   # True

rex and bella are two completely independent objects. Each has its own name and breed, stored separately. Changing rex.name has zero effect on bella.name — they just happen to have been built from the same blueprint. This independence is the entire point of a class: define the shape once, then stamp out as many independent instances as you need.

// Part 03 — The class Keyword

Anatomy of a Class Definition

A class definition starts with the class keyword, a name (conventionally PascalCase, not snake_case — this is one of the few places Python style genuinely differs between variables/functions and classes), and a colon, followed by an indented block exactly like a function or an if statement.

The pieces of a class
class Dog:                          # PascalCase class name
    species = "Canis familiaris"    # a class attribute (Part 02 of the next module covers this properly)

    def __init__(self, name, breed):   # the constructor — runs automatically when you create an object
        self.name = name                # an instance attribute
        self.breed = breed              # another instance attribute

    def bark(self):                     # a regular method
        return f"{self.name} says Woof!"

    def describe(self):                 # methods can call other methods through self
        return f"{self.name} is a {self.breed}. {self.bark()}"

Everything indented under class Dog: is part of the class body. Functions defined inside that body are called methods — they are functions that belong to the class and operate on individual instances. The naming convention matters more than it might seem: seeing Dog, PaymentProcessor, or OrderValidator in code immediately signals "this is a class" to any experienced Python reader, purely from the capitalization, before they even see a class keyword nearby.

// Part 04 — __init__ as the Constructor

__init__ Runs Automatically Every Time You Create an Object

__init__ (pronounced "dunder init," short for double-underscore init) is a special method Python calls automatically the moment you create a new instance of a class. It is where you set up whatever state that instance needs to start its life with. It is often called "the constructor," though technically Python has a separate, rarely-touched method called __new__ that does the actual object creation — __init__ just initializes the object after it already exists. You will not need __new__ for ordinary application code, and it is out of scope for this track.

__init__ receives whatever arguments you pass when creating the object
class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

rex = Dog("Rex", "German Shepherd")
# Python does roughly this, automatically, behind the scenes:
#   1. Create a new, empty Dog object
#   2. Call Dog.__init__(that_new_object, "Rex", "German Shepherd")
#   3. Bind the name "rex" to the now-initialized object

Nothing forces you to define __init__. A class with no __init__ is completely valid — you simply get an object with no instance attributes set up automatically. In practice, though, almost every real class you write will have one, because almost every real object needs some starting state.

🎯 Pro Tip
__init__ never returns a value. It is not allowed to have a return statement with a value (Python raises a TypeError if you try to return anything other than None from it). Its entire job is to set up self — the object being created — not to produce a result the way a normal function does.
// Part 05 — self, Explained Properly

Why self Is Explicit in Python — The Actual Mechanism

If you have touched Java, C#, or JavaScript, you have seen this — a keyword that refers to the current object inside a method, but that appears "for free," implicitly, without being declared as a parameter. Python does the equivalent job with self, but with one deliberate difference: self is not magic and it is not a keyword. It is an ordinary parameter, written explicitly as the first parameter of every instance method, and Python fills it in for you automatically when you call the method through an object.

Here is the actual mechanism, not just an analogy. A method defined inside a class is, under the hood, just a regular function stored on the class. When you write rex.bark(), Python does not magically know which dog you mean — it translates that call into Dog.bark(rex), passing rex in as the first argument. The parameter you conventionally name self is simply where that first argument lands. You could technically name it anything — self is a universal convention, not a rule enforced by the interpreter — but every Python codebase you will ever work in uses self, and deviating from it will draw immediate code review pushback.

Proving self is just the first argument, not magic
class Dog:
    def bark(self):
        return f"{self.name} says Woof!"

rex = Dog()
rex.name = "Rex"

rex.bark()          # "Rex says Woof!" — the usual, idiomatic way to call it
Dog.bark(rex)        # "Rex says Woof!" — IDENTICAL call, calling the function on the class directly,
                       # and passing rex in manually as the first argument

This is why self must be declared explicitly as a parameter in every method definition, even though you never pass it explicitly when calling instance.method() — Python's dot-call syntax (rex.bark()) is what quietly inserts rex as the first argument for you. It is genuinely just "the object this method was called on," made visible as an ordinary parameter instead of hidden behind special-case syntax the way this is in other languages. Once this clicks, a huge amount of Python's object model stops feeling like magic and starts feeling like straightforward function calls with one extra, automatically-supplied argument.

⚠️ Important
Forgetting self in a method signature is one of the most common beginner errors. Writing def bark(): instead of def bark(self): inside a class will raise a TypeError the moment you call it as rex.bark(), because Python is still trying to pass rex in as an argument, but the function signature has no parameter to receive it. The exact error message is covered in the Error Library at the end of this module.
// Part 06 — Instance Attributes

self.attribute — Data That Belongs to One Specific Object

Any attribute you set with self.something = value inside a method becomes an instance attribute — data that lives on that one specific object, completely separate from every other instance of the same class. You are not limited to setting instance attributes inside __init__, though that is by far the most common and most readable place to do it, since it means every attribute the object will ever have is visible in one place, right where the object is created.

Instance attributes are independent per object
class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed
        self.tricks = []          # every dog starts with its own empty list

    def learn_trick(self, trick):
        self.tricks.append(trick)

rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Poodle")

rex.learn_trick("sit")
rex.learn_trick("roll over")

print(rex.tricks)      # ['sit', 'roll over']
print(bella.tricks)     # [] — completely unaffected by what happened to rex

You can also read and write instance attributes directly from outside the class, using dot notation on the object — rex.name, rex.tricks. Nothing in plain Python prevents this by default (that is exactly the subject of encapsulation, Module 22, later in this phase). For now, treat instance attributes as an object's own private notebook of facts about itself.

// Part 07 — Calling Methods and Multiple Instances

Methods Reading and Changing an Object's Own State

A method is genuinely useful once it does more than just read self — it can change the object's state, and that change persists on the object for as long as it exists. This is the core value proposition of a class: bundling data with the behavior that knows how to change that data correctly, instead of scattering both across loose variables and standalone functions.

A slightly more realistic example — a BankAccount
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError(f"Insufficient funds: balance is {self.balance}")
        self.balance -= amount

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


alice_account = BankAccount("Alice", 500)
bob_account = BankAccount("Bob", 100)

alice_account.deposit(250)
bob_account.withdraw(50)

print(alice_account)   # BankAccount(owner='Alice', balance=750)
print(bob_account)      # BankAccount(owner='Bob', balance=50)

Notice each account genuinely manages its own balance — deposit() and withdraw() can never accidentally touch the wrong account, because every call is scoped to the specific object it was called on (alice_account.deposit(...) only ever touches alice_account). Creating a hundred more accounts costs nothing conceptually — each is independent, automatically, just by virtue of being a separate object. This is what "instance" really buys you: as many independent, self-consistent copies of the blueprint as your program needs, each safely isolated from the others.

💡 Note
__repr__ above is a small preview of dunder methods, covered properly in Module 22. For now, just know it is why print(alice_account) shows a readable summary instead of Python's default, fairly useless <__main__.BankAccount object at 0x7f...>.
// Part 08 — Class vs Simpler Alternatives

When OOP Genuinely Earns Its Complexity — An Honest Take

A class is not automatically the right tool just because Python has one available. Reaching for a full class when a simpler structure would do is a real, common source of unnecessary complexity in junior engineers' code — and this track will not pretend otherwise just because it is teaching OOP right now. It is worth being honest about when a class earns its keep and when it does not.

When a dict or namedtuple is genuinely the better choice

If you just need to group a few related values together with no behavior attached — no methods, no validation, no invariants to protect — a plain dict or a collections.namedtuple (or, once you reach later modules, a dataclass) is simpler, requires less boilerplate, and is exactly as readable.

Overkill: a class with no real behavior
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)

# This is genuinely simpler and does the same job:
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p.x, p.y)   # 3 4

When a class genuinely earns its complexity

A class starts paying for itself the moment you need behavior tied to data (methods that operate on the object's own state, like deposit() above), invariants to protect (rules that must always hold true, like a balance that can never legally go negative), or multiple related pieces of state that change together over the object's lifetime. The BankAccount example above is a genuine case for a class: a dict version of the same thing would let any code accidentally set account["balance"] = -500 directly, bypassing the validation entirely, since a plain dict has no way to enforce a rule about how its own values are allowed to change.

🎯 Pro Tip
A useful rule of thumb: if you find yourself passing the same dict around to five different functions that all read and modify specific keys in it, and there are rules about how those keys are allowed to relate to each other, that is usually a strong signal the dict wants to become a class. If you are just bundling two or three unrelated read-only values with zero behavior, a dict, namedtuple, or dataclass is almost always the better, simpler choice.
// Part 09 — Real World
💼 What This Looks Like at Work

A Denver Ride-Share Startup Rewrites Its Trip-Tracking Dict

Scenario — Ride-share startup, Denver · Codebase refactor

A ride-share startup's early prototype tracked every active trip as a plain dictionary passed between functions — start_trip(), update_location(), end_trip(), each one reaching in and reading or mutating specific keys.

The original prototype — dicts everywhere
def start_trip(driver_id, rider_id, pickup):
    return {
        "driver_id": driver_id,
        "rider_id": rider_id,
        "pickup": pickup,
        "status": "in_progress",
        "fare": 0,
    }

def update_location(trip, new_location):
    trip["current_location"] = new_location
    trip["fare"] += 0.50   # every update tacks on distance-based fare

def end_trip(trip):
    trip["status"] = "completed"
    return trip["fare"]

What went wrong as the codebase grew

Within a few months, a bug landed in production: some code path called update_location() on a trip after it had already been ended, silently adding fare to a trip that should have been closed. Nothing in a plain dict enforced that a completed trip could not still be modified — every function trusted every caller to behave correctly, and eventually one did not.

The fix — exactly what Part 08 describes

The team rewrote Trip as a real class. update_location() became a method that could check self.status before doing anything, and raise if the trip was already completed — an invariant a dict has no mechanism to protect, but a class with real methods enforces automatically, every single time, regardless of which part of the codebase calls it.

The fix — behavior and state bundled together, with a protected invariant
class Trip:
    def __init__(self, driver_id, rider_id, pickup):
        self.driver_id = driver_id
        self.rider_id = rider_id
        self.pickup = pickup
        self.status = "in_progress"
        self.fare = 0

    def update_location(self, new_location):
        if self.status != "in_progress":
            raise ValueError("Cannot update location on a trip that has already ended")
        self.current_location = new_location
        self.fare += 0.50

    def end(self):
        self.status = "completed"
        return self.fare

This is exactly the "invariants to protect" case from Part 08 — the moment a rule needed enforcing across every call site, the dict stopped being the right tool, and the class paid for its extra boilerplate many times over the first time it caught a bug that would otherwise have shipped.

// Part 10 — Misconceptions

Four Misconceptions About Classes and Objects

✕ ""self is a reserved keyword in Python, like this in Java""
self is an ordinary parameter name, chosen purely by convention. It works because Python translates rex.bark() into Dog.bark(rex) automatically — self is simply the first parameter that receives whatever object the method was called on. You could name it anything, but every Python codebase you will work in uses self, and deviating from that convention will draw immediate code review pushback.
✕ ""A class and an object are basically two words for the same thing""
A class is the blueprint/definition; an object (instance) is a concrete thing built from it. rex = Dog("Rex", "German Shepherd") creates one object from the Dog class. You can create as many independent objects from one class as you want, each with its own separate state.
✕ ""You should reach for a class whenever you're grouping related data together""
If there is no behavior attached and no invariants to protect, a plain dict, namedtuple, or dataclass is usually simpler and just as readable, as covered in Part 08. Classes earn their complexity when methods need to read or change an object's own state, or when rules must be enforced about how that state is allowed to change.
✕ ""__init__ is the same thing as the constructor in every sense, and it creates the object""
__init__ initializes an object that already exists — the actual creation is handled by a separate, rarely-touched method called __new__. In everyday application code you will only ever write __init__, but it is worth knowing the distinction exists, since __init__ never returns a value the way you might expect a "constructor" to.
// Part 11 — Interview Prep

5 Interview Questions — With Complete Answers

What is the difference between a class and an object in Python?
A class is a blueprint — a definition of what data an object will hold and what behavior (methods) it will support. An object (instance) is a concrete thing created from that blueprint, with its own independent copy of whatever instance attributes the class defines. You can create many independent objects from one class; each one has separate state, even though they share the same methods.
Explain how self actually works. Why is it explicit in Python, unlike this in Java or JavaScript?
self is not a keyword — it is an ordinary parameter, conventionally named self, that is always the first parameter of an instance method. When you call obj.method(arg), Python translates that into Class.method(obj, arg) — it automatically supplies the object as the first argument. self is just where that argument lands. It has to be declared explicitly in the method signature precisely because Python treats it as a normal parameter passed through normal function-call machinery, rather than hiding it behind special implicit syntax.
What does __init__ do, and is it the same as a constructor?
__init__ runs automatically right after a new object is created, and its job is to set up the object's initial state, typically by assigning instance attributes with self.attribute = value. It is commonly called "the constructor," though technically the actual object creation is handled by a separate, rarely-used method called __new__ — __init__ only initializes an object that already exists, and it must not return anything other than None.
What is the difference between an instance attribute and just using a local variable inside a method?
A local variable inside a method only exists for the duration of that single method call and disappears afterward. An instance attribute, set with self.attribute = value, is stored on the object itself and persists for the entire lifetime of that object, accessible from every other method on the same instance, and directly from outside the class via dot notation.
When would you choose a plain dict over defining a class?
When you are just grouping a few related, mostly read-only values with no behavior attached and no rules to enforce about how they change — a dict, namedtuple, or dataclass is simpler and requires less boilerplate. A class earns its complexity once you need methods that operate on the object's own state, or need to protect an invariant (a rule that must always hold true) that a plain dict has no mechanism to enforce.
// Common Mistakes

Class Mistakes Beginners Make Constantly

Forgetting self as the first parameter of a method
def bark(): inside a class raises a TypeError the moment you call rex.bark(), because Python is still trying to pass rex in as an argument, but the function has no parameter to receive it. Every instance method needs self as its first parameter, even if the method never actually uses it.
Forgetting to call the class (with parentheses) when creating an object
rex = Dog just makes rex point at the class itself, not an instance of it. You need rex = Dog("Rex", "German Shepherd") — the parentheses are what actually trigger __init__ and produce a real object.
Setting attributes outside __init__ and being surprised they don't always exist
If an attribute is only set inside a method other than __init__ (say, learn_trick()), any object that has never had that method called on it will not have that attribute yet, and accessing it raises an AttributeError. Set every attribute the object will ever need inside __init__, even if just to a default value like None or an empty list.
Confusing Dog (the class) with rex (an instance of it) when reading errors
Error messages and code both refer to classes and instances by very similar-looking names. Getting comfortable reading type(rex) and isinstance(rex, Dog) early makes debugging far less confusing once inheritance is introduced in Module 21.
// Error Library

Errors You Will Hit With Classes — And Exactly Why

TypeError: bark() takes 0 positional arguments but 1 was given
Cause: A method was defined without self as its first parameter (def bark():), but Python automatically passes the object in as the first argument whenever you call it as rex.bark().
Fix: Add self as the first parameter of every instance method: def bark(self):.
AttributeError: 'Dog' object has no attribute 'tricks'
Cause: The attribute was never set on this particular instance — most commonly because it is only assigned inside a method other than __init__, and that method has not been called yet on this object.
Fix: Set every attribute the object will need inside __init__, with a sensible default if the real value is not known yet (self.tricks = []), so it always exists from the moment the object is created.
TypeError: __init__() missing 1 required positional argument: 'breed'
Cause: The class was called with fewer arguments than __init__ requires — for example, Dog("Rex") when __init__(self, name, breed) requires both name and breed.
Fix: Pass every required argument, or give the missing parameter a default value in __init__ (covered in depth in Module 20) if it should be optional.
TypeError: 'Dog' object is not callable
Cause: An instance is being called with parentheses as if it were a function or the class itself — for example, rex() instead of rex.bark(). This usually means a method call is missing its name.
Fix: Call a specific method on the instance (rex.bark()), not the instance itself, unless the class deliberately defines __call__ — an advanced dunder method outside the scope of this module.

🎯 Key Takeaways

  • A class is a blueprint; an object (instance) is a concrete thing built from it. Every instance has its own independent copy of the instance attributes the class defines.
  • __init__ runs automatically when you create an object, and is where you set up its initial state — but it never returns a value other than None.
  • self is not magic and not a keyword — it is an ordinary parameter that Python automatically fills in with the object a method was called on. rex.bark() is really Dog.bark(rex) under the hood.
  • Instance attributes are set with self.attribute = value, most commonly inside __init__, and persist for the object's whole lifetime.
  • Multiple instances of the same class are fully independent — changing one instance's attributes never affects another instance, even though they share the same class and methods.
  • A class is not always the right tool. If there is no behavior attached and no invariants to protect, a dict, namedtuple, or dataclass is simpler.
  • Classes earn their complexity once methods need to read or change an object's own state, or once a rule about that state needs enforcing everywhere it is touched.
  • By convention, class names use PascalCase, distinguishing them at a glance from snake_case variables and functions.

What comes next

Module 20 goes deeper on __init__, and covers the single most infamous gotcha in object-oriented Python: what happens when a class attribute is a mutable object, shared silently across every instance until you know to watch for it.

Module 20 → Constructors, Instance vs Class Attributes
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...