Skip to content
C Codeloom
Python

Classes and Objects in Python

A practical introduction to Python classes — defining a class, __init__ and self, instance vs class attributes, methods, __repr__, and a first look at inheritance.

·9 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • How to define a class with the class keyword
  • What __init__ and self actually do
  • The difference between instance and class attributes
  • How methods work and what self really is
  • Why __repr__ is worth writing for every class
  • A first look at inheritance

Prerequisites

A class is a blueprint for objects that share the same structure and behaviour. Once you’ve described a User once, you can create a thousand users that all know how to validate themselves, format themselves, and answer questions about themselves. Classes are not the only way to organise data in Python — dictionaries and functions take you a long way — but past a certain point, classes are the cleanest representation of “things that have state and verbs.”

What is a class?

A class bundles data (attributes) with behaviour (methods) and gives the bundle a name. An object is one specific instance of that class — one concrete User, one concrete Order.

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

    def bark(self):
        print(f"{self.name} says woof!")

rex = Dog("Rex", "Labrador")
rex.bark()    # Rex says woof!

Dog is the class. rex is an object — an instance of Dog. The class is the recipe; the object is the cake.

__init__ and self

__init__ is the initialiser — it runs every time you create a new instance. Its job is to set up the object’s initial state.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(3, 4)
print(p.x, p.y)    # 3 4

Two things to notice:

  • You did not call __init__ directly. Writing Point(3, 4) creates the object and calls __init__ for you.
  • The first parameter is self. It refers to the object being constructed. self.x = x stores the argument x on the object as an attribute named x.

self is the convention for the first parameter of every method. It is not a keyword — you could legally name it me or this — but every Python programmer expects self, and editors and tools assume it. Use self.

Calling the class does the work for you:

  1. Python creates a new empty object of type Point.
  2. It calls Point.__init__(new_object, 3, 4) — passing the new object as self.
  3. __init__ populates the object and returns nothing.
  4. The new object is what Point(3, 4) evaluates to.

That’s the entire mechanism.

Methods

A method is a function defined inside a class. Its first parameter is always self, and inside the body you read and write attributes through it.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def scale(self, factor):
        self.width *= factor
        self.height *= factor

r = Rectangle(3, 4)
print(r.area())    # 12
r.scale(2)
print(r.area())    # 48

When you call r.area(), Python translates it to Rectangle.area(r). The r becomes self inside the method. This is the entire trick — methods are functions whose first argument is filled in by the dot.

Methods that don’t return a value (like scale above) return None, like any function. Methods that produce a useful value should return it.

Instance vs class attributes

The attributes you set on self belong to that specific instance. Each Rectangle has its own width and height. Sometimes, though, you want an attribute that belongs to the class itself — shared by every instance.

class Dog:
    species = "Canis familiaris"     # class attribute

    def __init__(self, name):
        self.name = name             # instance attribute

a = Dog("Rex")
b = Dog("Fido")
print(a.species, b.species)    # Canis familiaris Canis familiaris
print(a.name, b.name)          # Rex Fido

Class attributes are useful for constants and defaults shared across all instances. They live on the class object itself, not on each instance.

A trap to know about: if a class attribute is a mutable object — a list, dict, or set — it will be shared by every instance, and a mutation through one instance is visible to all the others.

class Inbox:
    messages = []        # SHARED across all instances — almost always a bug

    def add(self, msg):
        self.messages.append(msg)

a = Inbox()
b = Inbox()
a.add("hi")
print(b.messages)    # ['hi']  — oops

The fix is to set mutable state in __init__, so each instance gets its own:

class Inbox:
    def __init__(self):
        self.messages = []

This is the same shape as the famous “mutable default argument” trap — see Default and Keyword Arguments.

__repr__ — give your objects a useful printout

By default, print(obj) gives you something like <__main__.Point object at 0x10a4f8d50> — useless. __repr__ lets you control what an object looks like when printed or shown at the REPL.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

p = Point(3, 4)
print(p)                # Point(x=3, y=4)
print([p, Point(0, 0)]) # [Point(x=3, y=4), Point(x=0, y=0)]

The convention for __repr__ is to return a string that looks like a Python expression which would recreate the object. Point(x=3, y=4) passes the eye test. It’s the small habit that makes every debugging session easier.

__repr__ is one of Python’s dunder methods — short for “double underscore.” There are many more (__eq__, __len__, __iter__, __add__) and they hook into language operators. __repr__ is the one to start with.

