Python isn't Java. Functions are first-class citizens, modules behave like singletons, dataclasses give you data containers with three lines, and the PSF tutorial doesn't even introduce classes until chapter 9. So the question every Python developer eventually faces isn't "should I learn OOP?" (the answer's yes — see the absolute beginner's guide). It's "should I use OOP for THIS code?" Jack Diederich's canonical 2012 PyCon talk "Stop Writing Classes" remains the best answer 13 years later: most over-engineered class hierarchies are just functions wearing a costume. This guide turns Diederich's principles into a 4-question decision flowchart and covers function, dataclass, module, class, and full-OOP as five distinct landing places. It's one of 10 explainers in our Python OOP complete guide.

Key Takeaways

  • Python is multi-paradigm — the PSF tutorial introduces functions and modules first, classes only in chapter 9. Mix paradigms freely.
  • Diederich's rule: if your class has only two methods and one is __init__, you probably meant to write a function.
  • 5 landing places: function (no state), dataclass (mostly data), module (singleton), class (state + behavior + multiple instances), full OOP (inheritance + ABC + polymorphism).
  • Four questions decide which — persistent state, complexity, multiple instances, extensibility. The flowchart picks for you.
  • Refactoring is the test. If you can collapse a class to a function or a dataclass without losing clarity, you should.
Decision flowchart: function vs dataclass vs module vs class vs full OOP Should this be a class? — 4 questions, 5 outcomes FROM DIEDERICH'S "STOP WRITING CLASSES" (PYCON 2012) · ANSWER IN ORDER START: NEW CODE 1. Does it hold persistent state? NO → FUNCTION YES 2. Mostly data, little behavior? YES → DATACLASS NO 3. Multiple instances needed? NO → MODULE YES 4. Will it be extended / inherited? NO → CLASS YES → FULL OOP READING THE FLOWCHART Answer the 4 questions in order, follow the arrows, land on one of 5 outcomes — function, dataclass, module, class, or full OOP. Most code lands in the upper-left quadrant. Diederich's point in "Stop Writing Classes": Python developers default to the bottom-right. "Classes must be nouns but not every noun must be a class." — Jack Diederich, PyCon 2012
Four questions, five outcomes. Most code lands at function or dataclass; full OOP is the rarer endpoint.

The Diederich Rule

In "Stop Writing Classes", Jack Diederich (Python core developer) refactored a real production codebase. The starting point: a mail-handling library with 20 classes scattered across 22 modules. After his refactoring pass: 1 class with 15 lines. After his SECOND pass: 1 function with 3 lines. The functional change between the original and final version: zero. The codebase was doing the same work, more clearly, in 5% of the original code.

His central thesis stuck: "Classes must be nouns but not every noun must be a class." The followup rule that comes up constantly: if your class has only two methods and one is __init__, you probably meant to write a function.

The talk is 26 minutes. It's still the canonical reference for this question. The four-question flowchart above is the operational version of his guidance.

Outcome 1: Use a Function

If your code has NO persistent state across calls, a function does the job with the least ceremony.
# OVER-ENGINEERED: a class with no state
class TemperatureConverter:
    def __init__(self):
        pass
    def celsius_to_fahrenheit(self, c):
        return c * 9 / 5 + 32

converter = TemperatureConverter()
result = converter.celsius_to_fahrenheit(100)

# IDIOMATIC: just a function
def celsius_to_fahrenheit(c):
    return c * 9 / 5 + 32

result = celsius_to_fahrenheit(100)
The class version is a function wearing a costume. __init__ does nothing; self is unused. Strip the costume.

Common signs you wrote a function that wants to be a function:
  • Your __init__ is empty or just sets one attribute
  • You have one "real" method besides __init__
  • You only ever instantiate the class to immediately call the method
  • You never use self.something across methods

Outcome 2: Use a Dataclass (or NamedTuple)

If your class is mostly DATA with light behavior, dataclass (Python 3.7+, PEP 557) generates __init__, __repr__, and __eq__ for you. Twenty lines of boilerplate become one decorator.
# OVER-ENGINEERED: 20 lines of boilerplate
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y
    def __hash__(self):
        return hash((self.x, self.y))

# IDIOMATIC: dataclass
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
frozen=True also gives you immutability and a working __hash__. The full @dataclass deep dive covers all the options.

When dataclass fits: 3-6 fields, mostly data access, maybe one or two computed properties. When NamedTuple fits better: immutable, 2-4 fields, want tuple unpacking semantics. When a regular class fits better: rich behavior, complex methods, inheritance trees.

Outcome 3: Use a Module

A Python module IS a singleton. If you'll only ever create ONE instance, module-level functions and variables do the job with zero ceremony.
# OVER-ENGINEERED: a class instantiated once
class Logger:
    def __init__(self):
        self.history = []
    def log(self, msg):
        self.history.append(msg)
        print(msg)

logger = Logger()
logger.log("Starting up")

# IDIOMATIC: module-level state
# In logger.py:
_history = []

