The Story That Ties These Three Ideas Together
On the wall: posted rules — STANDARD_CUP = 250 ml, MAX_BREW_TEMP = 95°C, TAX_RATE = 10%. Same everywhere. Nobody edits them. These are static variables.
Behind the counter: a thermometer, a scale, a stopwatch, a calculator. Anyone can grab one. They don't care whose drink is being made. Pure tools. These are static methods.
At the espresso machine: three signature drinks — Espresso, Latte, Cappuccino. Every barista knows a generic
prepare() step, but each drink
is prepared completely differently. The word "prepare" doesn't mean "grind and
extract 25 seconds" for a Latte — it means "steam milk to 65°C and pour." Same call, different
result. That's method overriding.Wire all three together and you get a class hierarchy that shares what should be shared and customises what should be customised.
Static variables = one copy of a constant, shared by every instance. Static methods = pure functions living inside the class namespace. Method overriding = a child class redefining a parent's method to change its behaviour. Together they turn a rigid class into a flexible family of classes.
Meet All Three at a Glance
UPPER_SNAKE_CASE to signal "don't reassign."
Example: Drink.TAX_RATE = 0.10.
self, no cls
— only their own arguments. Called on the class:
Drink.celsius_to_fahrenheit(65). Perfect for validators, converters, and
pure calculations.
Static Variables — The Shop's Posted Rules
A static variable is a value defined directly under the class line, not inside
__init__. There is exactly one copy in memory and every instance
reads from it.
class Drink:
# ══ STATIC VARIABLES — one copy, shared by every drink ══
STANDARD_CUP_ML = 250
MAX_BREW_TEMP_C = 95
TAX_RATE = 0.10
CURRENCY = "$"
def __init__(self, base_price):
# INSTANCE variables — one per drink
self.base_price = base_price
# Read via the class (canonical form)
print(Drink.STANDARD_CUP_ML) # 250
print(Drink.TAX_RATE) # 0.1
print(Drink.CURRENCY) # $
# Every instance sees the same value — it's not copied per drink
d1 = Drink(3); d2 = Drink(5); d3 = Drink(4)
print(d1.TAX_RATE, d2.TAX_RATE, d3.TAX_RATE) # 0.1 0.1 0.1
Animated Diagram — One Posted Rulebook, Every Drink Reads It
Three drinks, three prices — but only one TAX_RATE. Change the constant on the class and every drink instantly sees the new value.
latte.TAX_RATE = 0.15 does not update the constant. It creates a
per-instance shadow that only latte sees — every other drink still reads
0.10. To modify a static value (rarely a good idea), do it through the class:
Drink.TAX_RATE = 0.15.
Static Methods — The Shared Utility Shelf
A static method is defined with the @staticmethod decorator and takes no
self and no cls. It's a plain function that happens to live inside
the class namespace. Use it for validators, converters, formatters, and pure calculations.
class Drink:
STANDARD_CUP_ML = 250
TAX_RATE = 0.10
def __init__(self, base_price):
self.base_price = base_price
# ══ STATIC METHOD — pure input → output, no self, no cls ══
@staticmethod
def celsius_to_fahrenheit(c):
return c * 9 / 5 + 32
@staticmethod
def is_valid_size(size_ml):
return 25 <= size_ml <= 500
@staticmethod
def calculate_total(price):
# Uses the class-level constant — but no self/cls needed
return price * (1 + Drink.TAX_RATE)
# Call BEFORE any drink exists — that's the whole point
print(Drink.celsius_to_fahrenheit(65)) # 149.0
print(Drink.is_valid_size(200)) # True
print(Drink.is_valid_size(1000)) # False
print(Drink.calculate_total(5.00)) # 5.5 (with 10% tax)
Animated Diagram — Static Method as a Pure Function
A static method is a plain function that lives in the class namespace. Call it directly on the class — no instance needed.
Method Overriding — Same Call, Different Behaviour
Method overriding is the moment a child class defines a method with the same name as one in its parent. When the method is called on the child, Python uses the child's version — the parent's version is silently ignored. This is what makes polymorphism possible: many types respond to the same command in different ways.
class Drink:
def __init__(self, size_ml, base_price):
self.size_ml = size_ml
self.base_price = base_price
# A generic prepare() — subclasses will override this
def prepare(self):
print("Preparing a generic drink — hot water in a cup.")
class Espresso(Drink):
# ── OVERRIDE — same name, brand-new implementation ──
def prepare(self):
print(f"Espresso ({self.size_ml}ml): grind beans, tamp, extract 25s.")
class Latte(Drink):
def prepare(self):
print(f"Latte ({self.size_ml}ml): pull espresso, steam milk to 65°C, pour art.")
class Cappuccino(Drink):
def prepare(self):
print(f"Cappuccino ({self.size_ml}ml): equal parts espresso, milk, foam.")
# Same call, different result — that's polymorphism
for drink in [Espresso(30, 3), Latte(250, 5), Cappuccino(180, 4.5)]:
drink.prepare()
When you call obj.method(), Python starts searching at the object's own class.
The first match wins. If Espresso defines prepare(),
Python uses that and never even looks at Drink.prepare(). This is the
Method Resolution Order (MRO) — deterministic, cheap, and the foundation
of polymorphism.
Animated Diagram — One Call, Three Different Outcomes
A single loop calls drink.prepare() on each of the three drinks. Watch how the
same method name lights up a different code path in each subclass — and each
produces its own distinct output.
One line, three behaviours. Python picks the correct prepare() for each drink automatically — the parent's generic version never runs.
Override + Extend — Using super() to Keep Parent Behaviour
Sometimes you don't want to replace the parent's method — you want to extend
it. Call super().method() from within your override to run the parent's version
first, then add your own logic.
class Drink:
def __init__(self, size_ml, base_price):
self.size_ml = size_ml
self.base_price = base_price
def prepare(self):
print(" 1. Warm the cup")
print(" 2. Fill with liquid")
class FlavouredLatte(Drink):
def __init__(self, size_ml, base_price, syrup):
super().__init__(size_ml, base_price)
self.syrup = syrup
def prepare(self):
super().prepare() # run the parent's steps first
print(f" 3. Add {self.syrup} syrup") # then extend
print(" 4. Steam milk and pour")
drink = FlavouredLatte(250, 5.50, syrup="vanilla")
drink.prepare()
Replace — override without calling super(). The parent's
version never runs. Use when the child's behaviour is completely different.
Extend — override and call super().method() inside.
The parent runs, then your addition runs. Use when the child wants "parent's logic + a bit more."
Putting All Three Together — The Complete Coffee Shop
A single hierarchy that uses static variables (shop rules), static methods (utility functions), and method overriding (each drink's unique preparation). This is the real payoff.
class Drink:
# ══ STATIC VARIABLES — shared across every drink and every subclass ══
STANDARD_CUP_ML = 250
MAX_BREW_TEMP_C = 95
TAX_RATE = 0.10
CURRENCY = "$"
def __init__(self, size_ml, base_price):
if not Drink.is_valid_size(size_ml): # uses a STATIC METHOD
raise ValueError(f"{size_ml}ml is out of range")
self.size_ml = size_ml
self.base_price = base_price
# ── Generic prepare — subclasses OVERRIDE this ──
def prepare(self):
print("Generic drink: hot water, done.")
# ── Instance method — uses static variable for its calculation ──
def price_receipt(self):
total = Drink.calculate_total(self.base_price)
return f" Price: {Drink.CURRENCY}{self.base_price:.2f} + tax → {Drink.CURRENCY}{total:.2f}"
# ══ STATIC METHODS — utility functions grouped with the class ══
@staticmethod
def is_valid_size(size_ml):
return 25 <= size_ml <= 500
@staticmethod
def celsius_to_fahrenheit(c):
return c * 9 / 5 + 32
@staticmethod
def calculate_total(price):
return price * (1 + Drink.TAX_RATE)
class Espresso(Drink):
def __init__(self):
super().__init__(size_ml=30, base_price=3.00)
# OVERRIDE — Espresso has its own steps
def prepare(self):
print(f"[Espresso {self.size_ml}ml] grind → tamp → extract for 25s")
class Latte(Drink):
def __init__(self):
super().__init__(size_ml=250, base_price=5.00)
def prepare(self):
temp_f = Drink.celsius_to_fahrenheit(65) # uses a STATIC METHOD
print(f"[Latte {self.size_ml}ml] pull espresso → steam milk to 65°C ({temp_f}°F) → pour art")
class Cappuccino(Drink):
def __init__(self):
super().__init__(size_ml=180, base_price=4.50)
def prepare(self):
print(f"[Cappuccino {self.size_ml}ml] equal parts espresso + steamed milk + foam")
class FlavouredLatte(Latte):
def __init__(self, syrup):
super().__init__()
self.syrup = syrup
self.base_price += 0.75 # flavour surcharge
# OVERRIDE + EXTEND — run parent's Latte prepare, then add syrup step
def prepare(self):
super().prepare()
print(f" → finish with {self.syrup} syrup")
# ── Use the static method BEFORE any drink exists ────────────
print("Valid 150ml cup?", Drink.is_valid_size(150))
print("Valid 900ml cup?", Drink.is_valid_size(900))
print("Brew temp in F:", Drink.celsius_to_fahrenheit(Drink.MAX_BREW_TEMP_C))
print()
# ── Same loop, polymorphic dispatch on prepare() ─────────────
menu = [Espresso(), Latte(), Cappuccino(), FlavouredLatte(syrup="vanilla")]
for drink in menu:
drink.prepare() # dispatched to the right override
print(drink.price_receipt()) # inherited from Drink, uses static var + method
print()
Static variables (TAX_RATE, CURRENCY,
MAX_BREW_TEMP_C) provide the one-truth constants. Static methods
(is_valid_size, celsius_to_fahrenheit, calculate_total)
are pure utilities every drink can call. Method overriding gives each drink
its own prepare(). And FlavouredLatte demonstrates the
"override + extend" pattern via super().prepare().
When to Reach for Each Tool — Decision Grid
self nor cls, but
still belongs conceptually to the class — validators, converters, formatters,
pure calculations. Grouping keeps them discoverable.super().method(...) inside the override to run the parent first, then add
your own steps.list or dict is shared by
every instance. One .append() hits them all. Put mutable per-instance
data in __init__ instead.Common Mistakes (and Fixes)
| Mistake | What Goes Wrong | Fix |
|---|---|---|
Adding self to a static method |
You lose the "no instance needed" property; callers must build an object first | Drop self. Add the @staticmethod decorator on the line above |
Forgetting @staticmethod |
Python treats it as an instance method; first argument silently becomes self |
Add @staticmethod — decorator required |
instance.CONSTANT = value |
Creates a per-instance shadow — the class constant stays unchanged | Never write it. If you must change it, do it through the class: Drink.TAX_RATE = ... |
Using a mutable static variable (orders = []) |
Every instance shares one list; a mutation from any of them leaks to all | Put it in __init__: self.orders = [] |
| Overriding without matching the parent's signature | Callers that used the parent's signature break silently on child instances | Keep the same parameters. To add new ones, give them defaults, or take *args, **kwargs |
Overriding then forgetting super() when you meant to extend |
Parent's setup logic is silently skipped — hard-to-trace bugs | Decide consciously: replace (no super) vs extend (call super) |
| Overriding when a config flag would do | Class hierarchy explodes with one-line subclasses (class RedButton(Button)) |
Use a parameter or class variable in the parent instead of subclassing |
Quick Reference
| Task | Syntax | Notes |
|---|---|---|
| Declare a static variable | TAX_RATE = 0.10 under class line | UPPER_SNAKE_CASE, immutable by convention |
| Read a static variable | Drink.TAX_RATE | Canonical form |
| Update a static variable | Drink.TAX_RATE = 0.12 | Never via an instance |
| Declare a static method | @staticmethod + def fn(x): | Decorator on the line above def |
| Call a static method | Drink.is_valid_size(200) | No instance needed |
| Override a method | Redefine with the same name in the child | Child's version wins over parent's |
| Extend a method | Override + call super().method(...) | Parent runs, then your addition |
| Polymorphic loop | for x in items: x.prepare() | Each object dispatches to its own override |
| Inspect lookup order | Espresso.mro() | First class in the list that defines the method wins |
Golden Rules
UPPER_SNAKE_CASE to signal
"don't reassign."
Drink.TAX_RATE. Writing them via an
instance (d.TAX_RATE = 0.15) does not update the constant — it creates
a silent per-instance shadow.
self nor cls. If
your method needs instance state, it's an instance method. If it needs the class, it's a
class method. Only when it needs neither is it truly static.
@staticmethod on the line above def. Forgetting
the decorator makes Python treat it as an instance method — with silently wrong behaviour on
the first call.
@staticmethod for validators, converters, formatters, and
pure calculations that logically live with the class. If it has nothing to do with
the class, leave it as a module function.
super(). Extending = call
super().method(...) inside your override so the parent's logic still runs.
Never accidentally lose the parent's behaviour.