All 57 lessons

Lesson 25 of 57 · Files, Errors and Modules

Python Classes and Objects: Your First Object-Oriented Code

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

So far your data has lived in separate pieces: a dictionary here, a list there, a function that acts on them somewhere else. That works for small scripts, but as programs grow you start to ask: which functions belong with which data? A customer has a name, a city and an order history, and there are things you can do with a customer: add an order, check Prime status, compute lifetime value. Object-oriented programming (OOP) bundles the data and the functions that work on it into a single unit called an object, built from a blueprint called a class.

You have been using objects all along. A string is an object with methods like .upper(). A list has .append(). A pandas DataFrame is an object with hundreds of methods. In this lesson you will build your own, starting small and working up to a realistic shopping cart. The goal is practical: after this lesson you will be able to read the class-based code in libraries and write tidy classes of your own when a problem calls for it.

Worked examples

Example 1: The simplest class

Question: Define a class, create objects from it, and give them data.

class Customer:
    pass

c1 = Customer()
c1.name = "Isha Joshi"
c1.city = "Delhi"

c2 = Customer()
c2.name = "Priya Sharma"

print(c1.name, c1.city)
print(type(c1).__name__, type(c1) is Customer)
print(c1 is c2, c1 == c2)

Output

Isha Joshi Delhi
Customer True
False False

How it works: class Customer: defines a new type. Calling Customer() creates an instance, a separate object of that type. We can attach attributes to each object with a dot. This works but is untidy because nothing guarantees every customer has the same attributes. The next example fixes that.

Reading the result: c1 is a Customer, and the two customers are different objects (c1 is c2 is False). A class is the blueprint; an instance is one thing built from it.

Example 2: __init__ and self

Question: Make every customer start with the same attributes.

class Customer:
    def __init__(self, customer_id, name, city, is_prime=False):
        self.customer_id = customer_id
        self.name = name
        self.city = city
        self.is_prime = is_prime
        self.orders = []

c = Customer("C0001", "Isha Joshi", "Delhi", is_prime=True)
print(c.customer_id, c.name, c.is_prime, c.orders)

d = Customer("C0002", "Priya Sharma", "Hyderabad")
print(d.is_prime)
print(c.__dict__)

Output

C0001 Isha Joshi True []
False
{'customer_id': 'C0001', 'name': 'Isha Joshi', 'city': 'Delhi', 'is_prime': True, 'orders': []}

How it works: __init__ runs automatically when you create an object. Its first parameter, self, is the new object itself; Python passes it for you, so you supply only the remaining arguments. Assignments like self.name = name store data on that particular instance. __dict__ shows an object's attributes as a dictionary.

Reading the result: Isha is a Prime customer (True) while Priya is not (False), and each has her own empty orders list. Without self., a name inside __init__ would be a throwaway local variable.

Example 3: Methods

Question: Teach a customer to record orders and report totals.

class Customer:
    def __init__(self, customer_id, name, city, is_prime=False):
        self.customer_id = customer_id
        self.name = name
        self.city = city
        self.is_prime = is_prime
        self.orders = []

    def add_order(self, order_id, amount):
        self.orders.append({"order_id": order_id, "amount": amount})

    def total_spent(self):
        return sum(o["amount"] for o in self.orders)

    def average_order(self):
        if not self.orders:
            return 0
        return self.total_spent() / len(self.orders)

c = Customer("C0001", "Isha Joshi", "Delhi")
c.add_order("O1", 450)
c.add_order("O2", 1200)
c.add_order("O3", 300)

print(c.total_spent())
print(c.average_order())
print(len(c.orders))
print(Customer("C9", "New", "Pune").average_order())

Output

1950
650.0
3
0

How it works: A method is a function defined inside a class whose first parameter is self. When you write c.add_order("O1", 450), Python calls Customer.add_order(c, "O1", 450). Methods can use other methods (average_order calls total_spent) and should handle edge cases such as no orders.

Reading the result: After three orders the customer has spent ₹1,950 at an average of ₹650.00 per order. A new customer with no orders safely reports 0.

Example 4: Readable output with __repr__ and __str__

Question: Make print(customer) helpful instead of cryptic.

class Customer:
    def __init__(self, customer_id, name):
        self.customer_id = customer_id
        self.name = name

c = Customer("C0001", "Isha Joshi")
print(c)