def log(msg):
    _history.append(msg)
    print(msg)

# In other files:
from logger import log
log("Starting up")
The underscore prefix on _history signals "internal." Module-level variables persist across calls just like instance attributes do, and they're only created once.

Reach for a class only when you need MULTIPLE instances with separate state. One Logger for the whole app? Module. Multiple UserSession objects with separate per-user state? Class.

Outcome 4: Use a Class

The sweet spot for regular OOP: state and behavior bundle together, AND you need multiple instances, AND the structure is stable (no inheritance hierarchy needed).
class GameSession:
    def __init__(self, player_id):
        self.player_id = player_id
        self.score = 0
        self.lives = 3
        self.level = 1

    def gain_points(self, n):
        self.score += n
        if self.score >= self.level * 1000:
            self.level_up()

    def level_up(self):
        self.level += 1
        self.lives += 1

    def lose_life(self):
        self.lives -= 1
        return self.lives > 0   # True if still alive

# Create distinct sessions per player
alice_session = GameSession("alice")
bob_session = GameSession("bob")
alice_session.gain_points(500)   # Alice's score only
State (score, lives, level) and behavior (gain_points, level_up, lose_life) are tightly coupled. Each player needs distinct state. The class fits.

Outcome 5: Use Full OOP (Inheritance, ABC, Polymorphism)

The deepest endpoint — needed when you're building something extensible: a framework, a plugin system, or a library where users will subclass your code.
from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def charge(self, amount: float) -> bool:
        ...

    @abstractmethod
    def refund(self, transaction_id: str) -> bool:
        ...

class StripeProcessor(PaymentProcessor):
    def charge(self, amount):
        # Stripe-specific implementation
        ...
    def refund(self, transaction_id):
        ...

class PayPalProcessor(PaymentProcessor):
    def charge(self, amount):
        # PayPal-specific implementation
        ...
    def refund(self, transaction_id):
        ...

def checkout(processor: PaymentProcessor, amount: float):
    if processor.charge(amount):
        send_receipt()
The abstract base class defines the contract; concrete classes implement it; the checkout function works with any processor via polymorphism. See the dedicated guides on ABC and Protocols and super().__init__() and MRO.

The Refactoring Test

The Diederich talk's most useful debugging technique: when you have a class, ASK if it can collapse. Concretely:
  1. Could this be a function? Try writing it as one. If the function version is shorter and clearer, the class was overkill.
  2. Could this be a dataclass? If your class is 80% data access, swap to @dataclass and see.
  3. Could this be a module? If you only ever create one instance, the class is a glorified namespace.
  4. Could this lose a few methods? Often half your methods could be functions taking the data as an argument.
If the answer is yes to any of these, refactor. Code that reads better with fewer abstractions usually IS better.When to Use OOP in Python (and When to Skip It): A Decision Framework Backed by "Stop Writing Classes" - 1

Decision Examples

Worked decisions for common Python tasks:
TaskRight toolReason
Calculate tax on a priceFunctionNo persistent state
Represent a 3D point (x, y, z)Dataclass (frozen)Pure data + equality
App-wide configurationModuleSingleton, one instance
Database connection (shared)Module + get_connection()Singleton, lazy init
Per-user shopping cartClassState + behavior, multiple instances
Form validators (plugin system)ABC + concrete classesExtensible by users
HTTP request handlerFunction (in Flask/FastAPI)Stateless, framework handles routing
State machine (open/closed/paid)Class with enumState transitions matter
Iterator that yields lazilyGenerator functionStateful but cleaner with yield
API client library you publishClass (extensible)Users will subclass to customize

The Anti-Pattern Catalog

Six patterns that signal "you wanted a function":

1. The Empty __init__

class Calculator:
    def __init__(self):
        pass
    def add(self, a, b):
        return a + b
If __init__ does nothing, the class is doing nothing. Write a function.

2. The One-Method Class

class CsvLoader:
    def __init__(self, path):
        self.path = path
    def load(self):
        with open(self.path) as f:
            return f.readlines()

# Usage:
data = CsvLoader("file.csv").load()
The class exists only to immediately call .load(). load_csv("file.csv") is shorter and clearer.

3. The Get/Set Class

class User:
    def __init__(self, name, email):
        self._name = name
        self._email = email
    def get_name(self): return self._name
    def set_name(self, name): self._name = name
    def get_email(self): return self._email
    def set_email(self, email): self._email = email
