When to Use Classes vs. Functions in Python: A Design Checklist
A repeatable mental checklist for Python developers to decide when to use classes, functions, instance state, and dependency injection.
You’re coding, momentum is good, then the design questions hit:
- Should this logic be a method or a standalone function?
- Do I put this on
selfor keep it local? - Should I inject this dependency or just create it inside the class?
- What is this class actually responsible for?
- If I add one more
if/else, am I being practical… or building a blob?
This post gives you a repeatable mental checklist with small Python examples you can copy-paste.
A simple rule of thumb you can reuse
Before we go into the 5 questions, keep this framing:
- Function = “Given inputs → return output.” No hidden state.
- Class = “An object with a job.” It holds invariants, state, and dependencies needed to do that job consistently.
self.attribute= “This must live longer than one call” or “Other methods need it.”- Dependency injection = “I want to swap this thing (tests, environments, versions).”
- New class/module = “This is a different job.”
1) Should this logic live inside the class or outside as a function?
Use a method when the logic:
- needs the object’s state (
self.*) - must preserve/validate invariants
- is part of the object’s public API
Use a module-level function when the logic:
- is pure or mostly pure (inputs → output)
- is useful across different classes
- doesn’t need private state
Example: keep pure math outside
from dataclasses import dataclass
def apply_discount(price: float, percent: float) -> float:
if percent < 0 or percent > 100:
raise ValueError("percent must be 0..100")
return price * (1 - percent / 100)
@dataclass
class CartItem:
name: str
price: float
def discounted_price(self, percent: float) -> float:
# method is just a convenience wrapper around pure logic
return apply_discount(self.price, percent)
Why this is nice:
apply_discountis reusable anywhere.CartItem.discounted_price()reads well and stays thin.
Example: put logic in the class when it protects invariants
class BankAccount:
def __init__(self, balance: int = 0):
if balance < 0:
raise ValueError("balance must be >= 0")
self._balance = balance
def withdraw(self, amount: int) -> None:
if amount <= 0:
raise ValueError("amount must be > 0")
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
This belongs in the class because the class is responsible for keeping _balance valid.
Quick test:
If you removed the class and wrote withdraw(balance, amount) -> new_balance, would you lose important rules about the object? If yes, method.
2) When should I store something as self.attribute versus keep it local?
Put it on self when:
- it’s needed across multiple method calls
- it’s needed by multiple methods
- it represents configuration, dependency, or cache
- it helps enforce an invariant
Keep it local when:
- it’s temporary computation
- it’s derivable from other state
- storing it would create state you must keep in sync
Example: configuration belongs on self
class CsvExporter:
def __init__(self, delimiter: str = ","):
self.delimiter = delimiter # configuration: long-lived
def export_row(self, values: list[str]) -> str:
# temporary variable: local
escaped = [v.replace('"', '""') for v in values]
return self.delimiter.join(f'"{v}"' for v in escaped)
Example: don’t store derived values unless you must
class Rectangle:
def __init__(self, w: float, h: float):
self.w = w
self.h = h
def area(self) -> float:
# derived from state; keep local
return self.w * self.h
If you stored self.area = self.w * self.h, you’d now need to keep it updated when w or h changes.
Example: caching is a valid reason to store
class UserProfileService:
def __init__(self, db):
self.db = db
self._cache: dict[int, dict] = {}
def get_user(self, user_id: int) -> dict:
if user_id not in self._cache:
self._cache[user_id] = self.db.fetch_user(user_id)
return self._cache[user_id]
3) When should I inject a dependency instead of creating it inside the class?
Inject when:
- you want easy testing
- you want different implementations (prod vs dev)
- the dependency is expensive or shared
- you want to avoid hard-coded reality inside your class
Create internally when:
- it’s a small, stable detail
- it’s unlikely to be replaced
- you don’t need to fake it in tests
Bad for testability: dependency created inside
import requests
class WeatherClient:
def get_temp(self, city: str) -> float:
resp = requests.get(f"https://example.com/weather?city={city}")
resp.raise_for_status()
return resp.json()["temp"]
Better: inject the HTTP client
class WeatherClient:
def __init__(self, http_get):
self.http_get = http_get # injected dependency
def get_temp(self, city: str) -> float:
resp = self.http_get(f"https://example.com/weather?city={city}")
resp.raise_for_status()
return resp.json()["temp"]
Even cleaner: inject a gateway object
class HttpGateway:
def get_json(self, url: str) -> dict:
import requests
r = requests.get(url)
r.raise_for_status()
return r.json()
class WeatherClient:
def __init__(self, http: HttpGateway):
self.http = http
def get_temp(self, city: str) -> float:
data = self.http.get_json(f"https://example.com/weather?city={city}")
return data["temp"]
4) What exactly should a class be responsible for?
A class should represent one coherent job with:
- clear inputs/outputs
- clear invariants
- a small, understandable API
If you describe the class with “and”, it’s a warning sign.
Example smell: “This class sends emails and formats HTML and retries and logs and reads templates…”
Example: split responsibilities
class EmailFormatter:
def format_welcome(self, name: str) -> str:
return f"Hi {name}, welcome!"
class EmailSender:
def __init__(self, smtp_client):
self.smtp_client = smtp_client
def send(self, to: str, body: str) -> None:
self.smtp_client.send(to=to, body=body)
class WelcomeEmailService:
def __init__(self, formatter: EmailFormatter, sender: EmailSender):
self.formatter = formatter
self.sender = sender
def send_welcome(self, to: str, name: str) -> None:
body = self.formatter.format_welcome(name)
self.sender.send(to, body)
Each piece has a single reason to change.
5) Add an if/else… or extract something new?
Add the branch when:
- the variation is small and stable
- there are only a couple of cases
- the behavior is clearly part of the same job
Extract when:
- you’re adding the 3rd or 4th branch and more are coming
- each branch has meaningful internal complexity
- you keep editing the same large method
- you’re passing flags like
mode,type, orstrategy
Simple if/else is fine
class PriceCalculator:
def total(self, base: float, is_member: bool) -> float:
if is_member:
return base * 0.9
return base
Extract strategy when logic grows
class PricingRule:
def apply(self, base: float) -> float:
raise NotImplementedError
class MemberDiscount(PricingRule):
def apply(self, base: float) -> float:
return base * 0.9
class BlackFriday(PricingRule):
def apply(self, base: float) -> float:
return base * 0.7
class PriceCalculator:
def __init__(self, rule: PricingRule):
self.rule = rule
def total(self, base: float) -> float:
return self.rule.apply(base)
Now adding a new promotion doesn’t require editing PriceCalculator.
Compact decision cheat-sheet
Method vs function:
- Needs object state or invariants → method
- Pure and reusable → function
self.attribute vs local:
- Used across calls or methods → self
- Temporary or derived → local
Inject vs create inside:
- Needs swapping or testing flexibility → inject
- Truly stable internal detail → create
Add branch vs extract:
- Two stable cases → simple if/else
- Growing variation → extract class or strategy
Related posts
-
When to Use Dataclasses, Generators, and Try/Except in Python
Phase 2 of the mental checklist for Python developers. Navigating dictionaries vs dataclasses, eager evaluation vs lazy execution, and error handling philosophies.
-
MoneyPrinterV2: What 18,000 Stars Worth of Automated Content Actually Looks Like
An assembly line for AI content — local LLMs write the script, KittenTTS reads it, Gemini paints the pictures. The video uploads itself.
-
Unleashing the Super Agent Harness: A Deep Dive into Bytedance's DeerFlow
Discover how DeerFlow 2.0 transforms from a deep research tool into a full-fledged agent harness with sandboxing, sub-agents, and persistent memory.
-
OpenBB Explained: The Open Data Platform for Investment Research
A deep dive into OpenBB, the open-source platform that unifies financial data APIs into a single interface for Python developers, analysts, and AI agents.