In Python's object-oriented world, classes are blueprints and objects are the buildings constructed from them. But the real power of a class lives in its methods — the functions defined inside it that give objects their behavior. Python does not limit you to one kind of method. It provides five distinct categories, each with a different relationship to the class and its instances: instance methods, class methods, static methods, property methods, and special "dunder" (double underscore) methods. Understanding when and why to use each one is what separates someone who writes classes from someone who designs them well. This guide covers every category in depth, with practical code you can run and patterns you can apply to your own projects immediately.
"One difference between a smart programmer and a professional programmer is that the professional understands that clarity is king." — Robert C. Martin, Clean Code
Before we dive into each method type, here is the core question every method category answers differently: what does this method have access to? An instance method can access the specific object and all its attributes. A class method can access the class itself but not any specific instance. A static method cannot access either — it is essentially a regular function that lives inside the class for organizational purposes. A property method disguises itself as an attribute while running code behind the scenes. And dunder methods let your objects integrate with Python's built-in syntax. Keep this access question in mind as we work through each one.
A Quick Refresher: Classes and self
A class defines a new type by bundling data (attributes) and behavior (methods) together. When you create an object from a class, Python calls the __init__ method to initialize it. The self parameter, which appears as the first argument in every instance method, is a reference to the specific object that called the method. Python passes it automatically — you never provide it yourself when calling the method.
class Dog:
"""A simple class to demonstrate the basics."""
species = "Canis familiaris" # Class attribute (shared by ALL dogs)
def __init__(self, name, breed, age):
# Instance attributes (unique to EACH dog)
self.name = name
self.breed = breed
self.age = age
def bark(self):
"""Instance method: uses self to access this dog's name."""
return f"{self.name} says: Woof!"
# Creating instances
rex = Dog("Rex", "German Shepherd", 5)
luna = Dog("Luna", "Labrador", 3)
print(rex.bark()) # Rex says: Woof!
print(luna.bark()) # Luna says: Woof!
print(rex.species) # Canis familiaris (shared class attribute)
The name self is a convention, not a keyword. You could technically name it anything, but using self is so universally expected in Python that using anything else will confuse every developer who reads your code. Always use self.
Instance Methods
Instance methods are the most common type of method. They take self as their first parameter, giving them full access to the instance's attributes and other methods. They can also access class-level attributes through self.__class__ or the class name directly. Any method you define inside a class without a decorator is an instance method by default.
class BankAccount:
"""Demonstrates instance methods with real behavior."""
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.transactions = []
def deposit(self, amount):
"""Add funds to the account."""
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
self.transactions.append(f"+${amount:.2f}")
return self # Enables method chaining
def withdraw(self, amount):
"""Remove funds from the account."""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
self.transactions.append(f"-${amount:.2f}")
return self
def get_statement(self):
"""Generate a formatted account statement."""
lines = [f"Account Statement for {self.owner}"]
lines.append("-" * 35)
for t in self.transactions:
lines.append(f" {t}")
lines.append("-" * 35)
lines.append(f" Balance: ${self.balance:.2f}")
return "\n".join(lines)
acct = BankAccount("Kandi", 1000)
acct.deposit(500)
acct.withdraw(200)
acct.deposit(75)
print(acct.get_statement())
Every instance method receives self automatically, which means it can read and modify the specific object's state. The deposit method modifies self.balance and appends to self.transactions — changes that affect only this particular account, not any other BankAccount instance. This is the essence of encapsulation: each object manages its own data through its own methods.
Class Methods
A class method receives the class itself as its first argument (conventionally named cls) instead of an instance. You define a class method using the @classmethod decorator. Class methods can access and modify class-level state, and their most common use is as alternative constructors — factory methods that create instances in different ways than the standard __init__.
class User:
"""Demonstrates class methods as alternative constructors."""
user_count = 0 # Class attribute: tracks all users
def __init__(self, username, email, role="viewer"):
self.username = username
self.email = email
self.role = role
User.user_count += 1
# --- Class methods ---
@classmethod
def from_string(cls, user_string):
"""Create a User from a 'username:email:role' string."""
username, email, role = user_string.split(":")
return cls(username, email, role)
@classmethod
def admin(cls, username, email):
"""Factory: create a user with admin role."""
return cls(username, email, role="admin")
@classmethod
def guest(cls):
"""Factory: create an anonymous guest user."""
return cls("guest", "[email protected]", role="guest")
@classmethod
def get_user_count(cls):
"""Return the total number of users created."""
return cls.user_count
def __repr__(self):
return f"User({self.username!r}, {self.email!r}, {self.role!r})"
# Standard constructor
u1 = User("kandi", "[email protected]", "instructor")
# Alternative constructors via class methods
u2 = User.from_string("alex:[email protected]:developer")
u3 = User.admin("sam", "[email protected]")
u4 = User.guest()
print(u1) # User('kandi', '[email protected]', 'instructor')
print(u2) # User('alex', '[email protected]', 'developer')
print(u3) # User('sam', '[email protected]', 'admin')
print(u4) # User('guest', '[email protected]', 'guest')
print(f"Total users: {User.get_user_count()}") # 4
Notice that class methods use cls instead of the class name directly (cls(username, email, role) rather than User(username, email, role)). This matters for inheritance: if you create a subclass of User, the class methods will correctly return instances of the subclass because cls will refer to whichever class was actually called.
"Programs must be written for people to read, and only incidentally for machines to execute." — Harold Abelson, Structure and Interpretation of Computer Programs
Static Methods
A static method belongs to a class for organizational purposes but has no access to the instance (self) or the class (cls). It is defined with the @staticmethod decorator and behaves exactly like a regular function that happens to live inside a class namespace. Use static methods for utility functions that are logically related to the class but do not need any class or instance data.
class PasswordUtils:
"""A collection of password-related utility methods."""
MIN_LENGTH = 8
def __init__(self, password):
self.password = password
@staticmethod
def generate(length=16):
"""Generate a random password (no self or cls needed)."""
import string
import secrets # secrets.choice is cryptographically secure; use this in production
chars = string.ascii_letters + string.digits + string.punctuation
return "".join(secrets.choice(chars) for _ in range(length))
@staticmethod
def entropy(password):
"""Estimate the bit entropy of a password.
Uses the size of the character pool inferred from the character
classes present in the password (lowercase, uppercase, digits,
punctuation) rather than the count of unique characters actually
used. Counting unique characters would underestimate entropy for
passwords that draw from a large alphabet but happen to repeat few
characters, and overestimate it for passwords that use only a tiny
slice of a stated alphabet. The pool-based approach is the standard
used by NIST SP 800-63B and most password-strength estimators.
"""
import math
import string
pool = 0
if any(c in string.ascii_lowercase for c in password):
pool += 26
if any(c in string.ascii_uppercase for c in password):
pool += 26
if any(c in string.digits for c in password):
pool += 10
if any(c in string.punctuation for c in password):
pool += 32 # len(string.punctuation) == 32
if pool == 0: # empty password or no recognizable character classes
return 0
return len(password) * math.log2(pool)
@staticmethod
def is_common(password):
"""Check if a password appears in a list of common passwords."""
common = {"password", "123456", "qwerty", "admin", "letmein"}
return password.lower() in common
def strength(self):
"""Instance method that uses static methods internally."""
if self.is_common(self.password):
return "Terrible (common password)"
bits = self.entropy(self.password)
# Thresholds loosely aligned with NIST SP 800-63B guidance
if bits < 30:
return "Weak"
elif bits < 50:
return "Fair"
elif bits < 70:
return "Strong"
return "Very Strong"
# Static methods can be called on the class directly
print(PasswordUtils.generate(20))
print(f"Entropy: {PasswordUtils.entropy('P@ssw0rd!'):.1f} bits")
print(f"Is common: {PasswordUtils.is_common('qwerty')}")
# Or on an instance
pw = PasswordUtils("MyS3cur3!Pass")
print(f"Strength: {pw.strength()}")
If you find that a method does not reference self or cls at all, it should probably be a @staticmethod. Many IDEs and linters will flag this automatically. Marking it as static communicates intent clearly: this function does not depend on instance or class state.
Instance vs. Class vs. Static: Side by Side
Seeing all three in one class makes the distinctions crystal clear. Each method type answers the question "what can I access?" differently.
class Demo:
class_attr = "I belong to the class"
def __init__(self, value):
self.instance_attr = value
def instance_method(self):
"""Has access to BOTH instance and class."""
return f"Instance: {self.instance_attr}, Class: {self.class_attr}"
@classmethod
def class_method(cls):
"""Has access to the CLASS but NOT a specific instance."""
return f"Class: {cls.class_attr}"
# Cannot access self.instance_attr here!
@staticmethod
def static_method():
"""Has access to NEITHER instance nor class."""
return "I'm just a function living inside a class"
obj = Demo("hello")
print(obj.instance_method()) # Instance: hello, Class: I belong to the class
print(Demo.class_method()) # Class: I belong to the class
print(Demo.static_method()) # I'm just a function living inside a class
# Instance methods must be called on an instance
# Class methods can be called on the class OR an instance (calling on an instance works but is unusual — prefer the class)
# Static methods can be called on the class OR an instance
Property Methods
The @property decorator lets you define a method that is accessed like an attribute. This gives you the simplicity of attribute access (obj.area instead of obj.get_area()) while running code behind the scenes to compute, validate, or control the value. Properties are Python's answer to getter and setter methods, but far more elegant.
class Server:
"""Demonstrates property methods for controlled attribute access."""
VALID_STATUSES = ("online", "offline", "maintenance", "degraded")
def __init__(self, hostname, ip, status="offline"):
self.hostname = hostname
self.ip = ip
self._status = status # Convention: _ prefix for "internal" attributes
self._cpu_usage = 0.0
self._memory_usage = 0.0
# Read-only property (getter only)
@property
def summary(self):
"""Human-readable server summary."""
return f"{self.hostname} ({self.ip}) - {self._status.upper()}"
# Property with getter, setter, and deleter
@property
def status(self):
"""Get the server's current status."""
return self._status
@status.setter
def status(self, new_status):
"""Set the server's status with validation."""
if new_status not in self.VALID_STATUSES:
raise ValueError(
f"Invalid status '{new_status}'. "
f"Must be one of: {self.VALID_STATUSES}"
)
print(f"[{self.hostname}] Status changed: {self._status} -> {new_status}")
self._status = new_status
@status.deleter
def status(self):
"""Reset status to offline."""
print(f"[{self.hostname}] Status reset to offline")
self._status = "offline"
# Computed property (no stored data)
@property
def health_score(self):
"""Calculate a health score from 0-100 based on resource usage."""
cpu_penalty = self._cpu_usage * 50
mem_penalty = self._memory_usage * 50
return max(0, round(100 - cpu_penalty - mem_penalty))
def update_metrics(self, cpu, memory):
"""Update resource usage metrics (0.0 to 1.0)."""
self._cpu_usage = max(0.0, min(1.0, cpu))
self._memory_usage = max(0.0, min(1.0, memory))
return self
# Usage: properties look like attributes
srv = Server("web-prod-01", "10.0.1.50")
print(srv.summary) # web-prod-01 (10.0.1.50) - OFFLINE
srv.status = "online" # Triggers the setter with validation
print(srv.status) # online
# srv.status = "exploding" # ValueError: Invalid status
srv.update_metrics(cpu=0.35, memory=0.60)
print(f"Health: {srv.health_score}/100") # Health: 53/100
del srv.status # Triggers the deleter
print(srv.status) # offline
"Explicit is better than implicit." — Tim Peters, The Zen of Python (PEP 20)
Dunder (Magic) Methods
Dunder methods (short for "double underscore") are special methods whose names start and end with __. They let your objects hook into Python's built-in syntax and protocols. When you write len(my_obj), Python calls my_obj.__len__(). When you write print(my_obj), Python calls my_obj.__str__(). By implementing these methods, your custom classes can behave exactly like Python's built-in types.
class Vulnerability:
"""A cybersecurity vulnerability with rich dunder method support."""
SEVERITY_ORDER = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
def __init__(self, cve_id, title, severity, cvss_score):
self.cve_id = cve_id
self.title = title
self.severity = severity.lower()
self.cvss_score = cvss_score
# String representations
def __str__(self):
"""Human-readable output (used by print())."""
return f"[{self.severity.upper()}] {self.cve_id}: {self.title}"
def __repr__(self):
"""Developer-friendly output (used in debugger and REPL)."""
return (f"Vulnerability({self.cve_id!r}, {self.title!r}, "
f"{self.severity!r}, {self.cvss_score})")
# Comparison operators
def __eq__(self, other):
"""Equal: same CVE ID."""
return isinstance(other, Vulnerability) and self.cve_id == other.cve_id
def __lt__(self, other):
"""Less than: compare by CVSS score."""
return self.cvss_score < other.cvss_score
def __le__(self, other):
return self.cvss_score <= other.cvss_score
def __gt__(self, other):
return self.cvss_score > other.cvss_score
def __ge__(self, other):
return self.cvss_score >= other.cvss_score
# Container-like behavior
def __len__(self):
"""Return the length of the title.
NOTE: This is here to demonstrate the __len__ protocol.
In real code, only implement __len__ when 'length' has a
clear, intuitive meaning for your type (e.g. a collection).
"""
return len(self.title)
def __bool__(self):
"""A vulnerability is 'truthy' if severity is medium or above."""
return self.SEVERITY_ORDER.get(self.severity, 0) >= 2
# Make it hashable (so it can go in sets and dict keys)
def __hash__(self):
return hash(self.cve_id)
# Creating vulnerabilities
v1 = Vulnerability("CVE-2026-1001", "SQL Injection in login form", "critical", 9.8)
v2 = Vulnerability("CVE-2026-1002", "XSS in search bar", "medium", 5.4)
v3 = Vulnerability("CVE-2026-1003", "Info disclosure in headers", "low", 2.1)
# __str__ via print()
print(v1) # [CRITICAL] CVE-2026-1001: SQL Injection in login form
# __repr__ in the REPL or debugger
print(repr(v2))
# __lt__ enables sorting
vulns = [v2, v3, v1]
by_severity = sorted(vulns, reverse=True)
for v in by_severity:
print(f" {v.cvss_score:.1f} - {v.cve_id}")
# __bool__ in conditionals
if v1:
print(f"{v1.cve_id} needs immediate attention")
if not v3:
print(f"{v3.cve_id} is low severity — no immediate action required")
# __eq__ and __hash__ enable sets
unique_vulns = {v1, v2, v3, v1} # Duplicate v1 removed
print(f"Unique vulnerabilities: {len(unique_vulns)}") # 3
If you define __eq__, Python automatically sets __hash__ to None on your class, making instances unhashable (trying to put them in a set or use them as dict keys will raise TypeError: unhashable type). If you need your objects to be usable in sets or as dictionary keys, you must also define __hash__ explicitly. A good pattern is to hash on the attribute(s) that define equality: if two objects are equal by CVE ID, hash by CVE ID.
Method Chaining
Method chaining is a design pattern where each method returns self, allowing you to call multiple methods in a single expression. This creates a fluent, readable API that reads almost like a sentence. You have seen this pattern in libraries like Pandas (df.dropna().sort_values().head()) and jQuery. Implementing it is simple: just return self at the end of any method that modifies the object.
class QueryBuilder:
"""A SQL query builder that uses method chaining."""
def __init__(self):
self._table = None
self._columns = ["*"]
self._conditions = []
self._order_by = None
self._limit = None
def select(self, *columns):
"""Specify which columns to retrieve."""
self._columns = list(columns) if columns else ["*"]
return self
def from_table(self, table):
"""Specify the table to query."""
self._table = table
return self
def where(self, condition):
"""Add a WHERE condition."""
self._conditions.append(condition)
return self
def order_by(self, column, direction="ASC"):
"""Add an ORDER BY clause."""
self._order_by = f"{column} {direction}"
return self
def limit(self, count):
"""Limit the number of results."""
self._limit = count
return self
def build(self):
"""Generate the final SQL string."""
sql = f"SELECT {', '.join(self._columns)}"
sql += f" FROM {self._table}"
if self._conditions:
sql += " WHERE " + " AND ".join(self._conditions)
if self._order_by:
sql += f" ORDER BY {self._order_by}"
if self._limit:
sql += f" LIMIT {self._limit}"
return sql + ";"
def __str__(self):
return self.build()
# Fluent, chainable API
query = (
QueryBuilder()
.select("hostname", "ip", "status")
.from_table("servers")
.where("status = 'online'")
.where("region = 'us-east-1'")
.order_by("hostname")
.limit(50)
.build()
)
print(query)
# SELECT hostname, ip, status FROM servers
# WHERE status = 'online' AND region = 'us-east-1'
# ORDER BY hostname ASC LIMIT 50;
"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." — Martin Fowler, Refactoring
A Complete Example: Putting It All Together
Let us build a single class that demonstrates every method type working together in a realistic context: a network device inventory system.
The is_valid_ip static method below explicitly rejects octets with leading zeros (e.g. "010.0.0.1"). A naive check using only int(p) would pass those strings because Python's int() treats them as decimal — but many systems and RFCs consider leading zeros in dotted-decimal notation either invalid or ambiguous (historically interpreted as octal in some contexts). Always reject them explicitly in production validators.
The _registry = [] list is a mutable class attribute shared by NetworkDevice and any subclass that does not override it. If you create a subclass such as class Router(NetworkDevice), all Router instances will be appended to the same NetworkDevice._registry list unless the subclass explicitly defines its own. This is a common Python gotcha. To give each class its own registry, override the attribute in the subclass: class Router(NetworkDevice): _registry = [].
from datetime import datetime
from functools import total_ordering
@total_ordering # Auto-generates __le__, __gt__, __ge__ from __eq__ and __lt__
class NetworkDevice:
"""A network device with every method type in action."""
_registry = [] # Class-level: tracks all devices
def __init__(self, hostname, ip, device_type, location):
self.hostname = hostname
self.ip = ip
self.device_type = device_type
self.location = location
self._uptime_hours = 0
self._created_at = datetime.now()
NetworkDevice._registry.append(self)
# --- Instance methods ---
def ping(self):
return f"Pinging {self.ip}... Reply from {self.hostname}"
def update_uptime(self, hours):
self._uptime_hours = hours
return self
# --- Class methods ---
@classmethod
def from_csv(cls, csv_line):
"""Alternative constructor from CSV data.
Expects exactly four comma-separated fields:
hostname, ip, device_type, location.
Raises ValueError if the line does not contain exactly four
comma-separated fields (raised by tuple unpacking, not split).
In production, wrap this in a try/except or validate first.
"""
hostname, ip, dtype, loc = csv_line.split(",")
return cls(hostname.strip(), ip.strip(), dtype.strip(), loc.strip())
@classmethod
def get_all(cls):
"""Return all registered devices."""
return list(cls._registry)
@classmethod
def find_by_type(cls, device_type):
"""Find all devices of a specific type."""
return [d for d in cls._registry if d.device_type == device_type]
# --- Static methods ---
@staticmethod
def is_valid_ip(ip):
"""Validate an IPv4 address."""
parts = ip.split(".")
if len(parts) != 4:
return False
for p in parts:
# Reject empty strings, non-digits, leading zeros (e.g. "010"),
# and values outside the 0-255 range.
if not p.isdigit():
return False
if len(p) > 1 and p[0] == "0": # leading zero
return False
if not (0 <= int(p) <= 255):
return False
return True
# --- Properties ---
@property
def uptime_days(self):
return round(self._uptime_hours / 24, 1)
@property
def age_seconds(self):
return (datetime.now() - self._created_at).total_seconds()
# --- Dunder methods ---
def __str__(self):
return f"{self.hostname} ({self.ip}) [{self.device_type}]"
def __repr__(self):
return f"NetworkDevice({self.hostname!r}, {self.ip!r})"
def __eq__(self, other):
return isinstance(other, NetworkDevice) and self.ip == other.ip
def __lt__(self, other):
return self.hostname < other.hostname
def __hash__(self):
return hash(self.ip)
# Build an inventory
d1 = NetworkDevice("fw-prod-01", "10.0.0.1", "firewall", "DC-East")
d2 = NetworkDevice("sw-core-01", "10.0.0.2", "switch", "DC-East")
d3 = NetworkDevice.from_csv("rt-edge-01, 10.0.0.3, router, DC-West")
# Instance methods
d1.update_uptime(2160)
print(d1.ping())
# Properties
print(f"{d1.hostname} uptime: {d1.uptime_days} days")
# Static method
print(f"Valid IP? {NetworkDevice.is_valid_ip('10.0.0.1')}")
print(f"Valid IP? {NetworkDevice.is_valid_ip('999.0.0.1')}")
# Class methods
all_devices = NetworkDevice.get_all()
firewalls = NetworkDevice.find_by_type("firewall")
print(f"Total devices: {len(all_devices)}")
print(f"Firewalls: {len(firewalls)}")
# Dunder methods enable sorting and sets
for device in sorted(all_devices):
print(f" {device}")
The @functools.total_ordering decorator saves you from writing all six comparison methods. Just define __eq__ and one of __lt__, __le__, __gt__, or __ge__, and it generates the rest automatically. Use it whenever your class needs full comparison support. One caveat: the generated methods carry a small lookup-table overhead per comparison. For ordinary use this is imperceptible, but if you are sorting millions of objects in a hot loop, writing the comparison methods by hand will be measurably faster.
Key Takeaways
- Instance methods are the default: They receive
self, giving them access to the specific object's data. Use them for any behavior that depends on instance state. - Class methods receive
cls: They operate on the class itself, making them ideal for alternative constructors, factory methods, and managing class-level state. Always useclsinstead of the class name for inheritance safety. - Static methods receive nothing: They are regular functions that live inside a class for organizational clarity. Use them for utility logic that is related to the class but does not need instance or class access.
- Properties bridge attributes and methods: The
@propertydecorator lets you compute values on access, validate data on assignment, and control attribute be