class Customer:
    def __init__(self, customer_id, name):
        self.customer_id = customer_id
        self.name = name

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

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

c = Customer("C0001", "Isha Joshi")
print(c)
print(repr(c))
print([c])

Output

<__main__.Customer object at 0x7fdd635b6900>
Isha Joshi (C0001)
Customer(customer_id='C0001', name='Isha Joshi')
[Customer(customer_id='C0001', name='Isha Joshi')]

How it works: By default Python prints something like <__main__.Customer object at 0x...>. __str__ defines the friendly text used by print and str(). __repr__ defines the developer-facing text, ideally something you could paste back into code; it is what lists and debuggers show. If you write only one, write __repr__.

Reading the result: print(c) now shows Isha Joshi (C0001) and the list shows [Customer(customer_id='C0001', name='Isha Joshi')]. Good representations save hours when debugging.

Example 5: Class attributes versus instance attributes

Question: Share one value across all objects.

class Order:
    gst_rate = 0.05          # class attribute, shared
    count = 0

    def __init__(self, order_id, amount):
        self.order_id = order_id    # instance attributes
        self.amount = amount
        Order.count += 1

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

a = Order("O1", 200)
b = Order("O2", 500)
print(a.total(), b.total())
print(Order.count)

Order.gst_rate = 0.12
print(a.total())

b.gst_rate = 0.18
print(a.total(), b.total())
print(Order.gst_rate, b.__dict__)

Output

210.0 525.0
2
224.0
224.0 590.0
0.12 {'order_id': 'O2', 'amount': 500, 'gst_rate': 0.18}

How it works: Attributes defined directly in the class body belong to the class and are shared by every instance, which suits constants and counters. Attributes assigned on self belong to one object. Changing the class attribute affects every instance that has not overridden it; assigning b.gst_rate creates a new instance attribute that hides the class one for b only.

Reading the result: Two orders were created (Order.count is 2). After the rate changed to 12%, order a cost 224.0, while b with its own 18% rate cost 590.0.

Example 6: Properties

Question: Compute a value on demand and validate changes.

class Product:
    def __init__(self, name, price, cost):
        self.name = name
        self.price = price
        self.cost = cost

    @property
    def margin_pct(self):
        return round((self.price - self.cost) / self.price * 100, 1)

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

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

p = Product("Paneer 200g", 95, 70)
print(p.margin_pct)
p.price = 100
print(p.margin_pct)

try:
    p.price = -5
except ValueError as e:
    print("ValueError:", e)

Output

26.3
30.0
ValueError: price cannot be negative

How it works: @property lets a method be read like an attribute (p.margin_pct, with no brackets). A setter runs code whenever the attribute is assigned, so you can validate. The real value is kept in _price, where the leading underscore signals "internal". The benefit is that callers write the simple p.price = 100 while the class still protects its data.

Reading the result: At ₹95 the margin was 26.3%, and after raising the price to ₹100 it became 30.0%. The negative price was rejected.

Example 7: Class methods and static methods

Question: Create objects from other formats, and group utility functions.

class Order:
    def __init__(self, order_id, amount, status="Delivered"):
        self.order_id = order_id
        self.amount = amount
        self.status = status

    @classmethod
    def from_row(cls, row):
        return cls(row["order_id"], float(row["amount"]), row.get("status", "Delivered"))

    @classmethod
    def from_string(cls, text):
        order_id, amount = text.split(",")
        return cls(order_id.strip(), float(amount))

    @staticmethod
    def is_valid_amount(x):
        return isinstance(x, (int, float)) and x > 0

    def __repr__(self):
        return f"Order({self.order_id!r}, {self.amount}, {self.status!r})"

o1 = Order.from_row({"order_id": "O1", "amount": "450", "status": "Returned"})
o2 = Order.from_string("O2, 999.5")
print(o1, o2)
print(Order.is_valid_amount(-5), Order.is_valid_amount(10))

Output

Order('O1', 450.0, 'Returned') Order('O2', 999.5, 'Delivered')
False True

How it works: A @classmethod receives the class (cls) instead of an instance and is typically an alternative constructor. A @staticmethod receives neither: it is an ordinary function that lives in the class because it belongs there logically. Alternative constructors keep __init__ simple while supporting inputs from files, rows or text.

