Python Classes and Objects: __init__, self and Methods - Python with AI tutorial chapter 32
Python

Python Classes and Objects: __init__, self and Methods

Python with AI Tutorial · Chapter 32 of 48

Python classes and objects let you bundle data and the functions that work on it into one named thing. A class is the blueprint (Employee, Invoice, Product); an object is one concrete instance built from it. The __init__ method sets up each new object, self refers to the object being worked on, and methods are functions that live inside the class.

So far you have kept an employee as a dictionary and written separate functions to calculate pay. That works for ten lines and gets messy at a hundred. Object-oriented programming puts the data and its behaviour together, so priya.annual_cost() reads like English and every part of your program agrees on what an Employee is. Everything in Python, from strings to pandas DataFrames, is built this way, which is why understanding classes makes every library easier to use.

Defining a class and creating objects

Use the class keyword with a capitalised name. __init__ runs automatically each time you create an object; its parameters become the values you pass in, and self.name = name stores them on the object as attributes.

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

priya = Employee("Priya Sharma", "Finance", 85000)
rahul = Employee("Rahul Verma", "Sales", 62000)

print(priya.name, "-", priya.dept)
print(rahul.salary)
print(type(priya))
Output:
Priya Sharma - Finance
62000
<class '__main__.Employee'>

self is not a keyword, just the conventional name for the first parameter of every method. Python fills it in for you: priya.annual_cost() is really Employee.annual_cost(priya). You never pass it yourself.

Adding methods

A method is a function defined inside the class. It reads and changes the object through self, and it can take extra parameters and default values just like any function.

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

    def annual_cost(self, bonus_pct=10):
        return self.salary * 12 * (100 + bonus_pct) // 100

    def give_raise(self, pct):
        self.salary = round(self.salary * (1 + pct / 100))

priya = Employee("Priya Sharma", "Finance", 85000)
print(priya.annual_cost())

priya.give_raise(8)
print(priya.salary)
print(priya.annual_cost(bonus_pct=0))
Output:
1122000
91800
1101600
Common mistake: writing print(priya.annual_cost) without parentheses prints <bound method Employee.annual_cost of ...> instead of a number. Methods are called with (); attributes are read without. The other classic is defining a method without self, which fails with takes 0 positional arguments but 1 was given.

__str__ and __repr__: readable objects

By default print(obj) shows an unhelpful memory address. Define __str__ for the friendly text a user sees and __repr__ for the unambiguous text a developer sees in lists and debuggers.

class Invoice:
    def __init__(self, number, customer, amount):
        self.number = number
        self.customer = customer
        self.amount = amount

    def __str__(self):
        return f"Invoice {self.number} - {self.customer}: {self.amount:,.2f}"

    def __repr__(self):
        return f"Invoice({self.number!r}, {self.customer!r}, {self.amount})"

inv = Invoice("INV-104", "Acme Ltd", 12500.5)
print(inv)
print(repr(inv))
print([inv])
Output:
Invoice INV-104 - Acme Ltd: 12,500.50
Invoice('INV-104', 'Acme Ltd', 12500.5)
[Invoice('INV-104', 'Acme Ltd', 12500.5)]
Special method Triggered by Typical use
__init__(self, ...) Creating an object Store the starting attributes
__str__(self) print(obj), str(obj) Friendly text for users
__repr__(self) repr(obj), showing a list Debug text that recreates the object
__eq__(self, other) a == b Compare by value rather than identity
__lt__(self, other) a < b, sorted() Natural sort order
__len__(self) len(obj) Number of items in a container object

Class attributes versus instance attributes

An attribute set with self. inside __init__ belongs to one object. An attribute defined directly in the class body is shared by every object, which suits constants such as a tax rate and counters that track how many objects exist.

class Invoice:
    gst_rate = 0.18        # shared by all invoices
    count = 0

    def __init__(self, number, amount):
        self.number = number   # unique to this invoice
        self.amount = amount
        Invoice.count += 1

    def total_with_gst(self):
        return round(self.amount * (1 + Invoice.gst_rate), 2)

a = Invoice("INV-104", 10000)
b = Invoice("INV-105", 2500)

print("Invoices created:", Invoice.count)
print(a.total_with_gst(), b.total_with_gst())

Invoice.gst_rate = 0.12
print(a.total_with_gst())
Output:
Invoices created: 2
11800.0 2950.0
11200.0

Inspecting, adding and deleting attributes

Objects are flexible: you can attach a new attribute at any time, check for one with hasattr, read one safely with getattr and a default, and remove one with del. The __dict__ attribute shows everything stored on the object.

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

priya = Employee("Priya Sharma", "Finance", 85000)
priya.email = "priya.sharma@neotech.in"

print(priya.__dict__)
print(hasattr(priya, "phone"), getattr(priya, "phone", "not set"))

del priya.email
print("email" in priya.__dict__)
Output:
{'name': 'Priya Sharma', 'dept': 'Finance', 'salary': 85000, 'email': 'priya.sharma@neotech.in'}
False not set
False

Working with a list of objects

The real payoff arrives when you have many objects. Lists of objects sort, filter and sum with the same tools you already know, and attribute names make the intent obvious.

team = [
    Employee("Priya Sharma", "Finance", 85000),
    Employee("Rahul Verma", "Sales", 62000),
    Employee("Anita Desai", "Sales", 71000),
]

