Skip to content

Classes and objects

docs.scrimba.com

Every type you have worked with so far (strings, lists, dictionaries) is actually a class. When you call "hello".upper(), you are calling a method on a string object. Classes let you define your own types with their own data and behaviour. A Player class can hold a name, a score, and a level, and know how to display itself.

Classes are the mechanism for user-defined types. A class defines a template: the data each instance holds (its attributes, the values stored on it) and the operations it supports (its methods, the functions attached to it). Instead of tracking values in parallel variables and passing them everywhere, you bundle them into one object with a clear interface.

A class statement builds a new type (a custom kind of value, the same sort of thing str and int are) and binds it to a name, like any other assignment. You make an instance (one concrete object of that type) by calling the class: Player("Alice"). Behind that call Python allocates a blank object and runs __init__ to fill it in. The model is uniform all the way down: a class is itself an object you can store in a variable, pass to a function, or inspect at runtime (while the program is running), which is what makes alternative constructors and registries later in this chapter possible.

Blueprint and instances

A class is a blueprint. An instance is a specific thing made from that blueprint. You can make as many instances as you need, each with its own data but sharing the same methods defined in the class.

A class defines structure and behaviour. Instances are objects created from that class: each carries its own data but shares the class's method objects. Creating an instance calls the class like a function: Dog() creates a new Dog instance.

Calling a class does two things in order: it allocates a blank instance, then runs __init__ on it to fill in the data. The finished object remembers its class (you can read it back as type(rex)), and that link is how Python finds a method when you call rex.bark(): it looks on the instance first, then on the class. The practical takeaway is that methods live in one place (on the class, shared) while data lives per instance, so a thousand Dog objects cost you a thousand sets of data but only one copy of each method.

python
class Dog:
    def bark(self):
        print("Woof!")

rex = Dog()
luna = Dog()

rex.bark()    # "Woof!"
luna.bark()   # "Woof!"

Dog is the class. rex and luna are instances: two different dogs, each sharing the same behaviour defined in the class.

JunoBlueprint and instances A class is the blueprint, an instance is one thing built from it. Call the class like a function, Dog(), and you get a fresh instance back. Every instance shares the class's methods but keeps its own data, so rex and luna can behave the same while being separate dogs.
JunoBlueprint and instances Calling Dog() makes an instance: its own data, the class's shared methods. That bundling is the whole point, related values and the code that acts on them travel together instead of as loose variables you pass around by hand.
JunoBlueprint and instances Calling a class allocates a blank object, then runs __init__ on it. Methods sit once on the class and are shared, data sits per instance, so a method call on rex is found by looking at the instance then the class. That split is why ten thousand instances stay cheap.

__init__ and self

__init__ is the method Python calls automatically when you create a new instance. It is where you set up the starting data for the object. self is how a method refers to the specific instance it is operating on, and it is always the first parameter.

__init__ initialises a freshly allocated instance. self is a conventional name for the first parameter of every instance method; Python passes the instance automatically when you call alice.display(). Attributes set on self inside __init__ are instance attributes: each instance has its own copy.

__init__ runs on the already-allocated instance and sets it up. self is not a keyword, it is the first parameter, and Python fills it in for you: when you call alice.display(), Python passes alice as self behind the scenes. Setting self.attr = value writes into that one instance, so two Player objects never tread on each other's data. One thing that surprises people coming from stricter languages: Python lets you add attributes anywhere, not only in __init__, so a typo like self.scor = 0 creates a new attribute instead of raising. Set every attribute an instance will ever use in __init__, even to None, so the object's shape is declared in one place and a typo elsewhere stands out.

python
class Player:
    def __init__(self, name, score=0):
        self.name = name
        self.score = score

    def add_points(self, points):
        self.score += points

    def display(self):
        print(f"{self.name}: {self.score} points")

alice = Player("Alice")
bob = Player("Bob", score=50)

alice.add_points(30)
alice.display()   # "Alice: 30 points"
bob.display()     # "Bob: 50 points"

self.name and self.score are instance attributes: they belong to the specific object, not the class itself. Each Player instance has its own name and score.

