Shipping fast with AI but don't fully trust the code? I help developers 1:1 turn AI-built apps into something they understand and own. How it works →
In a coaching call this week we discussed a create
classmethod, and someone asked the obvious question: why is that here? It just forwarded its arguments to __init__
. We ended up discussing the difference between instance methods, classmethods, and staticmethods, and how to tell which is which. Here's a simple decision rule.
The decision rule #
Look at what the method actually touches:
Needs the instance(self
) → instance methodNeeds the class(cls
) but not a specific instance →@classmethod
Needs neither→@staticmethod
Nice, but what are some actual use cases? Let's look at the create
method that prompted the question.
The create
method from the call fails the rule above. It took the same arguments as __init__
and passed them straight through. It still adds a nice interface (Class.create(...)
), but it doesn't do any work that the constructor doesn't already do:
@classmethod
def create(cls, amount: Decimal, currency: Currency = Currency.EUR) -> "Expense":
return cls(amount=amount, currency=currency)
When a classmethod earns its place #
A classmethod pulls its weight when it does work the constructor shouldn't, or builds the object from a different starting point. Add a normalization step and the same method suddenly has a job:
@classmethod
def create(cls, amount: Decimal, currency: Currency = Currency.EUR) -> "Expense":
return cls(amount=amount.quantize(Decimal("0.01")), currency=currency)
The canonical use of a @classmethod
is the alternative constructor. Python won't let you overload __init__
, so when you want to build an object several ways, each way becomes a classmethod.
The standard library has rich examples, for example take a look at datetime.date
:
date.today() # from the system clock
date.fromtimestamp(1718539200) # from a POSIX timestamp
date.fromisoformat("2026-06-16") # from an ISO 8601 string
date.fromordinal(739418) # from a proleptic Gregorian ordinal
date.fromisocalendar(2026, 25, 1) # from ISO year/week/day
Source:
@classmethod
def fromtimestamp(cls, t):
"Construct a date from a POSIX timestamp (like time.time())."
if t is None:
raise TypeError("'NoneType' object cannot be interpreted as an integer")
y, m, d, hh, mm, ss, weekday, jday, dst = _time.localtime(t)
return cls(y, m, d)
@classmethod
def today(cls):
"Construct a date from time.time()."
t = _time.time()
return cls.fromtimestamp(t)
...
...
Every one of those returns a date
, but starts from different raw material. They have to be classmethods because they need cls
to construct the instance, and they return cls(...)
which makes it also work with subclasses. For instance, if MyDate
subclasses date
, then MyDate.today()
will return a MyDate
instance, not a date
.
Bonus: I was annoyed that my pysource package didn't work, so I've since patched it, and now you can get to this source code easily with:
uvx --from pybites-pysource pysource -m datetime.date
(I tend to pip this into Vim with | vi -
to read the source code in a scratch buffer.)
You'll see the same pattern across the ecosystem: dict.fromkeys(...)
, int.from_bytes(...)
, and in Pydantic Model.model_validate(...) /
model_validate_json(...)
are all classmethods that build an instance from different raw material.Another classmethod use case is class-level state: registries, caches, counters. A plugin registry is the clean example, because the method reads and mutates state that belongs to the class, not to any instance:
class Handler:
_registry: dict[str, type["Handler"]] = {}
@classmethod
def register(cls, name: str, handler: type["Handler"]) -> None:
cls._registry[name] = handler
@classmethod
def get(cls, name: str) -> type["Handler"]:
return cls._registry[name]
Handler.register("json", JSONHandler)
When it's really a staticmethod #
If the method touches neither self
nor cls
, it's a staticmethod, which is a plain function that happens to live inside the class for namespacing. That's a legitimate choice when the helper is tightly bound to the class and you want Expense.normalize(...)
to read well. It's now part of the class API (it shows up in dir(Expense)
) and can be called without an instance.
Genuine staticmethods are rarer than the other two, which itself tells you something. A clean example is a Color
class with conversion helpers (from a Pybites exercise):
class Color:
def __init__(self, name: str):
self.name = name
self.rgb = COLOR_NAMES.get(name.upper())
@staticmethod
def hex2rgb(hex_value: str) -> tuple[int, int, int]:
return tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5))
@staticmethod
def rgb2hex(rgb: tuple[int, int, int]) -> str:
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
hex2rgb
and rgb2hex
touch neither the instance nor the class. They're pure conversions that live on Color
so Color.hex2rgb("#ff0000")
reads well next to the rest of the API.
But that's exactly the signal worth noticing: a staticmethod might be just a function in disguise, and sometimes the honest move is to pull it out to a module-level function where it's easier to test and use on its own.
Summary #
| Method type | First argument | Access to | Common use case |
|---|---|---|---|
| Instance method | self |
Instance & class state | Modifying object state |
Class method (@classmethod ) |
cls |
Class state only | Alternative constructors, registries |
Static method (@staticmethod ) |
none | Neither | Isolated utility/helper functions |
Why this matters more now #
When you write the code yourself, you rarely add a method without a reason. When an agent writes it, you get plausible-looking structure that nobody chose. A create
classmethod that does nothing, a staticmethod that should be a free function, a helper hanging off the wrong class. That's your judgment call: is this method doing work that belongs to the class, or is it just a pattern the agent learned from other code?
It pays to slow down and look critically at any code and ask those questions. With AI producing more code faster, it's easy to assume that if it looks like Python, it's good Python. But the agent has no taste, and it will happily produce code that is technically correct but structurally wrong.
This is also why I keep writing articles like this one: to give you a simple decision rule you can run in your head during review. It reminds me of Rust, which makes data flow explicit right in the signature with self
, &self
, and &mut self
. The signature tells you what the method touches, same idea as the rule here. (That data-and-behavior split is the whole theme of Why Rust does not need OOP.)
So use AI, but keep developing your knowledge and taste. The more you know, the better you judge the code that comes your way, whether a human or an agent wrote it.
Shipping fast with AI is the easy part. Knowing what to keep, rewrite, and trust is the hard part. I work with developers 1:1 to audit AI-built codebases, trace the real control flow, and make them something you can explain and own, without leaning on a chatbot. How 1:1 coaching works →