This is a dataclass with extra steps. Use @dataclass — or just use a regular class with public attributes (Python isn't Java).

4. The Module-as-Class

class StringUtils:
    @staticmethod
    def capitalize_first(s):
        return s[0].upper() + s[1:]

    @staticmethod
    def reverse(s):
        return s[::-1]

# Usage:
StringUtils.reverse("hello")
This is just a module. Put the functions at module level in string_utils.py and import them.

5. The Class as Namespace

class Constants:
    PI = 3.14159
    E = 2.71828
    GOLDEN = 1.61803
Modules already are namespaces. Put these at module level in constants.py.

6. Inheritance Without Polymorphism

class AnimalBase:
    def __init__(self, name):
        self.name = name

class Dog(AnimalBase):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

class Cat(AnimalBase):
    def __init__(self, name, color):
        super().__init__(name)
        self.color = color

# But you never use polymorphism. You always know it's a Dog or a Cat.
If you never write a function that takes AnimalBase and works with any subclass, the inheritance hierarchy was decorative. Use @dataclass for both. See composition vs inheritance.

What Python Specifically Makes Easier

Python's features that bias the decision FROM "always use OOP" toward "use the simplest tool":
  • First-class functions. You can pass functions as arguments, return them, store them in lists. Many languages need classes for this; Python doesn't.
  • Module-level state. Modules give you singleton-like behavior with less ceremony than a Singleton pattern.
  • Comprehensions. List, dict, set comprehensions handle most data transformations without iterator classes.
  • Generators. yield creates stateful iterators without writing iterator classes.
  • Decorators. Modify function behavior without inheritance trees.
  • Dataclasses + Protocols. Get data containers and duck typing without the full Java-style ceremony.
None of this means "never use classes." It means: classes are one tool among many in Python, not the default tool.

Frequently Asked Questions

When should I use OOP in Python?

Use OOP when state and behavior naturally bundle together AND you'll create multiple instances of the same shape. Specific situations: entities with both data and methods (a Player class with health, position, and move methods), multiple variants sharing structure (User, AdminUser, GuestUser), extensible frameworks (a plugin system), and long-lived stateful objects (a database connection pool). Skip OOP when there's no persistent state (use a function), when it's just a data container (use dataclass or NamedTuple), or when only one instance ever exists (use a module). Jack Diederich's canonical 'Stop Writing Classes' PyCon 2012 talk gives the rule of thumb: if your class has only two methods and one is __init__, you probably meant to write a function.

What is Jack Diederich's 'Stop Writing Classes' rule?

Jack Diederich's PyCon 2012 talk argues that Python developers over-use classes. His central rule: "Classes must be nouns but not every noun must be a class." If your class has only two methods and one is __init__, you probably meant to write a function. The talk includes a real refactoring of MuffinMail's API code that went from 20 classes scattered across 22 modules down to 1 class in 15 lines, then to 1 function in 3 lines. The pattern: most over-engineered class hierarchies are just functions wearing a costume.

When should I use a dataclass instead of a regular class?

Use dataclass (PEP 557, available since Python 3.7) when your class is mostly data with little behavior — typically 3-6 fields with maybe one or two computed properties. The dataclass decorator generates __init__, __repr__, and __eq__ for you, eliminating ~20 lines of boilerplate. The threshold question: "Could I just use a namedtuple, but I need mutability or a few methods?" That's a dataclass. If your class has rich behavior beyond data access — multiple complex methods, inheritance trees, polymorphism — use a regular class. Dataclass is OOP-lite, perfect for data records that need to be objects but don't need the full ceremony.

What's the difference between a module and a class with one instance?

A module IS a singleton in Python. If you're creating a class that you'll only ever instantiate once (a configuration manager, a logging system, an API client), a module with module-level functions and module-level variables does the same job with less code. The "Stop Writing Classes" talk uses this example: a Logger class that was used as Logger().log(msg) everywhere became log(msg) at module level, removing all the ceremony. Modules are lazy-initialized on first import, support cross-call state via module-level variables, and don't need a self argument on every function. Reach for a class when you genuinely need multiple instances with separate state.

Is Python a purely OOP language?

No. Python is multi-paradigm. Functions are first-class objects (you can pass them around, return them, store them in lists), comprehensions are functional in spirit, and the standard library is full of module-level functions. Unlike Java where every line of code must live inside a class, Python lets you mix paradigms freely. The PSF tutorial doesn't even introduce classes until chapter 9 — the first 8 chapters cover data types, control flow, functions, and modules. Pythonic code uses OOP where it fits and skips it where it doesn't, often within the same file.

The Bottom Line: Pick the Simplest Tool That Works

Python gives you five landing places: function, dataclass, module, class, full OOP. Most code belongs in the upper-left of that range. Diederich's 2012 PyCon talk made the case that Python developers default to the lower-right and over-engineer. The four-question flowchart keeps you honest. The refactoring test — "could this be a function?" — is your reality check. Pick the simplest tool that works; collapse back to it whenever you can. The next entry in this series covers staticmethod vs classmethod vs instance method for when you've committed to a class and need to choose method types. Or browse the full Python OOP guide.

Build OOP Judgment Through Real Practice

CodeGym's Python track teaches you to make the function-vs-class call through 800+ hands-on tasks structured to surface the trade-off. Each task is scoped so you experience why simpler abstractions usually win, before climbing to inheritance, ABC, and metaclasses. AI validators grade your solutions; AI mentors explain when you over-engineered. First level free; full plan on the pricing page.