Python Course Part 7: Object-Oriented Python (Without the Headache)

Every program so far has kept its data loose: an IP in one variable, a status count in a dictionary, a server load somewhere else. That works until the things you're modelling start having both data and behaviour that belong together.
Take a server. It has data (a name, an IP, a CPU load) and behaviour (is it healthy? is it overloaded?). Right now you'd scatter that across a dict and a couple of standalone functions. A class lets you bundle them into one clean, self-describing type.
If you're coming from Java or C#, you already think in objects — the news here is how little ceremony Python needs, especially once we reach dataclasses. If you're newer, classes are the moment your code starts to feel like it models the real world.
By the end of this post we'll build a server-fleet monitor out of clean objects.
Here's the target:
--- Fleet Status ---
web-01 192.168.1.10 load 0.45 ✅ healthy
web-02 192.168.1.11 load 0.91 ⚠️ overloaded
db-01 192.168.1.20 load 0.30 ✅ healthy
[ALERT] 1 server over threshold: web-02
1. Your First Class
A class is a blueprint. From it you create instances (also called objects) — individual things built to that blueprint.
class Server:
def __init__(self, name, ip, cpu_load):
self.name = name
self.ip = ip
self.cpu_load = cpu_load
# Create two instances from the blueprint:
web1 = Server("web-01", "192.168.1.10", 0.45)
db1 = Server("db-01", "192.168.1.20", 0.30)
print(web1.name) # web-01
print(db1.cpu_load) # 0.3
Two pieces of new vocabulary:
__init__is the constructor — it runs automatically when you create an instance. The double underscores mark it as one of Python's special "dunder" (double-underscore) methods.selfis the instance being built or operated on.self.name = namemeans "store this instance's name." Every method receivesselfas its first parameter.
🛑 Dev Callout: Where's
this? Why IsselfExplicit?In C#/Java,
thisis implicit — the compiler passes it for you. In Python, the instance is passed explicitly as the first parameter of every method, and by convention it's namedself. You writedef is_healthy(self):and call it asweb1.is_healthy()— Python wiresweb1intoselffor you. It feels verbose for about a day, then it just becomes how you read Python. (And yes,self.nameis the field; a barenamewould be a local variable that vanishes when the method returns — the same scope rule from Part 4.)
2. Adding Behaviour (Methods)
A function defined inside a class is a method. It can read and modify the instance's data through self:
class Server:
def __init__(self, name, ip, cpu_load):
self.name = name
self.ip = ip
self.cpu_load = cpu_load
def is_overloaded(self, threshold=0.85):
"""Return True if this server's load is above the threshold."""
return self.cpu_load > threshold
web2 = Server("web-02", "192.168.1.11", 0.91)
print(web2.is_overloaded()) # True
This is the whole point of objects: is_overloaded lives with the data it inspects. You don't pass the load in — the method already has it via self.
3. The Boilerplate Problem
Plain classes have an annoying rough edge. Watch what happens when you try to print one or compare two:
web1 = Server("web-01", "192.168.1.10", 0.45)
print(web1)
# <__main__.Server object at 0x7f3c1a2b8d90> ← useless
a = Server("web-01", "192.168.1.10", 0.45)
b = Server("web-01", "192.168.1.10", 0.45)
print(a == b) # False ← but they're identical!
By default, printing an object shows a memory address, and == checks whether two variables point at the same object, not whether their contents match. To fix both, you'd hand-write two more dunder methods — __repr__ for a readable printout and __eq__ for value comparison — plus retype every field name in __init__. For a class with eight fields, that's a lot of repetitive typing, and every new field means editing three methods. This is exactly the boilerplate Java developers know too well.
4. Enter @dataclass
Python's standard library has a fix: the @dataclass decorator. You declare your fields once, with type hints, and Python generates __init__, __repr__, and __eq__ for you.
from dataclasses import dataclass
@dataclass
class Server:
name: str
ip: str
cpu_load: float
def is_overloaded(self, threshold=0.85):
return self.cpu_load > threshold
That's the entire class. No __init__, no self.name = name copying. Now everything just works:
a = Server("web-01", "192.168.1.10", 0.45)
b = Server("web-01", "192.168.1.10", 0.45)
print(a) # Server(name='web-01', ip='192.168.1.10', cpu_load=0.45)
print(a == b) # True — compared by value, exactly what you wanted
print(a.is_overloaded()) # False
The @dataclass line is a decorator — a function that wraps your class and adds capabilities to it. You'll meet more decorators later; for now, just know it's doing the tedious typing on your behalf.
🛑 Dev Callout: Dataclasses ≈ Records / Structs
A
@dataclassis Python's answer to a C#recordor a Javarecord(and conceptually close to a struct for plain data). Same goal: declare the fields, get value-equality and a sensibleToString()/__repr__for free, skip the constructor boilerplate. One difference worth knowing: a default@dataclassis mutable (you can reassigna.cpu_load = 0.9). Want the immutability of a record? Use@dataclass(frozen=True)and reassignment raises an error — handy for values that should never change after creation, like the tuples back in Part 3.
5. Defaults and a Touch of Logic
Dataclass fields can have defaults, just like function parameters — and they must come after the fields without defaults (same rule as Part 4):
from dataclasses import dataclass, field
@dataclass
class Server:
name: str
ip: str
cpu_load: float = 0.0 # defaults to idle
tags: list = field(default_factory=list) # see the note below
def health_label(self):
return "overloaded" if self.cpu_load > 0.85 else "healthy"
That field(default_factory=list) looks odd, so here's the why: you must never write tags: list = [] as a default. A default value is created once and shared by every instance, so a shared [] would mean every server secretly points at the same list — append to one, and they all change. default_factory=list tells the dataclass to build a fresh empty list for each instance. (Java/C# folks: it's the classic "shared mutable default" footgun, and Python makes you opt out of it explicitly.)
6. Bringing It Together: The Fleet Monitor
Let's model a small fleet and produce the status report from the top of the post. Notice how the comprehensions from Part 6 come back to do the filtering — objects and comprehensions are a great pairing.
Create a file named fleet_monitor.py:
from dataclasses import dataclass
LOAD_THRESHOLD = 0.85
@dataclass
class Server:
name: str
ip: str
cpu_load: float
def is_overloaded(self):
return self.cpu_load > LOAD_THRESHOLD
def status_line(self):
"""A single formatted row for the report."""
icon = "⚠️ " if self.is_overloaded() else "✅"
label = "overloaded" if self.is_overloaded() else "healthy"
# :<8 left-pads the name to 8 chars so the columns line up;
# :<14 does the same for the IP; :.2f keeps the load tidy (Part 1).
return f"{self.name:<8} {self.ip:<14} load {self.cpu_load:.2f} {icon} {label}"
def main():
# Build the fleet — a plain list of Server objects.
fleet = [
Server("web-01", "192.168.1.10", 0.45),
Server("web-02", "192.168.1.11", 0.91),
Server("db-01", "192.168.1.20", 0.30),
]
print("--- Fleet Status ---")
for server in fleet:
print(server.status_line())
# A comprehension over objects: keep only the overloaded ones.
overloaded = [s for s in fleet if s.is_overloaded()]
print()
if overloaded:
names = ", ".join(s.name for s in overloaded)
print(f"[ALERT] {len(overloaded)} server over threshold: {names}")
else:
print("[OK] All servers within healthy limits.")
if __name__ == "__main__":
main()
Run it and you'll get the report. Two things to point out:
", ".join(...)stitches an iterable of strings together with a separator — the clean way to print a comma-separated list. We feed it a generator expression (Part 6) of names.if __name__ == "__main__":is a Python idiom you'll see in nearly every script. It means "only runmain()when this file is executed directly, not when it's imported by another file." That distinction becomes important the moment we start writing tests — which is exactly where we're headed next.
What's Next?
You can now model the world with clean, structured types and bundle behaviour where it belongs. Your code is starting to look like real software.
So far, though, everything we've built lives entirely inside Python's standard library. The real power of the ecosystem is the hundreds of thousands of third-party packages a pip install away. In Part 8: Escaping the Standard Library, we'll set up an isolated virtual environment, install our first external package, and write a script that pulls live data from a real REST API.