Juno__init__ and self__init__ runs the moment you create an instance, so it is where you set the starting data with self.name = value. self is the instance Python is working on, and it is always the first parameter of a method, handed to you automatically. You never pass self yourself when you call alice.display().
Juno__init__ and self__init__ sets up a fresh instance, and anything you store on self there is an instance attribute, one copy per object. self is the first parameter of every instance method; Python passes the instance for you when you write alice.display(), you only name it in the definition.
Juno__init__ and selfself is the instance, passed in for you on every call, and writes to self.attr land on that one object. Python won't stop you adding attributes outside __init__, so a misspelled self.scor quietly makes a new attribute. Declare every attribute in __init__, even as None, and the typos have nowhere to hide.

Methods

Any function defined inside a class is a method. Instance methods always have self as the first parameter; Python passes it automatically. Methods can read and change the instance's data via self.

Instance methods are regular functions stored on the class. When you access instance.method, Python hands back a bound method: the same function with the instance already wired in as self, so you only pass the remaining arguments. Returning self from a method enables chaining: obj.scale(2).rotate(90).

A method is one function on the class, shared by every instance. Accessing c.scale doesn't hand you the raw function, it hands you a bound method: that same function with c already wired in as self, which is how self gets passed without you doing it. Two patterns pay off in real code. Returning self from a mutating method gives you a fluent interface (chained calls like c.scale(2).scale(0.5)); reserve it for builder-style objects where chaining reads well, not for everything, since a method that returns self and one that returns a new value look identical at the call site. And because a bound method is a first-class value, you can store one (handler = obj.save) and call it later, which is what callbacks and event handlers rely on.

python
class Circle:
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14159 * self.radius ** 2

    def scale(self, factor):
        self.radius *= factor
        return self    # returning self allows chaining: c.scale(2).scale(0.5)

c = Circle(5)
print(c.area())    # 78.53975
c.scale(2)
print(c.area())    # 314.159
JunoMethods A method is a function defined inside a class, and its first parameter is always self, the instance it is working on. Python passes self for you, so you call c.area() with nothing extra. Through self a method reads and changes that object's own data.
JunoMethods Methods are plain functions on the class; reaching them through an instance binds self automatically, so you pass only the rest. Return self when you want chaining like c.scale(2).scale(0.5). Otherwise return the value the caller asked for.
JunoMethodsobj.method gives you a bound method, the function with obj wired in as self, which is why you never pass it. Returning self buys you fluent chaining, worth it for builders, confusing if every method does it. And since a bound method is a value, handler = obj.save stashes it for a callback later.

Class variables vs instance variables

Variables defined directly on the class (not inside __init__) are class variables. All instances share the same class variable. Variables set on self inside __init__ are instance variables, unique to each object.

A class variable is stored once on the class and shared by every instance. An instance variable is stored on the individual object. When you read self.attr, Python checks the instance first, then the class. When you write self.attr = value, it always creates or updates the instance's own copy, hiding the class variable for that object only.

Reading self.attr checks the instance first, then the class, so a class variable acts as a shared default until some instance writes its own value and shadows it. The trap that bites in production is a mutable class variable (one you can change in place, like a list or dict). Assigning self.x = ... makes a private copy, but self.x.append(...) does not: it reaches the one shared object and every instance sees the change. If you want a fresh list per instance, build it in __init__ with self.history = [], never as a class-level history = []. Reserve class variables for things that really are shared, and keep them immutable: constants, defaults, counters.

python
class Player:
    max_lives = 3    # class variable, same for every Player

    def __init__(self, name):
        self.name = name   # instance variable, unique to each Player
        self.lives = Player.max_lives

    def die(self):
        self.lives -= 1

alice = Player("Alice")
bob = Player("Bob")

Player.max_lives = 5    # change for all current and future instances

Use class variables for values shared across all instances: constants, counters, defaults. Use instance variables for data that differs per object.

