Python Classes

Master Python OOP: class definition, __init__, self, instance/class/static methods, @property, dunder methods, and operator overloading.

Intermediate 14 min read 10 examples

Class Basics

A class is a blueprint for creating objects. An object is an instance of a class with its own state (attributes) and behavior (methods).

Python
class Dog:
    """A simple Dog class."""

    # Class attribute - shared by all instances
    species = "Canis lupus familiaris"

    def __init__(self, name, age):
        """Initialize a new Dog instance."""
        # Instance attributes - unique to each instance
        self.name = name
        self.age  = age

    def bark(self):
        """Make the dog bark."""
        return f"{self.name} says: Woof!"

    def info(self):
        return f"{self.name} ({self.age} years old)"

# Create instances
dog1 = Dog("Buddy", 3)
dog2 = Dog("Max", 5)

# Access instance attributes
print(dog1.name)    # Buddy
print(dog2.age)     # 5

# Access class attribute via instance or class
print(dog1.species)         # Canis lupus familiaris
print(Dog.species)          # Canis lupus familiaris

# Call methods
print(dog1.bark())          # Buddy says: Woof!
print(dog2.info())          # Max (5 years old)

# Modify instance attributes
dog1.age = 4
print(dog1.age)     # 4 - only dog1 changed
print(dog2.age)     # 5 - dog2 unchanged

# Add new attribute to one instance
dog1.tricks = ["sit", "shake"]  # dog2 has no tricks attribute

# isinstance() and type()
print(isinstance(dog1, Dog))    # True
print(type(dog1))               # 
print(type(dog1) is Dog)        # True

Instance, Class, and Static Methods

Python
class Circle:
    PI = 3.14159265

    def __init__(self, radius):
        self.radius = radius

    # Instance method - has access to self
    def area(self):
        return self.PI * self.radius ** 2

    def circumference(self):
        return 2 * self.PI * self.radius

    # Class method - receives cls, not self
    # Common use: alternative constructor
    @classmethod
    def from_diameter(cls, diameter):
        """Create a Circle from a diameter value."""
        return cls(diameter / 2)    # cls() is same as Circle()

    @classmethod
    def unit_circle(cls):
        """Return a circle with radius 1."""
        return cls(1)

    # Static method - no self or cls
    # Just a function that lives in the class namespace
    @staticmethod
    def is_valid_radius(radius):
        return isinstance(radius, (int, float)) and radius > 0

# Instance method - called on an instance
c1 = Circle(5)
print(f"Area: {c1.area():.2f}")             # Area: 78.54
print(f"Circumference: {c1.circumference():.2f}")

# Class method - called on the class (or an instance)
c2 = Circle.from_diameter(10)
print(c2.radius)    # 5.0

c3 = Circle.unit_circle()
print(c3.radius)    # 1

# Static method - called on the class (or an instance)
print(Circle.is_valid_radius(5))    # True
print(Circle.is_valid_radius(-1))   # False

# Summary of differences
# instance_method(self) - can read/write self.attr and cls
# classmethod(cls)      - can read/write cls.attr, creates instances
# staticmethod()        - no self or cls, pure utility function

@property

@property creates a managed attribute with getter, setter, and deleter logic.

Python
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius     # _name signals "private by convention"

    # Getter - accessed like an attribute (no parentheses)
    @property
    def celsius(self):
        return self._celsius

    # Setter - runs when you assign: obj.celsius = value
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError(f"Temperature below absolute zero: {value}")
        self._celsius = value

    # Computed property (read-only - no setter)
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

    @property
    def kelvin(self):
        return self._celsius + 273.15

    # Deleter
    @celsius.deleter
    def celsius(self):
        del self._celsius

t = Temperature(25)

# Access like attribute (no ())
print(t.celsius)        # 25
print(t.fahrenheit)     # 77.0
print(t.kelvin)         # 298.15

# Assign triggers setter with validation
t.celsius = 100
print(t.fahrenheit)     # 212.0

try:
    t.celsius = -300    # below absolute zero
except ValueError as e:
    print(e)            # Temperature below absolute zero: -300

# Practical: lazy computed property with caching
class Circle:
    def __init__(self, radius):
        self._radius = radius
        self._area   = None         # cache

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        self._radius = value
        self._area   = None         # invalidate cache

    @property
    def area(self):
        if self._area is None:
            self._area = 3.14159 * self._radius ** 2
        return self._area

Dunder Methods

Dunder (double-underscore) methods integrate your class with Python's built-in operations.

Python
class Book:
    def __init__(self, title, author, pages):
        self.title  = title
        self.author = author
        self.pages  = pages

    # __str__: human-readable string (used by print() and str())
    def __str__(self):
        return f'"{self.title}" by {self.author}'

    # __repr__: developer/debug string (used by repr() and REPL)
    def __repr__(self):
        return f'Book(title={self.title!r}, author={self.author!r}, pages={self.pages})'

    # __len__: len(obj)
    def __len__(self):
        return self.pages

    # __eq__: == operator
    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.title == other.title and self.author == other.author

    # __lt__: < operator (enables sorting)
    def __lt__(self, other):
        return self.title < other.title

    # __hash__: required if you define __eq__ and want to use in sets/dicts
    # If __eq__ defined, __hash__ is set to None by default (unhashable)
    def __hash__(self):
        return hash((self.title, self.author))

    # __bool__: truth value - bool(obj)
    def __bool__(self):
        return self.pages > 0

    # __contains__: in operator
    def __contains__(self, word):
        return word.lower() in self.title.lower()

