PythonOOP

Python OOP: Classes, Inheritance, and Design Patterns

12 tutorials beginner / intermediate / advanced

Object-oriented programming in Python is flexible by design. The official Python documentation describes classes as providing "a means of bundling data and functionality together" — but that single sentence understates how much flexibility Python gives you in deciding what to bundle, when, and how (source: docs.python.org/3/tutorial/classes.html). You can write simple classes that group data with behavior, or build complex hierarchies with descriptors, metaclasses, and protocol-based design. The key is knowing which tools to reach for and when composition beats inheritance.

This path covers OOP from the ground up: defining classes, understanding __init__ and self, choosing between class methods and static methods, designing with composition vs. inheritance, and using descriptors for attribute control. It also covers the distinction between data descriptors and non-data descriptors — a detail that controls attribute lookup priority in CPython and that shapes how property, classmethod, and staticmethod behave internally (source: docs.python.org/3/howto/descriptor.html).

Because Python is a multi-paradigm language — supporting object-oriented, procedural, and functional styles — this collection also covers when not to reach for OOP at all. Many Python programs are written entirely in procedural style, and knowing when a plain function is the right choice is part of writing clean Python.

Tutorials marked with the cert badge include a final exam that awards a certificate of completion you can download and share.

How to Structure a Python Class from Scratch

Follow these steps to build a well-structured Python class. Each step corresponds to a distinct design decision — from naming your class to choosing where logic belongs.

  1. Name your class using CapitalizedWords

    Use the class keyword followed by a name written in CapitalizedWords (also called PascalCase). The name should describe what the class represents as a noun — BankAccount, HttpRequest, UserProfile. Avoid verb names and abbreviations. A clear noun name makes it immediately obvious what instances of this class model.

  2. Define __init__ to initialize instance attributes

    Write an __init__ method that accepts self plus any data the object needs at creation time. Assign each piece of data to self.attribute_name inside the method body. These become the instance attributes — values unique to each object created from the class. Keep __init__ focused on initialization only; defer complex logic to separate methods.

  3. Add instance methods for object behavior

    Define methods that describe what your object can do. Each instance method takes self as its first argument, giving it access to the object's own data. Name methods with verbs — calculate_total(), send_request(), validate(). Each method should do one thing. If a method grows beyond ten to fifteen lines, consider breaking it apart.

  4. Decide what belongs as a class attribute vs. an instance attribute

    If a value is shared across every instance — a species name, a fixed configuration constant, a counter — define it as a class attribute directly on the class body, outside any method. If the value is unique to each instance, assign it inside __init__ using self. Mixing these up is one of the most common sources of unexpected behavior in beginner OOP code.

  5. Choose between @classmethod, @staticmethod, and instance methods

    Use an instance method (no decorator) when the method needs access to self — the specific object's data. Use @classmethod when the method applies to the class as a whole and needs access to cls, such as alternative constructors. Use @staticmethod when the logic belongs in the class namespace conceptually but touches neither instance nor class state. Defaulting everything to instance methods is a common mistake — pick the right tool for what the method actually does.

  6. Add __repr__ for a useful string representation

    Define a __repr__ method that returns a developer-readable string describing the object's current state. The convention is to return a string that, if evaluated, would recreate the object — for example, BankAccount(owner='Alice', balance=500). Without __repr__, printing or logging your object produces an unhelpful memory address. This is one of the dunder methods worth adding to nearly every class you write.

Frequently Asked Questions

Object-oriented programming (OOP) in Python is a design approach that organizes code into classes and objects. A class acts as a blueprint that defines attributes (data) and methods (behavior). You create instances of that class — called objects — each holding its own state. Python's OOP model supports the four core pillars: encapsulation, inheritance, abstraction, and polymorphism. Because Python is a multi-paradigm language, OOP is one option rather than a requirement, and you can mix it freely with procedural and functional styles.

A Python class is a reusable blueprint that defines the structure and behavior of objects. You define one using the class keyword, followed by the class name in CapitalizedWords convention, then a colon. The body contains an __init__ method to initialize attributes and any other methods that describe the object's behavior. Every instance method takes self as its first argument, which refers to the specific object the method is called on.

__init__ is Python's instance initializer method, called automatically when a new object is created from a class. It is not the constructor itself — that role belongs to __new__ — but it is where you set the initial values of instance attributes using self. Every attribute assigned inside __init__ is unique to that object instance, meaning two objects from the same class can hold completely different data.

A class method, decorated with @classmethod, receives the class itself as its first argument (conventionally named cls) rather than an instance. This makes it useful for alternative constructors or operations that apply to the class as a whole. A static method, decorated with @staticmethod, receives neither the instance nor the class — it is essentially a plain function that lives in the class namespace for organizational purposes. Use a class method when you need access to class-level data; use a static method when the logic belongs conceptually to the class but doesn't touch class or instance state.

Class attributes are defined directly on the class body, outside any method. They are shared across all instances, meaning every object created from that class sees the same value unless it is overridden on a specific instance. Instance attributes are set inside __init__ (or other methods) using self, and they belong exclusively to that object. Python's attribute lookup order checks the instance dictionary first, then the class dictionary, which is why setting an attribute on an instance shadows the class attribute without modifying it globally.

Composition means building a class by holding references to other objects rather than inheriting from them. Use composition when the relationship between two classes is "has a" rather than "is a" — for example, a Car has an Engine rather than being one. Composition is generally preferred because it keeps classes loosely coupled, makes the code easier to test in isolation, and avoids the fragile base class problem that deep inheritance hierarchies create. Inheritance still makes sense when you have a genuine subtype relationship and want to extend or override specific behavior.

The descriptor protocol is the mechanism Python uses to customize attribute access. Any class that defines __get__, __set__, or __delete__ is a descriptor. When a descriptor object is assigned as a class attribute, Python calls those methods instead of doing a plain dictionary lookup whenever the attribute is accessed, set, or deleted on an instance. This is how property, classmethod, staticmethod, and slot-based attributes all work internally.

Descriptors that define __set__ or __delete__ are classified as data descriptors and take priority over an instance's own __dict__. Descriptors that only define __get__ are non-data descriptors and can be overridden by instance attributes. Python 3.6 added __set_name__, which is called at class creation time and lets a descriptor know which attribute name it was assigned to — useful for auto-naming internal storage. Understanding the full protocol is essential for writing advanced Python that controls attribute behavior at the class level.

No. Python is a multi-paradigm language that supports object-oriented, procedural, and functional programming styles. While everything in Python is technically an object — including functions, modules, and primitive values — you are not required to use classes or OOP patterns in your code. Many Python programs and scripts are written entirely in procedural style with plain functions and no class definitions. Choosing when to use OOP versus a simpler procedural approach is one of the key design decisions covered across the tutorials in this collection.

Sources & References