print("Monthly payroll:", sum(e.salary for e in team))
print("Highest paid:", max(team, key=lambda e: e.salary).name)

sales_team = [e.name for e in team if e.dept == "Sales"]
print("Sales:", sales_team)

for e in sorted(team, key=lambda e: e.name):
    print(f"{e.name:<14}{e.dept}")
Output:
Monthly payroll: 218000
Highest paid: Priya Sharma
Sales: ['Rahul Verma', 'Anita Desai']
Anita Desai   Sales
Priya Sharma  Finance
Rahul Verma   Sales

Properties: validation and computed values

A @property looks like an attribute from the outside but runs code when read or assigned. Use a setter to reject bad values at the door, and a read-only property for values that should always be derived from others rather than stored.

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price          # goes through the setter below

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = round(value, 2)

    @property
    def price_with_gst(self):
        return round(self._price * 1.18, 2)

p = Product("Laptop", 45999.999)
print(p.price, p.price_with_gst)

try:
    p.price = -5
except ValueError as e:
    print("Error:", e)
Output:
46000.0 54280.0
Error: Price cannot be negative

Less boilerplate with dataclasses

When a class is mostly data, the @dataclass decorator writes __init__, __repr__ and __eq__ for you from the type-annotated fields. You still add ordinary methods underneath.

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: int
    customer: str
    items: list = field(default_factory=list)

    def total(self):
        return sum(qty * price for qty, price in self.items)

o = Order(1001, "Acme Ltd", [(2, 1200.0), (1, 4500.0)])
print(o)
print("Total:", o.total())
print(o == Order(1001, "Acme Ltd", [(2, 1200.0), (1, 4500.0)]))
Output:
Order(order_id=1001, customer='Acme Ltd', items=[(2, 1200.0), (1, 4500.0)])
Total: 6900.0
True
Try it with AI

Ask for a small class design and a critique of your own attempt.

Design a Python class BankAccount for a small business with attributes account_no, holder and balance, methods deposit(amount) and withdraw(amount) that reject negative amounts and overdrafts with ValueError, a transactions list that records every change with a timestamp, and a __str__ that shows the balance formatted with commas. Then explain in plain English what self does in each method.
Try it with AI

Convert dictionary-based code into classes.

Here is my Python script that stores each invoice as a dict and has five separate functions that take an invoice dict (add_gst, is_overdue, days_late, mark_paid, summary_line). Refactor it into an Invoice class with those as methods, use a @property for total_with_gst, add __repr__, and show before and after code side by side with a short note on what improved.

Common mistakes

  • Leaving self out of a method definition, or writing name = name instead of self.name = name so the value is lost.
  • Putting a mutable default such as items=[] in __init__; every object then shares the same list. Use None and create the list inside, or field(default_factory=list) in a dataclass.
  • Calling a method without parentheses and getting a bound method object instead of a result.
  • Changing a class attribute through an instance (a.gst_rate = 0.12), which creates a new instance attribute and leaves every other object unchanged.
  • Forgetting that == compares identity for plain classes unless you define __eq__ or use a dataclass.

Exercise

Create a Subscription class with customer, monthly_fee and months. Add a method total() that applies a 10% discount when months is 12 or more, and a __str__ that prints Acme Ltd: 12 months at 2,500.00 = 27,000.00.

Show answer
class Subscription:
    def __init__(self, customer, monthly_fee, months):
        self.customer = customer
        self.monthly_fee = monthly_fee
        self.months = months

    def total(self):
        amount = self.monthly_fee * self.months
        if self.months >= 12:
            amount *= 0.9
        return round(amount, 2)

    def __str__(self):
        return (f"{self.customer}: {self.months} months at "
                f"{self.monthly_fee:,.2f} = {self.total():,.2f}")

print(Subscription("Acme Ltd", 2500, 12))
# Acme Ltd: 12 months at 2,500.00 = 27,000.00

Related chapters

FAQ

What does self mean in a Python class?

self is the object the method is being called on. Python passes it automatically, so priya.give_raise(8) runs give_raise with self set to priya. It is a convention, not a keyword.

What is the difference between __init__ and a constructor?

__init__ is Python’s initialiser: it runs right after the object is created to set its attributes. In everyday speech people call it the constructor, and for practical purposes it plays that role.

When should I use a dataclass instead of a normal class?

Use @dataclass when the class mainly holds data and you want __init__, __repr__ and __eq__ generated for you. Use a normal class when you need custom initialisation logic or properties.

Working with spreadsheets too? Ready-made Excel, Google Sheets and Power BI templates are at NextGenTemplates.com.

Chapter 32 of 48 · Python with AI: all 48 chapters

PK
Meet PK, the founder of NeotechNavigators.com! With over 15 years of experience in Data Visualization, Excel Automation, and dashboard creation. PK is a Microsoft Certified Professional who has a passion for all things in Excel. PK loves to explore new and innovative ways to use Excel and is always eager to share his knowledge with others. With an eye for detail and a commitment to excellence, PK has become a go-to expert in the world of Excel. Whether you're looking to create stunning visualizations or streamline your workflow with automation, PK has the skills and expertise to help you succeed. Join the many satisfied clients who have benefited from PK's services and see how he can take your data analysis skills to the next level!
https://neotechnavigators.com