Reading the result: Both constructors built Order objects: Order('O1', 450.0, 'Returned') and Order('O2', 999.5, 'Delivered'). The helper rejected a negative amount (False).

Example 8: A shopping cart

Question: Combine everything into a realistic class.

class Cart:
    DELIVERY_FREE_ABOVE = 499
    DELIVERY_FEE = 25

    def __init__(self):
        self.lines = {}          # name -> [price, quantity]

    def add(self, name, price, qty=1):
        if qty <= 0:
            raise ValueError("quantity must be positive")
        if name in self.lines:
            self.lines[name][1] += qty
        else:
            self.lines[name] = [price, qty]

    def remove(self, name):
        self.lines.pop(name, None)

    @property
    def subtotal(self):
        return sum(p * q for p, q in self.lines.values())

    @property
    def delivery_fee(self):
        return 0 if self.subtotal >= self.DELIVERY_FREE_ABOVE else self.DELIVERY_FEE

    def total(self):
        return self.subtotal + self.delivery_fee

    def __len__(self):
        return sum(q for _, q in self.lines.values())

    def __repr__(self):
        return f"Cart({len(self)} items, subtotal={self.subtotal})"

cart = Cart()
cart.add("Milk", 68, 2)
cart.add("Eggs", 96)
cart.add("Milk", 68)
print(cart)
print(cart.subtotal, cart.delivery_fee, cart.total())

cart.add("Paneer", 95, 3)
print(cart.subtotal, cart.delivery_fee, cart.total())
cart.remove("Eggs")
print(len(cart), cart.total())

Output

Cart(4 items, subtotal=300)
300 25 325
585 0 585
6 514

How it works: The class has constants, a data dictionary, methods that change state, properties for derived values, and __len__ so that len(cart) works. This is object-oriented design in practice: callers use a small, clear interface (add, remove, total) and never touch the internal dictionary.

Reading the result: The first cart had 0 items before anything was added, then after adding 3 milk and 1 egg its subtotal was ₹300 plus a delivery fee. Adding paneer pushed the cart over ₹499 and removed the delivery fee. The final cart holds 6 items for ₹514.

Example 9: Objects from real data

Question: Turn customers.csv rows into objects and compute a statistic.

import csv

class Customer:
    def __init__(self, customer_id, name, city, is_prime):
        self.customer_id = customer_id
        self.name = name
        self.city = city
        self.is_prime = is_prime

    def __repr__(self):
        return f"Customer({self.customer_id}, {self.name!r}, {self.city!r}, prime={self.is_prime})"

with open("customers.csv", newline="", encoding="utf-8") as f:
    customers = [
        Customer(r["customer_id"], r["name"], r["city"], r["is_prime"] == "True")
        for r in csv.DictReader(f)
    ]

print(len(customers))
print(customers[0])
prime_share = sum(c.is_prime for c in customers) / len(customers)
print(f"{prime_share:.1%}")

Output

600
Customer(C0001, 'Isha Joshi', 'Delhi', prime=True)
26.8%

How it works: A comprehension builds one object per CSV row. Converting "True" (text) into a real boolean happens once, at the boundary, so the rest of the code works with clean values. Then sum over booleans gives the number of Prime members.

Reading the result: We created 600 customer objects, of whom 26.8% are Prime. For plain tables, pandas is more convenient; use classes when behaviour (methods) matters.

Example 10: Class or dictionary?

Question: How do you choose between a class and a dictionary?

as_dict = {"order_id": "O1", "amount": 450}

class OrderObj:
    def __init__(self, order_id, amount):
        self.order_id = order_id
        self.amount = amount

as_obj = OrderObj("O1", 450)

print(as_dict["amount"], as_obj.amount)
as_dict["amout"] = 1        # typo silently creates a new key
print(sorted(as_dict))

try:
    as_obj.amout
except AttributeError as e:
    print("AttributeError:", e)

print(isinstance(as_obj, OrderObj), isinstance(as_dict, dict))

Output

450 450
['amount', 'amout', 'order_id']
AttributeError: 'OrderObj' object has no attribute 'amout'
True True

How it works: A dictionary is quick for loose data, but typos create new keys silently, and there is no place to put behaviour. A class gives a fixed set of attributes, methods that travel with the data, and clearer errors. Rule of thumb: dictionaries for data going in and out (JSON, rows), classes for things with rules and behaviour.

