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 FalseHow 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
0How 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 negativeHow 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 TrueHow 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 514How 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 TrueHow 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 given2. 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 20The 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 balanceMethods 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, andselfrefers to the current instance.- Methods are functions inside the class that use
selfto 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.
@propertyprovides computed or validated attributes;@classmethodgives 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.
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