Python Basics series — Part 7. This post continues Part 1: What Every Beginner Should Know, Part 2: Variables and Data Types, Part 3: Lists and Dictionaries, Part 4: If Statement, Part 5: User Input and While Loops, and Part 6: Functions. Each post in the series takes one topic and covers it in depth. This one introduces classes.
In Part 3, you stored a client project in a dictionary. In Part 6, you wrote functions like calculate_quote() that work with that data. That works well — but as a program grows, the data and the functions that work on it end up scattered. Which functions expect a project dictionary? Which keys does it need? What happens if someone misspells "budget" as "buget"?
Classes solve this by bundling data and the functions that work with it into one unit. A class describes what a kind of thing has (its data) and what it can do (its functions). From that class, you can create as many individual things — called objects — as you need.
You’ve actually been using objects since Part 2. Every string is an object of the str class, which is why you can call "hello".upper(). Every list is an object of the list class, which is where .append() comes from. In this post, you’ll learn to write your own classes.
As always, open the Python shell (python3 in your terminal), or save the examples in a .py file, and try each one yourself.
Classes and Objects
A class is a blueprint. An object (also called an instance) is one specific thing built from that blueprint.
Think of a class as a project intake form. The form itself defines the fields every project has: client, number of pages, budget. Each filled-in form is an object: Bright Bakery’s 5-page website is one object, and Calexico Coffee’s 8-page online store is another. Same structure, different data.
You can see this with Python’s built-in types:
print(type("Bright Bakery")) # <class 'str'>
print(type([1, 2, 3])) # <class 'list'>
"Bright Bakery" is an object, and str is its class.
Defining a Class
Here’s a simple class for a website project:
class Project:
"""A website project for a client."""
def __init__(self, client, pages):
self.client = client
self.pages = pages
Let’s go through it:
class Project:starts the class. Class names usePascalCase— each word capitalized, no underscores — which makes them easy to tell apart from variables and functions.- The docstring describes what the class represents.
__init__(two underscores on each side) is a special method that Python runs automatically every time you create a new object. Its job is to set up the object’s starting data. It’s often called the constructor.selfis the object being created. Every method in a class receives it as its first parameter. You never pass it yourself — Python does that for you.self.client = clientcreates an attribute: a variable that belongs to the object. Theclienton the right is the parameter;self.clientis the attribute stored on the object.
A function that belongs to a class is called a method. __init__ is the first method you’ll write in almost every class.
Creating Objects
To create an object, call the class like a function, passing the arguments __init__ needs (everything except self):
bakery = Project("Bright Bakery", 5)
coffee = Project("Calexico Coffee", 8)
print(bakery.client) # Bright Bakery
print(coffee.pages) # 8
bakery and coffee are two separate objects made from the same class. Each has its own client and pages. You read an attribute with a dot: object.attribute.
You can change an attribute the same way you change a variable:
bakery.pages = 6
print(bakery.pages) # 6
print(coffee.pages) # 8 (unaffected)
Adding Methods
Attributes hold an object’s data. Methods are what an object can do. You define them inside the class, just like functions, with self as the first parameter:
class Project:
"""A website project for a client."""
HOURLY_RATE = 85
def __init__(self, client, pages, rush=False):
self.client = client
self.pages = pages
self.rush = rush
self.paid = False
def hours(self):
"""Return the estimated hours for the project."""
return self.pages * 4
def total(self):
"""Return the total price, including any rush fee."""
price = self.hours() * self.HOURLY_RATE
if self.rush:
price = price * 1.25
return price
def mark_paid(self):
"""Record that the client has paid."""
self.paid = True
Call a method with a dot, just like .upper() or .append():
bakery = Project("Bright Bakery", 5)
rush_job = Project("Valley Dental", 2, rush=True)
print(bakery.hours()) # 20
print(bakery.total()) # 1700
print(rush_job.total()) # 850.0
print(bakery.paid) # False
bakery.mark_paid()
print(bakery.paid) # True
A few things to notice:
- Inside a method,
selfgives you access to the object’s own data.total()usesself.pagesandself.rushwithout needing them as parameters, because they belong to the object. - Methods can call other methods on the same object:
total()callsself.hours(). __init__can set attributes that aren’t parameters. Every new project starts withself.paid = False.rush=Falseis a default value, just like the ones you used with functions in Part 6.
Class Attributes vs. Instance Attributes
HOURLY_RATE = 85 sits directly inside the class, not inside a method. That makes it a class attribute: a single value shared by every project. Attributes created with self. inside __init__ are instance attributes, and each object has its own copy.
print(Project.HOURLY_RATE) # 85 (read from the class itself)
print(bakery.HOURLY_RATE) # 85 (every object can read it too)
Use class attributes for values that are the same for every object — like constants and settings — and instance attributes for data that’s different for each one.
Methods That Protect the Data
One of the biggest benefits of methods is that they can check changes before making them. Instead of letting any part of your program set pages to anything, give the class a method that validates the change:
class Project:
"""A website project for a client."""
def __init__(self, client):
self.client = client
self.pages = []
def add_page(self, name):
"""Add a page, ignoring blanks and duplicates."""
name = name.strip().title()
if not name:
print("Page name can't be empty.")
elif name in self.pages:
print(f"{name} is already in the project.")
else:
self.pages.append(name)
def page_count(self):
return len(self.pages)
bakery = Project("Bright Bakery")
bakery.add_page("home")
bakery.add_page("menu")
bakery.add_page("Home")
bakery.add_page(" ")
print(bakery.pages) # ['Home', 'Menu']
print(bakery.page_count()) # 2
Home is already in the project.
Page name can't be empty.
['Home', 'Menu']
2
The rules about page names now live in one place — the add_page() method — instead of being repeated everywhere a page gets added.
Printing Objects Nicely with str
If you print an object, Python shows something like <__main__.Project object at 0x104c2f0d0>, which isn’t very helpful. Add a __str__ method to control what print() and str() show. It must return a string:
class Project:
def __init__(self, client, pages):
self.client = client
self.pages = pages
def __str__(self):
return f"{self.client} ({self.pages} pages)"
bakery = Project("Bright Bakery", 5)
print(bakery) # Bright Bakery (5 pages)
print(f"Next up: {bakery}") # Next up: Bright Bakery (5 pages)
__init__ and __str__ are two of Python’s special methods (sometimes called “dunder” methods, for the double underscores). Python calls them automatically at the right moment. There are many more, but these two are the ones you’ll use most.
Working with Many Objects
Objects fit naturally into the lists from Part 3. A list of objects works just like the list of dictionaries you used there — but with dots instead of square brackets, and with methods built in:
class Project:
HOURLY_RATE = 85
def __init__(self, client, pages, paid=False):
self.client = client
self.pages = pages
self.paid = paid
def total(self):
return self.pages * 4 * self.HOURLY_RATE
projects = [
Project("Bright Bakery", 5, paid=True),
Project("Lopez Landscaping", 3),
Project("Calexico Coffee", 8, paid=True),
Project("Valley Dental", 1),
]
revenue = sum(p.total() for p in projects)
unpaid = [p.client for p in projects if not p.paid]
biggest = max(projects, key=lambda p: p.total())
print(revenue) # 5780
print(unpaid) # ['Lopez Landscaping', 'Valley Dental']
print(biggest.client) # Calexico Coffee
Compare p.total() to p["budget"]: the object knows how to calculate its own total, so the code that uses it doesn’t have to.
Inheritance: Building on an Existing Class
Sometimes you need a class that’s almost the same as one you already have, with a few additions. Instead of copying the whole class, you can inherit from it. The new class (the child) automatically gets every attribute and method of the original (the parent), and you only write what’s different.
Say online stores are priced like regular websites, plus a setup fee and a charge for each product you load:
class Project:
"""A website project for a client."""
HOURLY_RATE = 85
def __init__(self, client, pages):
self.client = client
self.pages = pages
def total(self):
return self.pages * 4 * self.HOURLY_RATE
def __str__(self):
return f"{self.client}: ${self.total():,.2f}"
class StoreProject(Project):
"""An online store: a website plus products and a setup fee."""
SETUP_FEE = 500
PRICE_PER_PRODUCT = 10
def __init__(self, client, pages, products):
super().__init__(client, pages)
self.products = products
def total(self):
website = super().total()
return website + self.SETUP_FEE + self.products * self.PRICE_PER_PRODUCT
site = Project("Bright Bakery", 5)
store = StoreProject("Calexico Coffee", 8, products=40)
print(site) # Bright Bakery: $1,700.00
print(store) # Calexico Coffee: $3,620.00
Here’s what’s happening:
class StoreProject(Project):— the parent class goes in the parentheses.super().__init__(client, pages)calls the parent’s__init__, so the child doesn’t have to repeat the code that setsclientandpages. Then it adds its own attribute,products.StoreProjectdefines its owntotal(), which overrides (replaces) the parent’s version. Inside it,super().total()still calls the parent’s version to get the website price, then adds the store-specific costs.StoreProjectnever defines__str__, butprint(store)works anyway: it’s inherited fromProject. And because__str__callsself.total(), it automatically uses the store’s version.
You can check whether an object belongs to a class with isinstance(). A child object counts as an instance of its parent, too:
print(isinstance(store, StoreProject)) # True
print(isinstance(store, Project)) # True
print(isinstance(site, StoreProject)) # False
Inheritance is powerful, but use it only when the child really is a kind of the parent — an online store project is a project. If one thing just has another, use composition instead.
Composition: Objects That Contain Objects
Composition means using objects as attributes of other objects. A client isn’t a kind of project, but a client has projects:
class Client:
"""A client and their projects."""
def __init__(self, name):
self.name = name
self.projects = []
def add_project(self, project):
self.projects.append(project)
def lifetime_value(self):
return sum(p.total() for p in self.projects)
maria = Client("Bright Bakery")
maria.add_project(Project("Bright Bakery", 5))
maria.add_project(StoreProject("Bright Bakery", 3, products=20))
print(len(maria.projects)) # 2
print(maria.lifetime_value()) # 3420
lifetime_value() doesn’t need to know which kind of project each one is. It just calls total(), and each object uses its own version. Different classes responding to the same method name in their own way is called polymorphism, and it’s one of the main reasons object-oriented code stays flexible as it grows.
A Shortcut for Data Classes: @dataclass
Many classes mostly hold data, and writing __init__ with a line for every attribute gets repetitive. Python’s dataclasses module can write that part for you. Put @dataclass above the class and list the attributes with their types:
from dataclasses import dataclass
@dataclass
class Invoice:
client: str
amount: float
paid: bool = False
def mark_paid(self):
self.paid = True
invoice = Invoice("Valley Dental", 340.0)
print(invoice) # Invoice(client='Valley Dental', amount=340.0, paid=False)
invoice.mark_paid()
print(invoice.paid) # True
@dataclass creates the __init__ method automatically, plus a readable printout and the ability to compare two invoices with ==. The : str, : float, and : bool parts are type hints — they document what each attribute should hold. You can still add your own methods, like mark_paid().
Classes in Modules
Just like the functions in Part 6, classes can live in their own file and be imported. If you save Project and StoreProject in a file called projects.py, any other file in the same folder can use them:
from projects import Project, StoreProject
site = Project("Bright Bakery", 5)
Larger programs are usually organized this way: one module per group of related classes.
When Should You Use a Class?
Classes are a tool, not a requirement. Many useful Python programs never define one. Some guidelines:
- Use a dictionary for simple data you just need to store and look up, especially data that comes from JSON or a spreadsheet.
- Use functions for actions that take some input and return a result.
- Use a class when you have data and behavior that belong together — especially when you’ll create many similar objects, need to protect the data with rules, or have several kinds of a thing that share most of their behavior.
If you notice you’re passing the same dictionary to five different functions, that’s often a sign it wants to be a class.
Putting It All Together: A Client Project Tracker
This script combines everything from this post:
class Project:
"""A website project with pages, pricing, and payment status."""
HOURLY_RATE = 85
HOURS_PER_PAGE = 4
def __init__(self, name, pages):
self.name = name
self.pages = pages
self.paid = False
def total(self):
return self.pages * self.HOURS_PER_PAGE * self.HOURLY_RATE
def mark_paid(self):
self.paid = True
def __str__(self):
status = "paid" if self.paid else "unpaid"
return f"{self.name}: ${self.total():,.2f} ({status})"
class StoreProject(Project):
"""An online store: a website plus a setup fee and product loading."""
SETUP_FEE = 500
PRICE_PER_PRODUCT = 10
def __init__(self, name, pages, products):
super().__init__(name, pages)
self.products = products
def total(self):
return super().total() + self.SETUP_FEE + self.products * self.PRICE_PER_PRODUCT
class Client:
"""A client and all of their projects."""
def __init__(self, business):
self.business = business
self.projects = []
def add_project(self, project):
self.projects.append(project)
def amount_owed(self):
return sum(p.total() for p in self.projects if not p.paid)
def report(self):
print(f"\n{self.business}")
for project in self.projects:
print(f" - {project}")
print(f" Amount owed: ${self.amount_owed():,.2f}")
bakery = Client("Bright Bakery")
bakery.add_project(Project("Website redesign", 5))
bakery.add_project(StoreProject("Online ordering", 3, products=25))
bakery.projects[0].mark_paid()
coffee = Client("Calexico Coffee")
coffee.add_project(Project("Landing page", 1))
for client in [bakery, coffee]:
client.report()
Running it prints:
Bright Bakery
- Website redesign: $1,700.00 (paid)
- Online ordering: $1,770.00 (unpaid)
Amount owed: $1,770.00
Calexico Coffee
- Landing page: $340.00 (unpaid)
Amount owed: $340.00
Notice how little the last few lines need to know. They create clients and projects and ask for reports; all the details about pricing, payment status, and formatting live inside the classes. To change how store projects are priced, you’d edit one method, and every report would update automatically.
Common Beginner Mistakes
Forgetting self in a method definition. Every method needs self as its first parameter. Without it, calling the method raises a TypeError like Project.total() takes 0 positional arguments but 1 was given — Python passed the object, but there was no parameter to receive it.
Forgetting self. when using an attribute. Inside a method, pages on its own is a local variable that doesn’t exist; self.pages is the object’s attribute. Writing pages = pages in __init__ (instead of self.pages = pages) silently stores nothing on the object.
Misspelling __init__. It needs two underscores on each side. With _init_ or __int__, Python never calls it, so your attributes are never set, and creating the object fails with a confusing error.
Forgetting the parentheses when creating an object. bakery = Project doesn’t create a project — it just gives the class another name. You need Project("Bright Bakery", 5).
Returning a value from __init__. __init__ sets up the object; it shouldn’t return anything. If you need a calculated value, add a separate method.
Putting a list in a class attribute. A class attribute is shared by every object, so a mutable one like a list is shared too:
class Project:
pages = [] # shared by ALL projects!
def __init__(self, client):
self.client = client
a = Project("Bright Bakery")
b = Project("Calexico Coffee")
a.pages.append("Home")
print(b.pages) # ['Home'] ← b got a's page
Create lists and dictionaries in __init__ with self.pages = [], so each object gets its own.
Using a class for everything. A class with only an __init__ and no methods is often just a dictionary in disguise, and a class with one method is often just a function. Start simple, and reach for a class when data and behavior clearly belong together.
Practice Exercises
- Write a
Clientclass withnameandemailattributes and a__str__method that returns"Name <email>". Create two clients and print them. - Write a
BankAccountclass with abalancethat starts at 0, anddeposit(amount)andwithdraw(amount)methods.withdrawshould refuse to let the balance go below zero. - Add a
discount(percent)method to aProjectclass that lowers its total, and make sure the discount can’t be more than 50%. - Create a
Taskclass with atitleand adonestatus, and aTodoListclass that holds tasks and hasadd(),complete(title), andremaining()methods. - Write a
Productclass, then aDigitalProductsubclass that adds adownload_urland overrides ashipping_cost()method to return0. - Rewrite one of your earlier programs from this series that uses a list of dictionaries so that it uses a list of objects instead.
Conclusion
Classes let you model the things your program works with — clients, projects, invoices, products — as objects that bundle their own data and behavior. __init__ sets up each new object, self gives methods access to that object’s data, and methods let objects do things and protect their data with rules. __str__ makes them print nicely, inheritance lets one class build on another, composition lets objects contain other objects, and @dataclass saves typing for classes that mostly hold data.
You’ve now seen how Python organizes code at every level: values in variables, groups of values in lists and dictionaries, decisions with if, repetition with loops, reusable steps in functions, and now data and behavior together in classes. Those are the same building blocks used in frameworks like Flask and libraries like SQLAlchemy, where you’ll see classes everywhere.
Next in the Python Basics series: Files and Exceptions, how to save your data to files and handle errors without crashing.
Python Basics series: Part 1: What Every Beginner Should Know · Part 2: Variables and Data Types · Part 3: Lists and Dictionaries · Part 4: If Statement · Part 5: User Input and While Loops · Part 6: Functions · Part 7: Classes · Part 8: Files and Exceptions · Part 9: Testing Your Code