Try it yourself. Write a BankAccount class with owner and balance attributes, methods deposit(amount) and withdraw(amount) (refuse negative amounts and overdrafts), and a __repr__ that prints BankAccount(owner='Alice', balance=100). Create an account, deposit and withdraw, and print it.

Methods that compute, methods that mutate

A useful habit is to separate queries (methods that compute and return without changing state) from commands (methods that change state, typically returning None).

class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):           # command
        self.value += 1

    def is_even(self):             # query
        return self.value % 2 == 0

A method that does both — changes state and returns something interesting — is harder to reason about. Not forbidden, but worth pausing over when you find yourself writing one.

A worked example

A small ShoppingCart class that models a real interaction:

class ShoppingCart:
    tax_rate = 0.08    # class attribute — shared

    def __init__(self, owner):
        self.owner = owner
        self.items = []          # list of (name, price)

    def add(self, name, price):
        if price < 0:
            raise ValueError("price cannot be negative")
        self.items.append((name, price))

    def remove(self, name):
        self.items = [item for item in self.items if item[0] != name]

    def subtotal(self):
        return sum(price for _, price in self.items)

    def total(self):
        return round(self.subtotal() * (1 + self.tax_rate), 2)

    def __repr__(self):
        return f"ShoppingCart(owner={self.owner!r}, items={len(self.items)})"


cart = ShoppingCart("Alice")
cart.add("book", 12.99)
cart.add("pen", 1.50)
print(cart)                    # ShoppingCart(owner='Alice', items=2)
print(cart.subtotal())         # 14.49
print(cart.total())            # 15.65
cart.remove("pen")
print(cart.total())            # 14.03

Notice the shape: __init__ sets up state, command methods mutate it, query methods compute from it, and __repr__ makes the object printable. Most well-behaved classes look like this.

Inheritance, briefly

A class can inherit from another, taking on all its methods and attributes and optionally adding or overriding some.

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

    def speak(self):
        return f"{self.name} makes a sound"

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

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

for pet in [Dog("Rex"), Cat("Whiskers")]:
    print(pet.speak())
# Rex says woof!
# Whiskers says meow!

Dog(Animal) means Dog inherits from Animal. Dog gets Animal’s __init__ for free — and overrides speak with its own version.

When a subclass needs to extend the parent’s __init__ rather than replace it, use super():

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

class Manager(Employee):
    def __init__(self, name, salary, reports):
        super().__init__(name, salary)
        self.reports = reports

super().__init__(...) calls the parent’s initialiser, then the subclass adds its own bits. This is the standard pattern.

Inheritance is powerful and easy to overuse. The rule of thumb: reach for it when there is a genuine “is-a” relationship — a Manager is an Employee. When you just want to share some helper code, prefer composition or plain functions.

Try it yourself. Create a Vehicle class with make, model, and a describe() method. Then create Car(Vehicle) that adds a num_doors attribute, calls super().__init__ to set the inherited attributes, and overrides describe() to include the door count.

When to reach for a class

Use a class when:

  • You have data and behaviour that naturally belong together
  • You’re going to create many instances of the same shape
  • You need to track state across a sequence of method calls
  • A dictionary would technically work but the access patterns are starting to look repetitive

Don’t reach for a class when a single function or a small dictionary does the job — and don’t be afraid to start with a dictionary and migrate to a class once the operations on it start to repeat. Python lets you change your mind cheaply.

For data-only objects (no behaviour, just named fields) you’ll eventually meet @dataclass, which writes __init__, __repr__, and __eq__ for you. We’ll cover it in a later post.

Recap

You now know:

  • A class is defined with class Name: and creates a blueprint for objects
  • __init__ sets up new instances; self refers to the instance being acted on
  • Instance attributes live on the object; class attributes are shared
  • Methods are functions whose first parameter is self, filled in by the dot
  • __repr__ makes objects printable and is worth writing for every class
  • class Child(Parent): inherits; super().__init__(...) chains to the parent

Next steps

The next post takes a small but valuable detour into lambda functions — Python’s syntax for anonymous one-line functions. They’re a natural companion to sorted, map, and filter, and seeing where they help (and where they don’t) sharpens your sense of when a regular def is the right call.

→ Next: Lambda Functions in Python

Questions or feedback? Email codeloomdevv@gmail.com.