b1 = Book("Python Cookbook", "David Beazley", 706)
b2 = Book("Fluent Python", "Luciano Ramalho", 792)
b3 = Book("Python Cookbook", "David Beazley", 706)

print(b1)           # "Python Cookbook" by David Beazley
print(repr(b1))     # Book(title='Python Cookbook', ...)
print(len(b1))      # 706
print(b1 == b3)     # True (same title + author)
print(b1 == b2)     # False
print(bool(b1))     # True
print("Python" in b1)   # True

# Sorting works because __lt__ is defined
books = [b2, b1]
print(sorted(books))    # sorted by title alphabetically

# Can be used in sets/dicts because __hash__ is defined
book_set = {b1, b2, b3}
print(len(book_set))    # 2 (b1 and b3 are equal)
DunderCalled byDescription
__init__ClassName()Object initializer
__str__str(obj), print(obj)Human-readable string
__repr__repr(obj), REPLDebug string
__len__len(obj)Length
__eq__obj == otherEquality
__lt__obj < otherLess than (enables sort)
__hash__hash(obj)Hash value (for dict/set)
__bool__bool(obj), if objTruth value
__contains__x in objMembership test
__iter__for x in objIterator protocol
__getitem__obj[key]Subscript access
__enter__/__exit__with objContext manager

Operator Overloading

Python
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

    # Arithmetic operators
    def __add__(self, other):       # v1 + v2
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):       # v1 - v2
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):      # v * 3
        return Vector(self.x * scalar, self.y * scalar)

    def __rmul__(self, scalar):     # 3 * v  (reflected mul)
        return self.__mul__(scalar)

    def __neg__(self):              # -v
        return Vector(-self.x, -self.y)

    def __abs__(self):              # abs(v) - magnitude
        return (self.x**2 + self.y**2) ** 0.5

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

v1 = Vector(2, 3)
v2 = Vector(1, 4)

print(v1 + v2)      # Vector(3, 7)
print(v1 - v2)      # Vector(1, -1)
print(v1 * 3)       # Vector(6, 9)
print(3 * v1)       # Vector(6, 9)  - uses __rmul__
print(-v1)          # Vector(-2, -3)
print(abs(v1))      # 3.605...

# Sequence protocol
class NumberRange:
    def __init__(self, start, end):
        self.start = start
        self.end   = end

    def __contains__(self, n):
        return self.start <= n <= self.end

    def __len__(self):
        return self.end - self.start + 1

    def __iter__(self):
        return iter(range(self.start, self.end + 1))

r = NumberRange(1, 10)
print(5 in r)       # True
print(11 in r)      # False
print(len(r))       # 10
print(list(r))      # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Classes vs @dataclass

For classes that mainly store data, @dataclass (Python 3.7+) eliminates boilerplate by auto-generating __init__, __repr__, and __eq__.

Python
from dataclasses import dataclass

# Manual class - lots of boilerplate
class PointManual:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

    def __eq__(self, other):
        return isinstance(other, PointManual) and self.x == other.x and self.y == other.y

# @dataclass - same thing, less code
@dataclass
class Point:
    x: float
    y: float

p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)

print(p1)           # Point(x=1.0, y=2.0)  - __repr__ auto-generated
print(p1 == p2)     # True                  - __eq__ auto-generated

# Use regular classes for:
# - Classes with significant behavior (methods)
# - Classes needing custom __init__ logic
# - Inheritance hierarchies

# Use @dataclass for:
# - Simple data containers (DTO, value objects)
# - When you want auto-generated __init__, __repr__, __eq__
# See the Dataclasses tutorial for full details

Frequently Asked Questions

self is a reference to the current instance of the class. It is always the first parameter in instance methods. When you call obj.method(), Python automatically passes the object as the first argument - you don't pass it manually. The name self is just a convention; you could use any name (but don't). Through self, methods access and modify the object's own attributes.

Instance methods receive self (the instance) as first argument - they can access and modify instance attributes. Class methods use @classmethod and receive cls (the class itself) as first argument - useful for alternative constructors. Static methods use @staticmethod and receive neither self nor cls - they are just functions namespaced inside the class, with no access to instance or class state.

Dunder (double underscore) methods, also called "magic methods" or "special methods," are methods with names like __init__, __str__, __len__. Python calls them automatically in specific situations - __init__ when creating an object, __str__ when calling str(obj) or print(obj), __len__ when calling len(obj). They let your objects integrate naturally with Python's built-in operations and syntax.

@property turns a method into a computed attribute. Calling obj.method (no parentheses) triggers the getter. You can also define a setter with @method.setter to validate or transform values on assignment. This lets you add logic to attribute access without changing the public interface - you start with a plain attribute and switch to a property later without breaking callers.