JunoClass variables vs instance variables A variable set right on the class is shared by every instance; a variable set on self in __init__ belongs to that one object. Writing self.attr = value always makes or updates the instance's own copy. So reach for class variables when a value is the same for everyone, instance variables when it differs per object.
JunoClass variables vs instance variables Reading self.attr checks the instance, then the class, so a class variable is a shared default. Writing self.attr = value always lands on the instance and hides the class one for that object. Keep class variables for constants and defaults, per-object state goes on self.
JunoClass variables vs instance variables The quiet bug is a mutable class variable: self.x = ... makes a private copy but self.x.append(...) mutates the one shared object, so every instance sees it. Want a per-instance list, build it in __init__ with self.history = [], never history = [] at class level.

__str__ and __repr__

__str__ controls what print() and f-strings show for your object. __repr__ controls the developer view shown in the console and for debugging. Always define __repr__. Define __str__ when you want a clean user-facing display separate from the debug view.

__str__ is called by str(), print(), and the f-strings from the output chapter, the user-facing text. __repr__ is called by repr() and shown when an object prints in the console, the developer view. If only __repr__ is defined, Python uses it for both. The convention: __repr__ returns a string that looks like the code to recreate the object; __str__ returns a readable summary.

These are two dunder methods (short for double-underscore: methods named with leading and trailing __ that Python calls for you at the right moment). print(obj) and str(obj) call __str__, falling back to __repr__ when there is no __str__. repr(obj), and an object shown bare in the console, call __repr__. So __repr__ is the one that always has a job, which is why the rule is: always define __repr__, add __str__ only when the user-facing text should differ from the debug text. Make __repr__ look like the constructor call that built the object (Player(name='Alice', score=87)) and use !r on the fields, so strings keep their quotes and a stray space or newline shows up instead of hiding. A good __repr__ is what makes a log line or a stack trace readable at 2am.

python
class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score

    def __str__(self):
        return f"{self.name} ({self.score} pts)"

    def __repr__(self):
        return f"Player(name={self.name!r}, score={self.score})"

alice = Player("Alice", 87)
print(alice)        # "Alice (87 pts)"   (uses __str__)
repr(alice)         # "Player(name='Alice', score=87)"  (uses __repr__)

Always define __repr__. Define __str__ when you want a clean user-facing representation separate from the debug view. If only __repr__ is defined, Python uses it for both.

Juno__str__ and __repr____str__ is what print() and f-strings show, the friendly version. __repr__ is the developer view you see in the console. Always write __repr__; it is the one that has a job even when you forget __str__. Add __str__ only when the user-facing text should read differently.
Juno__str__ and __repr____str__ is the readable version for print() and f-strings; __repr__ is the debug version, and Python falls back to it when there is no __str__. Always define __repr__, make it look like the call that built the object, and you save yourself later when an object turns up in a log.
Juno__str__ and __repr____repr__ always has a job, __str__ only when the user view should differ, so define __repr__ first and every time. Shape it like the constructor and put !r on the fields, so a stray space or newline shows instead of hiding. That one habit is what makes a 2am stack trace readable.

Private convention

Python has no real private variables, but a single underscore at the start of a name (_balance) is a convention that signals "this is internal, do not use it directly from outside the class". It is not enforced by the language; it is a communication to other developers.

A single underscore (_attr) is a convention signalling internal use. Python does not enforce it, but all linters, IDEs, and developers respect it. A double underscore (__attr) triggers name mangling: Python rewrites it to _ClassName__attr, which prevents accidental collision in subclasses. It is not true privacy; it is a collision-avoidance mechanism.