Reading the result: The typo amout created an extra key in the dictionary (['amount', 'amout', 'order_id']), whereas reading a misspelled attribute on the object raised an error immediately.

Common mistakes

1. Forgetting self in a method definition.

class Counter:
    def increment():
        pass

Counter().increment()

Output

Traceback (most recent call last):
  File "example.py", line 5, in <module>
    Counter().increment()
    ~~~~~~~~~~~~~~~~~~~^^
TypeError: Counter.increment() takes 0 positional arguments but 1 was given

2. Forgetting self. when setting attributes, so the value is lost when __init__ ends.

3. Mutable class attributes shared by accident: orders = [] in the class body is shared by all instances; create lists inside __init__.

4. Printing an object without __repr__ and getting an unreadable memory address.

5. Overusing classes. If a function and a dictionary solve the problem clearly, use them.

Practice questions

Practice 1 (Easy): A Rectangle

Create a Rectangle class with width and height and methods area() and perimeter(). Test with 4 by 6.

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

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

    def perimeter(self):
        return 2 * (self.width + self.height)

r = Rectangle(4, 6)
print(r.area(), r.perimeter())

Output

24 20

The area is 24 and the perimeter is 20.

Practice 2 (Medium): A bank-style wallet

Create a Wallet class with a balance, deposit(x) and spend(x). Spending more than the balance must raise a ValueError.

Show solution
class Wallet:
    def __init__(self, balance=0):
        self.balance = balance

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

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

w = Wallet(500)
w.deposit(200)
w.spend(300)
print(w.balance)

try:
    w.spend(1000)
except ValueError as e:
    print(e)

Output

400
insufficient balance

Methods change the object's state, and errors protect it. The balance is ₹400 after the valid operations.

Practice 3 (Hard): A delivery tracker

Create a DeliveryTracker class that stores delivery times, with add(minutes), a property average, a property late_share (fraction above 30 minutes), and __len__. Load the real delivery minutes from orders.csv.

Show solution
class DeliveryTracker:
    LATE_AFTER = 30

    def __init__(self):
        self.times = []

    def add(self, minutes):
        self.times.append(minutes)

    @property
    def average(self):
        return sum(self.times) / len(self.times) if self.times else 0

    @property
    def late_share(self):
        if not self.times:
            return 0
        return sum(t > self.LATE_AFTER for t in self.times) / len(self.times)

    def __len__(self):
        return len(self.times)

tracker = DeliveryTracker()
with open("orders.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if row["delivery_minutes"]:
            tracker.add(float(row["delivery_minutes"]))

print(len(tracker), round(tracker.average, 1), f"{tracker.late_share:.1%}")

Output

5630 23.7 15.6%

The tracker holds 5,630 deliveries with an average of 23.7 minutes, and 15.6% take longer than 30 minutes. Properties make derived statistics read like plain attributes.

Key takeaways

  • A class is a blueprint; calling it creates instances with their own data.
  • __init__ sets up each new object, and self refers to the current instance.
  • Methods are functions inside the class that use self to read and change state.
  • Define __repr__ (and optionally __str__) for readable output.
  • Class attributes are shared; instance attributes belong to one object; never share a mutable default.
  • @property provides computed or validated attributes; @classmethod gives alternative constructors.
  • Use classes for things with behaviour; use dictionaries for loose data.

Frequently asked questions

What is self?

It is the instance the method is working on. Python passes it as the first argument automatically; the name self is a convention, not a keyword.

What is the difference between a class and an object?

A class is the blueprint (the definition), and an object (instance) is one thing built from it. You can create many objects from one class.

Do I need classes to be a data analyst?

Not for most analysis, but you will use class-based libraries (pandas, scikit-learn, matplotlib) constantly, and classes help organise larger pipelines and tools.

What does the underscore in _price mean?

It is a convention meaning "internal, please do not touch directly". Python does not enforce it. A double underscore (__x) triggers name mangling and is rarely needed.

Why use @property instead of a method?

It lets callers use a simple attribute syntax while the class keeps control: computed values stay fresh and assignments can be validated.

What is the difference between @classmethod and @staticmethod?

A class method receives the class and is used for alternative constructors. A static method receives nothing automatically and is just a function stored in the class for organisation.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions