Difference Between Method and Function in Python (With Practical Examples)

I still see experienced Python developers trip over the same moment: you refactor a helper into a class, you call it the “same way as before”, and suddenly you get a TypeError about missing arguments—or worse, your code runs but the behavior subtly changes.\n\nThat confusion is normal because “method” and “function” feel almost identical from a distance: both are callable blocks of code, both can take arguments, both can return values, and both can be passed around. The difference only becomes obvious when you pay attention to binding: where the callable “lives”, and what Python automatically supplies when you call it.\n\nWhen you understand binding, the rest follows: why self exists, why Invoice.total() isn’t the same thing as invoice.total(), why @staticmethod is rarely the best choice, and why “method vs function” is less about syntax and more about how attribute access changes a callable at runtime.\n\nHere’s how I think about the difference method function python developers actually care about: it affects your APIs, your tests, your readability, and your ability to reason about state.\n\n## A Quick Mental Model: Action vs Action-With-An-Address\nA plain function is an action with no built-in “home address”. You call it directly by its name (or by a variable pointing to it), and every input it needs must be provided explicitly.\n\nA method is an action that is accessed through an object (or a class). That access step matters: Python can attach context to the callable, most notably the instance (self) for instance methods.\n\nA simple analogy I use when teaching this:\n\n- A function is like a phone number written on a sticky note. You can call it from anywhere, but you have to say who you are and what you want.\n- A method is like a “Call Support” button inside an app. Pressing the button already knows which account you’re using and can include that context automatically.\n\nIn Python terms, the “app context” is the object you accessed the method from.\n\nHere’s a quick comparison table you can keep in your head:\n\n

Topic

Function

Method

\n

\n

Where it’s defined

Module scope (commonly), or nested

Inside a class body (commonly), or attached to an object

\n

How you call it

name(args)

obj.name(args) or Class.name(args)

\n

Implicit first argument

None

Often yes: self (instance) or cls (class)

\n

Relationship to state

Typically stateless by design

Often reads/writes instance/class state

\n

What Python does on access

Nothing special

May bind the function to an object

\n\nThat last row is the key: methods start as functions, and become methods when accessed as attributes.\n\n## What Python Actually Does: Functions, Descriptors, and Binding\nIf you want the real explanation (the one that makes bugs stop happening), it’s this:\n\n- A def at module level creates a function object.\n- A def inside a class body also creates a function object, but it’s stored on the class.\n- When you access that function through an instance, Python applies the descriptor protocol (get) and returns a bound method (roughly: “function + instance glued together”).\n\nYou can see the difference with a tiny, runnable snippet:\n\n import inspect\n\n class ReportRenderer:\n def render(self, title: str) -> str:\n return f‘Report: {title}‘\n\n renderer = ReportRenderer()\n\n print(‘Access via class:‘, ReportRenderer.render)\n print(‘Access via instance:‘, renderer.render)\n\n print(‘Is class attribute a function?‘, inspect.isfunction(ReportRenderer.render))\n print(‘Is instance attribute a function?‘, inspect.isfunction(renderer.render))\n\n print(‘Is instance attribute a method?‘, inspect.ismethod(renderer.render))\n\nTypical output (exact formatting varies by Python version):\n\n- ReportRenderer.render looks like a plain function object.\n- renderer.render looks like a bound method.\n\nWhat changes is not the source code of render, but how you retrieved it.\n\nThis is why these two calls are equivalent:\n\n class ReportRenderer:\n def render(self, title: str) -> str:\n return f‘Report: {title}‘\n\n renderer = ReportRenderer()\n\n print(renderer.render(‘Q4 Revenue‘))\n print(ReportRenderer.render(renderer, ‘Q4 Revenue‘))\n\nWhen you call renderer.render(‘Q4 Revenue‘), Python is effectively doing “bind the function to renderer, then call it”.\n\nOne more point that saves time in reviews: self is not a keyword. It’s a convention.\n\n class UserSession:\n def whoami(session) -> str: # works, but please don‘t do this\n return f‘Session object id={id(session)}‘\n\nYou can name it session, but if you do, you’re making every future reader’s life harder.\n\n### A Deeper Look at “Binding” (The Part That Explains Weird Bugs)\nWhen I say “binding”, I mean that Python turns an attribute lookup into a callable that already knows its first argument. That’s why you sometimes see TypeError: missing 1 required positional argument when you call something “the same way as before”.\n\nThere are three objects worth recognizing when you’re debugging:\n\n1) The function stored on the class\n2) The bound method you get from an instance\n3) The instance itself\n\nHere’s a small introspection snippet that makes the “glue” visible:\n\n class Demo:\n def f(self, x: int) -> int:\n return x + 1\n\n d = Demo()\n\n bound = d.f\n print(‘bound:‘, bound)\n print(‘bound.self:‘, bound.self) # the instance\n print(‘bound.func:‘, bound.func) # the original function on the class\n\nTwo practical takeaways I use constantly:\n\n- If I ever need “the underlying function” (for decorators, adapters, or advanced mocking), I can reach for bound.func.\n- If I’m ever confused about “what object will be passed as self”, I can inspect bound.self.\n\n### Why This Matters Outside of Trivia\nIn production code, binding affects:\n\n- How you design APIs (do callers need an instance?)\n- How you test (are you patching the class function or the bound method?)\n- How you pass callables around (callbacks, hooks, event handlers)\n- How you refactor safely (function to method, method to function)\n\nIf you internalize “attribute access can change the callable”, you stop being surprised by all of those.\n\n## Instance Methods: Where self Becomes a Design Tool\nI recommend instance methods when the behavior is naturally tied to a specific piece of state.\n\nHere’s a practical example: invoice totals. A function can do it, but a method usually reads better because the data and behavior stay together.\n\n from dataclasses import dataclass\n\n @dataclass(frozen=True)\n class LineItem:\n sku: str\n unitpricecents: int\n quantity: int\n\n @dataclass\n class Invoice:\n invoiceid: str\n items: list[LineItem]\n taxrate: float # e.g. 0.0825\n\n def subtotalcents(self) -> int:\n return sum(item.unitpricecents item.quantity for item in self.items)\n\n def totalcents(self) -> int:\n subtotal = self.subtotalcents()\n tax = int(round(subtotal self.taxrate))\n return subtotal + tax\n\n invoice = Invoice(\n invoiceid=‘INV-2026-0007‘,\n items=[LineItem(‘USB-C-CABLE‘, 1299, 2), LineItem(‘LAPTOP-STAND‘, 4999, 1)],\n taxrate=0.0825,\n )\n\n print(invoice.subtotalcents())\n print(invoice.totalcents())\n\nWhy a method here?\n\n- totalcents() depends on self.items and self.taxrate.\n- The call site reads like a sentence: invoice.totalcents().\n- It becomes hard to accidentally “mix” an invoice with another invoice’s tax rate.\n\nA mistake I often see is people forcing everything into methods “because OOP”, then ending up with classes that just hold unrelated utilities.\n\nIf the behavior doesn’t need instance state, don’t pretend it does. Methods are for behavior that belongs to an object.\n\nOne subtle but important design point: Python doesn’t enforce encapsulation the way some languages try to. You still can access invoice.items directly. In practice, I treat methods as a communication tool: they tell the reader which operations are safe and intended.\n\n### Instance Methods and Invariants (The Real Payoff)\nThe biggest practical advantage of instance methods isn’t “OOP purity”. It’s that methods give you a natural home for enforcing invariants.\n\nFor example, imagine your invoice must never have negative quantities, and tax rate must be within a range. You can enforce that once, at the boundary, and keep the rest of the code simpler.\n\n from dataclasses import dataclass\n\n @dataclass\n class Invoice:\n items: list[LineItem]\n taxrate: float\n\n def postinit(self) -> None:\n if not (0.0 <= self.taxrate <= 0.25):\n raise ValueError(f'Unexpected taxrate: {self.taxrate}‘)\n for item in self.items:\n if item.quantity < 0:\n raise ValueError(f'Negative quantity for {item.sku}')\n\n def additem(self, item: LineItem) -> None:\n # Keeping mutation behind a method makes intent explicit.\n self.items.append(item)\n\nNow, callers can still mutate items directly, but your intended API is clear: use additem. In real teams, that clarity reduces accidental misuse.\n\n### A Common Refactor Trap: Helper Function → Method\nHere’s the exact “missing argument” bug I see all the time:\n\n1) You start with a function:\n\n def normalizeemail(email: str) -> str:\n return email.strip().lower()\n\n2) Later you move it into a class but keep calling it like a free function:\n\n class UserService:\n def normalizeemail(self, email: str) -> str:\n return email.strip().lower()\n\n normalizeemail(‘[email protected]‘) # NameError or wrong call site\n\n3) Or you call it on the class:\n\n UserService.normalizeemail(‘[email protected]‘) # TypeError: missing self\n\nThe fix depends on what you actually want:\n\n- If normalization is pure and doesn’t need self, keep it as a module-level function.\n- If it’s part of a service and you want to inject config, then call it on an instance: service.normalizeemail(...).\n- If it’s “logically attached to the class”, consider @staticmethod or @classmethod—but do that because it improves the API, not because it avoids passing self.\n\n## Functions: Small, Testable Building Blocks (Especially for I/O Boundaries)\nFunctions shine when you want a stateless transformation, a pure calculation, or a clean boundary around side effects.\n\nA classic case: formatting and parsing. You can attach these to a class, but a function often produces a simpler API.\n\n def formatmoneycents(amountcents: int, currency: str = ‘USD‘) -> str:\n if currency != ‘USD‘:\n raise ValueError(f‘Unsupported currency: {currency}‘)\n\n dollars = amountcents // 100\n cents = amountcents % 100\n return f‘${dollars:,}.{cents:02d}‘\n\n print(formatmoneycents(1299))\n print(formatmoneycents(104250))\n\nAnother case where functions are the right default: callbacks.\n\n from collections.abc import Callable\n\n def retry(operation: Callable[[], str], attempts: int = 3) -> str:\n lasterror: Exception

None = None\n\n for in range(attempts):\n try:\n return operation()\n except Exception as exc:\n lasterror = exc\n\n assert lasterror is not None\n raise lasterror\n\n\n def fetchstatustext() -> str:\n # Pretend this hits a network or file system.\n return ‘OK‘\n\n print(retry(fetchstatustext, attempts=2))\n\nYou can pass a bound method as operation too:\n\n class StatusClient:\n def fetch(self) -> str:\n return ‘OK‘\n\n client = StatusClient()\n print(retry(client.fetch, attempts=2))\n\nThis is one of the reasons I’m careful about claiming “functions are separate from OOP” in Python. In real code, functions and methods mix constantly because both are just callables.\n\nTwo more function capabilities that matter in modern Python:\n\n1) Closures (functions that capture variables):\n\n def makeprefixer(prefix: str):\n def addprefix(text: str) -> str:\n return f‘{prefix}{text}‘\n return addprefix\n\n warn = makeprefixer(‘WARN: ‘)\n info = makeprefixer(‘INFO: ‘)\n\n print(warn(‘disk almost full‘))\n print(info(‘job finished‘))\n\n2) Top-level functions as stable module APIs. In 2026, with strong tooling (type checkers, fast linters, good IDE navigation), a module of well-named functions is often easier to maintain than a hierarchy of “utility classes”.\n\n### A Practical “Default”: Prefer Functions Until State Shows Up\nMy personal heuristic is boring but reliable:\n\n- If the operation is a pure transformation (input → output), start with a function.\n- If you later discover shared configuration, caching, or stateful coordination, wrap that in a class and turn the operation into a method.\n\nThat order tends to keep code simple early, while still giving you an escape hatch for complexity later.\n\n### Functions at the Boundary: I/O, Parsing, Serialization\nA place where functions are especially effective is at boundaries: parsing text, building dicts for JSON, validating inputs, normalizing data, converting API responses into domain objects.\n\nThose tasks benefit from being:\n\n- easy to test without setup\n- easy to reuse across multiple services\n- decoupled from object lifetimes\n\nFor example, I like parsing helpers that return explicit results (or raise explicit errors) rather than mutating objects.\n\n from datetime import datetime\n\n def parseutciso8601(ts: str) -> datetime:\n # Minimal example; real parsing rules can be stricter.\n if not ts.endswith(‘Z‘):\n raise ValueError(f‘Expected Zulu time, got: {ts}‘)\n return datetime.fromisoformat(ts.removesuffix(‘Z‘))\n\nIf you later want to store parsing options (tolerant vs strict), you can introduce a class. But until then, a function is a clean tool.\n\n## @classmethod and @staticmethod: What They Are (and What I Avoid)\nIf you only remember one thing: @staticmethod is not “more modern”. It’s just “no automatic binding”.\n\n### @classmethod\nA class method receives the class as the first argument, conventionally named cls. I like class methods for alternate constructors and for behavior that conceptually belongs to the class as a whole.\n\nHere’s a more complete (and less misleading) token example than the usual toy snippets. This version actually uses ttlseconds, keeps timezone handling consistent, and demonstrates why cls(...) matters for subclassing.\n\n from dataclasses import dataclass\n from datetime import datetime, timedelta, timezone\n\n @dataclass(frozen=True)\n class ApiToken:\n value: str\n expiresat: datetime\n\n @classmethod\n def fromttlseconds(cls, value: str, ttlseconds: int) -> ‘ApiToken‘:\n if ttlseconds <= 0:\n raise ValueError('ttlseconds must be positive‘)\n now = datetime.now(timezone.utc).replace(microsecond=0)\n return cls(value=value, expiresat=now + timedelta(seconds=ttlseconds))\n\n def isexpired(self, now: datetime

None = None) -> bool:\n now = now or datetime.now(timezone.utc)\n return now >= self.expiresat\n\n token = ApiToken.fromttlseconds(‘abc‘, 60)\n print(token)\n\nThe key point: cls(...) lets subclasses inherit the constructor behavior naturally.\n\nIf you later do this:\n\n @dataclass(frozen=True)\n class ScopedToken(ApiToken):\n scope: str = ‘read‘\n\n scoped = ScopedToken.fromttlseconds(‘def‘, 60)\n print(type(scoped), scoped.scope)\n\nfromttlseconds will produce a ScopedToken because it uses cls, not ApiToken. That’s the difference between “an alternate constructor” and “a helper function stuffed into a class.”\n\n### @staticmethod\nA static method receives no automatic self or cls. It lives in the class namespace mostly for namespacing.\n\n class PasswordRules:\n @staticmethod\n def isstrong(password: str) -> bool:\n return (\n len(password) >= 12\n and any(ch.islower() for ch in password)\n and any(ch.isupper() for ch in password)\n and any(ch.isdigit() for ch in password)\n )\n\n print(PasswordRules.isstrong(‘Short7‘))\n print(PasswordRules.isstrong(‘BetterPassword2026‘))\n\nI avoid @staticmethod when a module-level function would be clearer:\n\n- If the function doesn’t need class state, putting it in a class often adds ceremony.\n- Testing a module-level function is straightforward.\n- Discoverability is good with modern IDEs, ripgrep, and type-aware search.\n\nWhen I do use @staticmethod, it’s usually because:\n\n- I want a protocol-like “shape” for a class API.\n- I’m implementing a pluggable strategy where keeping related callables under one class name helps readability.\n\n### The “Namespacing” Argument: When It’s Real and When It’s Wishful\nPeople often justify @staticmethod with “it keeps things organized.” Sometimes that’s true. Often it’s not.\n\nIf you want namespacing without confusing method semantics, you have options:\n\n- Put functions in a module: passwords.isstrong(...)\n- Put functions in a small namespace object: class passwords: ... (rare, but occasionally fine)\n- Use a dataclass/config object plus functions: keep configuration explicit\n\nI treat @staticmethod as a deliberate design choice, not a default.\n\n## Built-in Methods vs Built-in Functions: Same Words, Different Shapes\nPython’s standard library adds a twist: some things are “built-in functions” (like sum) and some are “methods on objects” (like list.append). You’ll also see “module functions” (like math.ceil).\n\nThey all behave similarly at the call site, but the ownership model differs:\n\n- sum([1, 2, 3]) is a function call.\n- [1, 2, 3].append(4) is a method call.\n- math.ceil(15.25) is a function from a module.\n\nHere’s a runnable snippet that shows the difference in the way I explain it to teammates:\n\n import math\n\n numbers = [5, 15, 2]\n\n print(‘sum(numbers) ->‘, sum(numbers))\n\n numbers.append(99)\n print(‘numbers after append ->‘, numbers)\n\n print(‘math.ceil(15.25) ->‘, math.ceil(15.25))\n\nOne detail that matters for debugging: many built-in methods are implemented in C. They can have different introspection behavior than pure Python functions. For example, you might not get a full Python signature from inspect.signature in every case, depending on the object.\n\nThat’s not a “method vs function” problem so much as a “CPython implementation detail” problem, but it shows up when you’re generating docs, building wrappers, or writing tooling.\n\nPerformance-wise, the difference between calling a function and a bound method is usually small compared to real workloads. The binding step (attribute lookup + method object creation) is typically sub-microsecond. If you’re worried about it, you’re probably in a tight loop where algorithm choice or data layout matters more.\n\n## Common Mistakes I See in Code Reviews (and How to Fix Them)\nThese show up in real teams, even strong ones.\n\n### 1) Forgetting self in instance methods\n\n class EmailSender:\n def send(toaddress: str, subject: str) -> None:\n print(f‘Sending {subject} to {toaddress}‘)\n\nThis compiles, but calling it as EmailSender().send(‘[email protected]‘, ‘Hi‘) will treat the instance as toaddress and shift everything.\n\nFix:\n\n class EmailSender:\n def send(self, toaddress: str, subject: str) -> None:\n print(f‘Sending {subject} to {toaddress}‘)\n\n### 2) Calling a method on the class without passing an instance\n\n class AuditLog:\n def record(self, event: str) -> None:\n print(f‘AUDIT: {event}‘)\n\n AuditLog.record(‘usersignedin‘)\n\nThat raises a missing argument error because self isn’t supplied.\n\nFix: create an instance, or make the API a function/classmethod if you truly don’t need instances.\n\n### 3) Using @staticmethod as a “default”\nIf your method never touches self and never touches cls, I recommend asking a blunt question: “Why is this in a class at all?”\n\nOften the cleanest fix is to move it to a module-level function:\n\n def isstrongpassword(password: str) -> bool:\n return (\n len(password) >= 12\n and any(ch.islower() for ch in password)\n and any(ch.isupper() for ch in password)\n and any(ch.isdigit() for ch in password)\n )\n\n### 4) Treating methods as “not first-class”\nIn Python, you can pass methods around. If you see code manually wrapping a method in a lambda “just because it’s a method”, that’s a smell.\n\nGood:\n\n class HealthCheck:\n def run(self) -> str:\n return ‘OK‘\n\n check = HealthCheck()\n handler = check.run\n print(handler())\n\n### 5) Confusing instance state with class state\nI see this bite people when they put mutable defaults on the class.\n\n class Cache:\n entries = {} # shared across all instances\n\n def set(self, key: str, value: str) -> None:\n self.entries[key] = value\n\nNow every instance shares entries. Sometimes that’s intended, often it’s not.\n\nFix: keep per-instance state on self:\n\n class Cache:\n def init(self) -> None:\n self.entries: dict[str, str] = {}\n\n def set(self, key: str, value: str) -> None:\n self.entries[key] = value\n\n### 6) Accidentally Storing a Bound Method Instead of Calling It\nThis one is subtle because it doesn’t always crash right away.\n\n class Clock:\n def now(self) -> int:\n return 123\n\n clock = Clock()\n\n current = clock.now # forgot parentheses\n # current is a bound method, not an int\n\nIf you later do math with current, you’ll get confusing errors. The fix is obvious—call it—but the prevention is better: strong typing and descriptive names help (nowfn vs now).\n\n### 7) Decorating Methods Like Functions Without Thinking About self\nA decorator that works on free functions might not behave the way you expect on methods if it doesn’t preserve the signature or if it changes binding behavior.\n\nFor example, this naive decorator breaks introspection and can make debugging harder:\n\n def loud(fn):\n def wrapper(args, kwargs):\n print(‘calling‘, fn.name)\n return fn(args, kwargs)\n return wrapper\n\nBetter: use functools.wraps so tooling remains sane.\n\n import functools\n\n def loud(fn):\n @functools.wraps(fn)\n def wrapper(args, kwargs):\n print(‘calling‘, fn.name)\n return fn(args, kwargs)\n return wrapper\n\nThis applies to both functions and methods, but it becomes especially important on methods because you’ll often inspect them (help(), IDE tooltips, doc generation).\n\n## Choosing Between Them in Real Code (2026 Patterns I Actually Use)\nWhen you’re deciding “method or function”, I recommend thinking in APIs, not syntax.\n\nHere’s how I choose in practice, with rules that hold up under refactors.\n\n### 1) If It Needs Instance Data, Make It an Instance Method\nIf the behavior depends on self (current configuration, cached data, open connections, accumulated state), it belongs as an instance method.\n\nSignals I look for:\n\n- You have to pass the same object into the function repeatedly\n- You’re threading a config value through multiple call sites\n- The operation reads more naturally as thing.do() than do(thing)\n\nExample: a client that stores an API base URL and auth token.\n\n class ApiClient:\n def init(self, baseurl: str, token: str) -> None:\n self.baseurl = baseurl\n self.token = token\n\n def buildheaders(self) -> dict[str, str]:\n return {‘Authorization‘: f‘Bearer {self.token}‘}\n\n def url(self, path: str) -> str:\n return self.baseurl.rstrip(‘/‘) + ‘/‘ + path.lstrip(‘/‘)\n\nHere, making url a function would force every caller to keep passing baseurl. That’s noise.\n\n### 2) If It’s Pure, Start as a Function\nIf it’s deterministic and depends only on its inputs, a function is usually the cleanest expression.\n\nWhy I like functions for pure logic:\n\n- trivial to test\n- easy to reuse\n- no hidden state\n- works equally well from scripts, libraries, and notebooks\n\n### 3) If It’s “About the Type”, Use @classmethod (Often as an Alternate Constructor)\nIf the operation’s meaning is tied to the class itself rather than any specific instance, consider a class method.\n\nA common pattern I use: parse/validate constructors.\n\n from dataclasses import dataclass\n\n @dataclass(frozen=True)\n class EmailAddress:\n value: str\n\n @classmethod\n def parse(cls, raw: str) -> ‘EmailAddress‘:\n raw = raw.strip()\n if ‘@‘ not in raw:\n raise ValueError(f‘Invalid email: {raw}‘)\n return cls(raw.lower())\n\n email = EmailAddress.parse(‘ [email protected] ‘)\n print(email.value)\n\nThis reads well, it’s discoverable, and it supports subclassing if you ever extend the type.\n\n### 4) If It’s Only Namespacing, Prefer a Module (Over @staticmethod)\nIf you’re about to write @staticmethod purely to keep code “organized”, pause and consider a module instead.\n\nWhat I mean by that: passwordrules.py with isstrong(...), estimateentropy(...), mask(...) often reads better than class PasswordRules: with a pile of static methods.\n\nA module is a namespace too, and it avoids method semantics entirely.\n\n### 5) If You’re Building an Extensible Interface, Methods Help\nWhen you want polymorphism (“any object with a run() method can be used here”), methods provide a straightforward interface.\n\nThis is where methods shine even when they don’t strictly need state: they define a shape.\n\n from typing import Protocol\n\n class Check(Protocol):\n def run(self) -> str: …\n\n def runall(checks: list[Check]) -> list[str]:\n return [c.run() for c in checks]\n\nNow you can pass any object with a .run() method. The method is part of the contract.\n\n## Edge Cases That Make the Difference Feel “Real”\nIf you’ve only worked with straightforward classes, “method vs function” can still feel academic. These edge cases are where the concept becomes practical.\n\n### 1) Functions Attached to Instances (Dynamic Methods)\nIn Python, you can attach a function to an instance at runtime. Whether it becomes a method depends on how you attach it.\n\n import types\n\n class Greeter:\n def init(self, name: str) -> None:\n self.name = name\n\n def sayhi(self) -> str:\n return f‘hi from {self.name}‘\n\n g = Greeter(‘Ava‘)\n\n g.sayhi = types.MethodType(sayhi, g)\n print(g.sayhi())\n\nIf you instead did g.sayhi = sayhi, you’d store a plain function on the instance, and calling it would not automatically pass self. That’s another way to see that “method-ness” is about binding behavior, not about the mere existence of def.\n\n### 2) Monkey Patching and Testing: Patch the Right Layer\nWhen you patch a method in tests, you must be clear about whether you’re patching:\n\n- the function on the class, or\n- the bound method on an instance\n\nIn general, patching the class attribute is more reliable because all instances will see it. Patching a single instance is narrower but can be useful for targeted tests.\n\nThis isn’t about a specific testing framework; it’s about understanding where the callable is stored (class vs instance) and how binding happens when accessed.\n\n### 3) @property: It’s Not a Method Call at the Call Site\nProperties are descriptors too, but they feel different to use.\n\n class Temperature:\n def init(self, celsius: float) -> None:\n self.c = celsius\n\n @property\n def fahrenheit(self) -> float:\n return self.c 9 / 5 + 32\n\n t = Temperature(0)\n print(t.fahrenheit) # not t.fahrenheit()\n\nI bring this up because people sometimes ask “is a property a method or a function?”\n\n- Implementation-wise, fahrenheit is defined with def inside a class, so it starts as a function.\n- Access-wise, it’s a descriptor that returns a value, not a bound method.\n- API-wise, it behaves like an attribute.\n\nSo the key idea still holds: attribute access changes what you get back.\n\n### 4) Dunder Methods: “Everything Is a Method Call” (Sometimes)\nPython’s operator syntax is powered by special methods. When you do a + b, you’re really doing a.add(b) (conceptually). Those are methods, and binding works the same way, but you rarely call them directly.\n\nUnderstanding that special methods are just methods can help when you design your own types and want your objects to feel natural in Python code.\n\n## Performance Considerations (Useful, but Rarely the Deciding Factor)\nPeople occasionally ask whether functions are “faster” than methods. The honest answer is: it depends, and it usually doesn’t matter.\n\nWhat’s actually different:\n\n- A function call like f(x) needs a name lookup for f (unless cached in a local variable).\n- A method call like obj.f(x) needs attribute lookup, descriptor binding, and then a call.\n\nIn practice:\n\n- The overhead difference is often in the “tiny fractions of a microsecond” range per call.\n- In real apps, network I/O, disk I/O, database work, JSON parsing, and algorithmic complexity dominate.\n\nIf you truly have a tight inner loop, a micro-optimization I sometimes use is caching the bound method into a local variable once:\n\n f = obj.f\n for x in items:\n f(x)\n\nBut I only do this when profiling proves it matters. Most of the time, method vs function is a design decision, not a performance decision.\n\n## A Practical Checklist: Method or Function?\nIf you want a quick decision tool, this is what I use.\n\nChoose a function when:\n\n- the operation is pure (no state)\n- you want easy reuse across modules\n- the API reads naturally as do(x)\n- you want minimal coupling for testing\n\nChoose an instance method when:\n\n- the operation depends on instance state\n- you want to enforce invariants around state changes\n- the API reads naturally as x.do()\n- you want to support polymorphism via “objects that have a .do()”\n\nChoose a class method when:\n\n- you’re building an alternate constructor\n- behavior is about the type, not a particular instance\n- you want subclass-friendly construction via cls(...)\n\nBe cautious with static methods when:\n\n- they exist only for namespacing\n- they make readers ask “why is this in a class?”\n\n## Putting It All Together: A Mini Case Study\nTo show how this plays out in real code, here’s a small “order pricing” slice with all three styles used intentionally.\n\n- A pure function for money formatting (no state)\n- An instance method for totals (depends on items and tax)\n- A class method to build the object from raw data\n\n from dataclasses import dataclass\n\n def formatusdcents(amountcents: int) -> str:\n dollars = amountcents // 100\n cents = amountcents % 100\n return f‘${dollars:,}.{cents:02d}‘\n\n\n @dataclass(frozen=True)\n class LineItem:\n sku: str\n unitpricecents: int\n quantity: int\n\n def totalcents(self) -> int:\n return self.unitpricecents self.quantity\n\n\n @dataclass\n class Invoice:\n items: list[LineItem]\n taxrate: float\n\n @classmethod\n def fromdict(cls, data: dict) -> ‘Invoice‘:\n items = [\n LineItem(\n sku=i[‘sku‘],\n unitpricecents=int(i[‘unitpricecents‘]),\n quantity=int(i[‘quantity‘]),\n )\n for i in data.get(‘items‘, [])\n ]\n return cls(items=items, taxrate=float(data[‘taxrate‘]))\n\n def subtotalcents(self) -> int:\n return sum(i.totalcents() for i in self.items)\n\n def totalcents(self) -> int:\n subtotal = self.subtotalcents()\n tax = int(round(subtotal * self.taxrate))\n return subtotal + tax\n\n\n raw = {\n ‘taxrate‘: 0.0825,\n ‘items‘: [\n {‘sku‘: ‘USB-C-CABLE‘, ‘unitpricecents‘: 1299, ‘quantity‘: 2},\n {‘sku‘: ‘LAPTOP-STAND‘, ‘unitpricecents‘: 4999, ‘quantity‘: 1},\n ],\n }\n\n invoice = Invoice.fromdict(raw)\n print(‘Subtotal:‘, formatusdcents(invoice.subtotalcents()))\n print(‘Total:‘, formatusdcents(invoice.total_cents()))\n\nI like this shape because each piece is doing a job it’s naturally good at. Nothing is a method “just because.” Nothing is a function “just because.”\n\n## Final Takeaway\nIf you boil all of this down to one sentence, this is the difference method function python developers benefit from remembering:\n\n- A function is just a callable object.\n- A method is what you get when a function is accessed through an object (or class) in a way that triggers binding via attribute access.\n\nOnce you see that, self stops being mysterious, @classmethod starts making sense as a subclass-friendly tool, and @staticmethod becomes a conscious choice instead of a default.\n\nMost importantly, your refactors get safer: when you move behavior into a class, you’ll know exactly what changes at the call site—and why.

Scroll to Top