A single underscore is convention only: nothing enforces it, but linters (tools that scan your code for style and likely bugs), editors, and reviewers all read _balance as "internal, do not touch from outside". A double underscore triggers name mangling: Python rewrites __attr to _ClassName__attr so a subclass defining its own __attr cannot clash with the parent's by accident. It is a collision guard, not privacy: the mangled name is still reachable from outside if someone insists. Reach for it only in a base class meant to be subclassed widely, where you need an attribute the subclasses can't step on; in ordinary code a single underscore is the right default and double underscore mostly creates confusion. If you want to stop arbitrary attributes from being set at all (the typo'd self.scor from earlier), define __slots__ with the fixed list of attribute names: it tells Python those are the only attributes an instance may have, so any other assignment raises instead of silently sticking, and it trims memory per instance as a bonus.

python
class BankAccount:
    def __init__(self, balance):
        self._balance = balance    # _ means "hands off"

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount

    def balance(self):
        return self._balance

A double underscore (__name) triggers name mangling; Python renames the attribute to _ClassName__name to avoid conflicts in subclasses. It is rarely needed. Single underscore is the convention in most code.

JunoPrivate convention Python has no real private variables, but a leading underscore (_balance) is the agreed signal for "internal, leave this alone from outside". Nothing stops you reaching in, it is a message to other developers, including future you. A double underscore is a rarer tool for avoiding name clashes in subclasses; the single underscore is what you will use day to day.
JunoPrivate convention A single underscore means "internal", and every tool and reviewer respects it even though Python doesn't enforce it. A double underscore triggers name mangling to _ClassName__attr, which guards against subclass name clashes, not against access. Reach for the single underscore by default; double is rare.
JunoPrivate convention Single underscore is convention, double underscore mangles to _ClassName__attr to stop subclass clashes, neither is real privacy. Keep __attr for widely-subclassed base classes; elsewhere it only adds confusion. Want to ban stray attributes outright and catch the typo'd self.scor, set __slots__ to the fixed list.

Inheritance

A class can inherit from another class, getting all its attributes and methods automatically. You can then override specific methods in the subclass to change their behaviour. This lets you reuse a common base and specialise where needed.

Inheritance models an "is-a" relationship: a Dog is an Animal. The subclass gets all the parent's methods and attributes and can override any of them by defining its own version. When you call a method, Python looks on the subclass first, then walks up to the parent, so an unoverridden method falls through automatically. That lookup path is the method resolution order (MRO), the ordered list of classes Python searches.

When you call obj.method, Python searches the classes in a fixed order, the MRO (method resolution order: the flattened list of a class and its ancestors, readable as Dog.__mro__), and uses the first match. Single inheritance is straightforward, subclass then parent. The reason the MRO exists is multiple inheritance and the diamond case (two parents that both inherit from a common grandparent): Python orders the classes so the grandparent appears once, after both parents, and resolves calls deterministically. The practical guidance: prefer shallow hierarchies and single inheritance, because deep or multiple-inheritance trees make "which method actually ran" hard to reason about. When you do mix in multiple bases, route every __init__ through super() (next section) so each one runs exactly once along that order.

python
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

pets = [Dog("Rex"), Cat("Luna"), Dog("Max")]
for pet in pets:
    print(pet.speak())

Dog and Cat inherit __init__ from Animal, so they do not need their own. They override speak() with their specific behaviour.

JunoInheritance A subclass inherits everything the parent has, then overrides only the methods it wants to change. Anything you don't override falls through to the parent for free, which is the whole point: write the shared behaviour once, specialise where it differs. Dog and Cat reuse Animal's __init__ and only redefine speak().
JunoInheritance Inheritance is "is-a": the subclass gets the parent's methods and overrides what it needs, and unoverridden calls fall through. Python finds a method by walking the MRO, subclass first, then up to the parent. Reuse the shared base, redefine only what differs.
JunoInheritance Python resolves a method by walking the MRO and taking the first match, which is what makes the diamond case deterministic. Keep hierarchies shallow and lean on single inheritance; deep or multiple-inheritance trees turn "which method ran" into a guessing game. When you must mix bases, chain every __init__ through super().

super()

super() calls a method from the parent class. Use it when you want to extend the parent's behaviour rather than replace it entirely: call the parent's __init__ to run its setup, then add anything your subclass needs on top.

super() returns a proxy object that delegates method calls to the next class in the MRO. Always call super().__init__() from a subclass __init__ when the parent has one. Skipping it means the parent's setup code does not run, which can leave the object in a broken state.

super() doesn't mean "my direct parent", it means "the next class along the MRO" (the lookup order from the previous section). In a plain subclass that next class is the parent, so the distinction looks academic, but it is exactly what makes multiple inheritance work: if every class in the tree calls super().__init__(...), each __init__ runs once, in MRO order, with no class hard-coding a parent by name. That is why you write super().__init__() and not Animal.__init__(self): naming the parent directly breaks the chain the moment another class is mixed in, and can run a shared base twice. The bare super() with no arguments is the modern form; the older super(Dog, self) is the same thing spelled out, which you will still see in code that targets older versions.

python
class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

class Dog(Animal):
    def __init__(self, name):
        super().__init__(name, "Woof")   # call Animal.__init__
        self.tricks = []                  # add something extra

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

rex = Dog("Rex")
rex.learn("sit")
print(rex.tricks)   # ["sit"]

Always call super().__init__() when your subclass has its own __init__ and the parent does too.

Junosuper()super() reaches the parent class, so super().__init__() runs the parent's setup before you add your own. Use it when your subclass writes its own __init__ and the parent has one too. Skip it and the parent's setup never runs, leaving the object half-built.
Junosuper()super() delegates to the next class up, so call super().__init__() from a subclass __init__ whenever the parent has one. Forget it and the parent's setup is skipped, which can leave the object in a broken state. It is the line that wires your subclass onto the parent it extends.
Junosuper()super() means "next class in the MRO", not "my parent", which is why hard-coding Animal.__init__(self) breaks the chain once a class is mixed in. If every __init__ calls super(), each runs once in order. Bare super() is the modern form; super(Dog, self) is the same thing spelled out.

Class methods and static methods

@classmethod creates a method that receives the class itself instead of an instance. It is useful for alternative constructors: creating an instance from a string, a file, or another format. @staticmethod is a plain function that lives inside the class for organisational reasons; it receives neither the instance nor the class.

@classmethod receives cls (the class) as its first argument, not an instance. The primary use is alternative constructors that create instances from different input formats. @staticmethod is a regular function namespaced under the class; it has no access to the class or instance. Use @classmethod for constructors, @staticmethod for utility functions logically tied to the class.

@classmethod receives the class as its first argument (cls) instead of an instance, and the detail that earns its keep is that cls is the class the method was called on, not the one it was defined in. So if Player.from_string builds with cls(...) and a subclass ProPlayer calls ProPlayer.from_string(...), it constructs a ProPlayer, not a Player. That is exactly why alternative constructors use @classmethod rather than hard-coding the class name: they keep working under inheritance. @staticmethod takes neither the instance nor the class; it is a plain function parked inside the class for namespacing, when the logic belongs with the class but needs none of its data. Reach for a classmethod when you are making an instance from some other format, a staticmethod for a related helper, and a normal method for anything that touches one object's data.

python
class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score

    @classmethod
    def from_string(cls, data):
        name, score = data.split(",")
        return cls(name, int(score))

alice = Player.from_string("Alice,87")
python
class Player:
    @staticmethod
    def is_valid_name(name):
        return name.isalpha() and len(name) >= 2

Player.is_valid_name("Alice")   # True
Player.is_valid_name("A1")      # False

Use @classmethod for alternative constructors. Use @staticmethod for utility functions that logically belong with the class but do not need instance or class data.

JunoClass methods and static methods@classmethod hands you the class instead of an instance, which makes it the go-to for alternative constructors: build a Player from a string, a file, whatever format you have. @staticmethod is an ordinary function tucked inside the class for tidiness; it gets neither the class nor the instance. Plain methods touch one object's data, these two don't.
JunoClass methods and static methods@classmethod takes cls, the class itself, so use it for alternative constructors that return cls(...). @staticmethod takes nothing implicit, a helper namespaced under the class. Method for per-object data, classmethod to build instances, staticmethod for a related utility.
JunoClass methods and static methods The point of a classmethod constructor is that cls is whoever called it, so ProPlayer.from_string(...) builds a ProPlayer, not a Player. Hard-code the class name and you lose that. Classmethod to construct, staticmethod for a related helper that needs no data, normal method for everything that touches the instance.

@property

@property lets you access a method like an attribute, with no parentheses needed. Use it for values that are computed from other attributes and feel natural to read as simple attribute access.

@property turns a method into a read-only attribute. The method runs when the attribute is accessed. This is useful for computed values derived from stored data, and for adding validation to attribute access without changing the public interface. A paired @name.setter makes the attribute writable.

@property turns a method into something you read like an attribute: c.area, no parentheses, runs the method each time. Add @area.setter and it becomes writable too, with your code running on the way in, which is where validation goes (reject a negative radius before it is stored). The real value is that this changes nothing for callers: you can start with a plain self.radius attribute and later promote it to a property with validation without touching a single line that reads or writes it. That is why Python has no getter/setter ceremony, you expose plain attributes and reach for a property only when one needs computing or guarding. Two cautions for production: the access looks free but runs code, so keep it cheap (cache an expensive result rather than recomputing on every read), and a property that quietly does heavy work or raises surprises whoever assumed it was a simple field.

python
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

    @property
    def diameter(self):
        return self.radius * 2

c = Circle(5)
print(c.area)      # 78.53975 (looks like an attribute, runs like a method)
print(c.diameter)  # 10

Properties are useful for computed values: things derived from other attributes that feel natural to access without ().

Juno@property@property lets you read a method like an attribute, no parentheses: c.area instead of c.area(). It fits values that are worked out from other attributes and feel natural to read as plain data. Behind the scenes it still runs your method every time you access it.
Juno@property@property reads a method like an attribute, and a paired @name.setter makes it writable so you can validate on the way in. The win: you can turn a plain attribute into a property later without changing any caller. Use it for computed or guarded values, not as a habit on every field.
Juno@property The reason to like @property: a plain attribute can become a computed-or-validated one later with no change to callers, so you skip getters and setters until you actually need one. The access looks free but runs code, so keep it cheap, cache the expensive ones, and don't hide heavy work or a raise behind what reads like a field.

In practice

A Player class with instance attributes, methods, a @property, and __str__:

python
class Player:
    max_lives = 3

    def __init__(self, name: str):
        self.name = name
        self.score = 0
        self.lives = Player.max_lives

    def earn_points(self, amount: int) -> None:
        self.score += amount

    def take_hit(self) -> bool:
        self.lives -= 1
        return self.lives > 0

    @property
    def is_alive(self) -> bool:
        return self.lives > 0

    def __str__(self) -> str:
        return f"{self.name} | Score: {self.score} | Lives: {self.lives}"

alice = Player("Alice")
alice.earn_points(50)
alice.take_hit()
print(alice)            # "Alice | Score: 50 | Lives: 2"
print(alice.is_alive)   # True

A User class that uses a private attribute with a @property getter, a deactivate method, and a to_dict serialiser:

python
class User:
    def __init__(self, user_id: int, username: str, email: str):
        self.id = user_id
        self.username = username
        self.email = email
        self._active = True

    @property
    def active(self) -> bool:
        return self._active

    def deactivate(self) -> None:
        self._active = False

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "username": self.username,
            "email": self.email,
            "active": self._active,
        }

    def __repr__(self) -> str:
        return f"User(id={self.id}, username={self.username!r})"

alice = User(1, "alice", "[email protected]")
print(alice.to_dict())
alice.deactivate()
print(alice.active)   # False

A DataSplit class that encapsulates train/validation slicing behind properties, with __repr__ for clean debug output:

python
class DataSplit:
    def __init__(self, data: list, train_ratio: float = 0.8):
        split = int(len(data) * train_ratio)
        self._train = data[:split]
        self._val = data[split:]

    @property
    def train(self) -> list:
        return self._train

    @property
    def val(self) -> list:
        return self._val

    @property
    def sizes(self) -> tuple[int, int]:
        return len(self._train), len(self._val)

    def __repr__(self) -> str:
        return f"DataSplit(train={len(self._train)}, val={len(self._val)})"

data = list(range(100))
split = DataSplit(data, train_ratio=0.8)
print(split)         # DataSplit(train=80, val=20)
print(split.sizes)   # (80, 20)

The underscore prefix on _train and _val signals that callers should go through the properties rather than mutating the raw lists directly. Python will not enforce this, but it sets a clear contract.