Python Classes Explained: Constructors, self and Attributes

A Python class bundles data and behaviour. What self is, how the init method sets attributes, class vs instance attributes, and the shared-list trap.

  • Course: Python study plan
  • Module: Classes and objects
  • Kind: Lesson
  • Reading time: 14 min
  • Runtime: CPython 3.11

What is self in Python?

self is the conventional name for the first parameter of an instance method, and it receives the instance the method was called on. Python passes it automatically: acct.deposit(50) runs as Account.deposit(acct, 50). Every attribute access inside a method goes through it — self.balance, never a bare balance, which would be a local variable of the method.

Lesson

A class bundles data and the functions that operate on it into one kind of object, and Python's version is unusually transparent: an instance is a dictionary of attributes with a pointer to its class, a method is an ordinary function whose first parameter receives the instance, and self is nothing more than the name that parameter is given by convention. Seeing that plainly removes most of the mystery. This lesson covers the class statement, __init__, instance versus class attributes and the trap between them, methods and how obj.method() becomes Class.method(obj), and __repr__ as the first dunder every class should have.

The statement

class Account:
    """A bank account with a balance that cannot go negative."""

    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount

acct = Account("ada", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct.balance)     # 120

class Account: creates a class object bound to the name Account. Calling it — Account("ada", 100) — creates a new, empty instance and then calls __init__ with the instance as self and the arguments after it. __init__ is not a constructor in the C++ sense (the object already exists; __new__ made it — Module 16) but an initialiser: its job is to give the instance its attributes. It returns nothing.

Naming: classes are PascalCase, methods and attributes snake_case, and there is one blank line between methods, two between top-level definitions.

self

self is the instance, passed automatically. acct.deposit(50) is exactly Account.deposit(acct, 50): attribute lookup finds the function on the class, wraps it with the instance as a bound method, and the call fills in self. That is why every method's first parameter is self, why forgetting it produces TypeError: deposit() takes 1 positional argument but 2 were given, and why self could be named anything — it is a convention so strong that violating it reads as an error.

Inside a method, every attribute access goes through self: self.balance, self.deposit(…). A bare balance would be a local variable of the method, unrelated to the instance — the most common beginner bug in a class.

Instance attributes

Attributes are created by assignment, usually in __init__, and live in the instance's own dictionary:

acct.__dict__          # {'owner': 'ada', 'balance': 120}
acct.nickname = "main" # a new attribute on this instance only — legal, and usually a smell
vars(acct)             # the same dict

Reading a missing attribute is AttributeError. Because attributes are per instance, two accounts have separate balances. The discipline is to create every attribute in __init__, even if only as None or an empty list, so that the instance's shape is visible in one place and no method can meet an attribute that has not been set yet.

Class attributes — and the trap

An assignment in the class body (outside any method) creates a class attribute, shared by every instance:

class Account:
    interest_rate = 0.02          # class attribute: one value for all accounts
    count = 0

    def __init__(self, owner):
        self.owner = owner
        Account.count += 1        # update the shared counter through the class

Reading acct.interest_rate finds nothing in the instance dict and falls through to the class. That is right for constants and defaults. It is wrong for a mutable default:

class Team:
    members = []                  # ONE list shared by every Team

    def add(self, name):
        self.members.append(name) # every team sees every name

class Team:
    def __init__(self):
        self.members = []         # a fresh list per instance

The same lesson as the mutable default argument (Module 4): a mutable object created once in a class body is shared. Per-instance state belongs in __init__.

Assigning acct.interest_rate = 0.05 creates an instance attribute that shadows the class one for that object only — the class value is unchanged. Account.interest_rate = 0.05 changes it for every instance that has not shadowed it.

Methods versus functions

A method is a function in the class body. Three consequences: it can be called on the class with an explicit instance (Account.deposit(acct, 5)); it can be stored and passed like any function (f = acct.deposit; f(5) — a bound method remembers its instance); and a function that does not use self at all is either a @staticmethod (Module 8 lesson 4) or does not belong in the class. Methods call other methods through self, and __init__ may call them to set things up.

__repr__ first

Every class should define __repr__ before anything else, because the default <__main__.Account object at 0x7f…> tells you nothing in a traceback, a list, or a debug print:

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

The convention is text that looks like the constructor call. print(acct) uses __str__ if defined and falls back to __repr__; print([acct]) always uses __repr__ for the elements. The next lesson covers the rest of the dunders.

Pitfalls

  • Forgetting self in a method's parameter list, or writing balance for self.balance.
  • A mutable class attribute used as per-instance state.
  • Creating attributes outside __init__ so the object's shape depends on which methods have run.
  • Account.deposit(50) without an instance.
  • Treating __init__ as returning the object (return self in it is an error).
  • Leaving the default __repr__.

Key takeaways

  • class creates a class object; calling it makes an instance and runs __init__(self, …) to set attributes.
  • obj.method(args) is Class.method(obj, args); self is the instance and every attribute access goes through it.
  • Instance attributes live in obj.__dict__, created by assignment — all of them in __init__.
  • Class-body assignments are shared class attributes: fine for constants, a bug for mutable state.
  • Define __repr__ to look like the constructor call; it is what tracebacks and containers show.

Common questions

What does __init__ do in Python?

__init__ is the initialiser that runs when you call a class: Python creates an empty instance, then calls __init__ with it as self and the call's arguments after it. Its job is to set the instance's attributes, and it returns None — the object already exists, made by __new__.

What is the difference between class attributes and instance attributes in Python?

An instance attribute is set on one object, usually through self in __init__, and lives in that object's __dict__. A class attribute is assigned in the class body and shared by every instance; reading obj.name checks the instance first and falls back to the class.

Why is a list defined in the class body shared by all instances?

A class-body assignment such as members = [] runs once and creates one list stored on the class, so self.members.append(x) mutates that shared list for every instance. Create per-instance state in __init__ instead, with self.members = [].

Why do I get TypeError: takes 1 positional argument but 2 were given?

The method was defined without self. Calling obj.method(x) passes the instance as the first argument, so a method written as def deposit(amount) receives two arguments for one parameter. Add self as the first parameter.

What is __repr__ in Python and why should I define it?

__repr__ returns an object's developer-facing text, shown in tracebacks, at the interactive prompt and inside containers such as print([obj]). The default <__main__.Account object at 0x…> says nothing, so return text that looks like the constructor call, such as Account(owner='ada', balance=120).

Exercises

Tallies with a class-level count

Write Tally, a named counter: Tally(name) starts at 0, inc(by=1) adds, and __repr__ returns Tally(name='x', value=3). The class keeps a class attribute created counting how many instances have been made. Run commands: new name, inc name [by], show name (prints the repr), created (prints the class count).

Input: commands. Output: one line per show and created.

new hits
inc hits
inc hits 4
show hits
new misses
created

prints

Tally(name='hits', value=5)
2

Account

Write Account(owner, balance=0) with deposit(amount), withdraw(amount) that raises ValueError when the balance would go negative, and a __repr__ of the form Account(owner='ada', balance=120). Process commands open owner [balance], deposit owner amount, withdraw owner amount (print insufficient funds when the exception is raised) and show owner.

Input: commands. Output: one line per show and per failed withdrawal.

open ada 100
deposit ada 50
withdraw ada 30
withdraw ada 500
show ada

prints

insufficient funds
Account(owner='ada', balance=120)

In this module: Classes and objects

← Checkpoint — Dictionaries and sets · Dunder methods — making a class behave like a built-in →