Skip to content
DevPebble
Programming Tutorials

Object-oriented programming in Python: a complete guide with practical examples

Object-oriented programming in Python explained — classes and objects, constructors, the four pillars, instance vs class vs static methods, and common mistakes.

The DevPebble Team13 min read
Object-oriented programming in Python — a complete guide with practical examples covering classes and objects, constructors, the four pillars, and the three method types.
object-oriented programming in Pythonpython classes and objectspython constructors and __init__four pillars of oop in pythoninstance vs class vs static methods
On this page

Every Python developer eventually hits the same wall. You write function after function, pass the same data between a dozen of them, track related values across scattered variables, and debugging turns into a maze of print statements. Small scripts never have this problem. Real applications always do. The issue isn't Python itself, it's that a procedural style runs out of room the moment a project grows.

Object-oriented programming in Python fixes this by letting you model real things as self-contained units that carry their own data and behavior. Code becomes easier to reuse, test, and reason about as an application scales. This guide covers the whole picture: classes and objects, constructors, the four pillars, the three method types, and the mistakes that trip up almost everyone at first. Every section has runnable code, and by the end you should know how OOP works and when it's worth reaching for.

What is object-oriented programming in Python, and how does it work?

What is object-oriented programming in Python and how it works — bundling data (attributes) and behavior (methods) into objects built from a class blueprint.

Object-oriented programming (OOP) structures code around objects instead of a linear list of instructions. An object bundles two things that normally live apart in procedural code: data (called attributes) and behavior (called methods). Rather than writing functions that operate on loose variables, you define a class, a blueprint describing what an object looks like and what it can do, then create as many instances of it as you need.

Python is often described as a language where "everything is an object," and that holds up literally. Strings, integers, lists, functions, and even modules are objects with their own attributes and methods. That's why "hello".upper() works: upper() is a method that belongs to the string object itself. Once that lands, OOP stops feeling like a topic bolted onto the language and starts looking like its natural grain.

Here's a mental model. Say you're managing a fleet of delivery vehicles. In procedural code you'd track each vehicle's data in separate lists, one for fuel, one for drivers, one for status, and write functions that keep them in sync by index. That works until you add a tenth vehicle or a teammate joins and can't tell which list maps to which car. With OOP, each vehicle becomes a Vehicle object holding its own fuel, driver, and status, plus methods like refuel(), so the logic sits right next to the data it changes.

How Python creates objects under the hood

When you define a class, Python doesn't create any objects yet. It registers a template in memory. Objects exist only once you instantiate the class, at which point Python allocates memory for that instance and links it back to the class for shared behavior. That's why two objects from the same class hold different data but share identical methods: methods are looked up on the class when called, not copied into every object, which keeps memory use efficient even with thousands of instances.

Procedural programming vs object-oriented programming

The contrast is easier to see side by side.

| Aspect | Procedural approach | Object-oriented approach | |---|---|---| | Data organization | Scattered across variables and lists | Bundled inside objects as attributes | | Code reuse | Copy-pasted or loosely shared functions | Inherited and extended through classes | | Scalability | Harder to manage as the project grows | Built to scale with modular objects | | Real-world mapping | Abstract, instruction-based | Mirrors real entities (users, orders, vehicles) | | Maintenance | Changes risk breaking unrelated code | Changes stay isolated inside relevant classes |

Neither approach is wrong. A 20-line automation script rarely needs classes, and forcing OOP onto it is overkill. But once an application involves multiple related entities interacting, like users, products, and orders, object-oriented Python gives you a structure that mirrors the problem you're solving, which is why Django, Pandas, and TensorFlow are built almost entirely around classes.

What are classes and objects in Python?

What are classes and objects in Python — a class as a blueprint and each object as an instance holding its own attribute values.

A class is the blueprint. An object, also called an instance, is what you get when you build something from it. If Car is a class saying every car has a brand, a model, and a fuel level, then your neighbor's red Honda Civic is one object built from it, and your blue Toyota Corolla is another. Same blueprint, different data.

Defining a class

Here's the basic syntax:

class Car:
    def __init__(self, brand, model, fuel_level=100):
        self.brand = brand
        self.model = model
        self.fuel_level = fuel_level

    def drive(self, distance):
        fuel_used = distance * 0.1
        self.fuel_level -= fuel_used
        print(f"{self.brand} {self.model} drove {distance} km. Fuel remaining: {self.fuel_level:.1f}%")

Two things carry the weight here. __init__ is the constructor, which Python calls automatically every time you create an object, and it's where you set up that object's starting state. self refers to the specific instance being acted on; it's how each object tracks its own data separately from every other object of the same class. Beginners often assume self is a keyword. It isn't, just a naming convention, and renaming it only confuses whoever reads your code later.

Creating objects (instantiation)

car1 = Car("Toyota", "Corolla")
car2 = Car("Honda", "Civic", fuel_level=80)

car1.drive(50)
car2.drive(30)

Each car ends up with its own fuel level. car1 and car2 are separate objects, so changing one has zero effect on the other even though both came from the same class. That isolation is the point: the class defines shared structure, and each object owns its own state.

Instance attributes vs class attributes

This distinction catches a lot of people moving from beginner to intermediate Python.

class Car:
    wheels = 4  # class attribute, shared across all instances

    def __init__(self, brand, model):
        self.brand = brand   # instance attribute, unique per object
        self.model = model

A class attribute is defined in the class body and shared by every object unless you deliberately override it. An instance attribute, defined inside __init__ with self, belongs to one object only. Every Car here shares the same wheels value, but brand and model differ from car to car.

Class vs object at a glance

| Class | Object | |---|---| | A blueprint or template | A real, usable instance built from that blueprint | | Defined once in code | Can be created many times | | Holds no data until instantiated | Occupies its own memory once created | | Example: Car | Example: car1 = Car("Toyota", "Corolla") | | Describes what attributes and methods exist | Holds actual values for those attributes |

Constructors, attributes, and instance methods

Constructors, attributes, and instance methods in Python — the __init__ constructor setting up an object's starting state alongside its data and behavior.

A well-built class comes down to three parts working together: the constructor that sets up an object's starting state, the attributes that store its data, and the instance methods that define what it can do. The constructor, __init__, runs the moment an object is created. You can give it required parameters, optional ones with defaults, or a mix. Take a Student class:

class Student:
    def __init__(self, name, roll_number, grades=None):
        self.name = name
        self.roll_number = roll_number
        self.grades = grades if grades is not None else []

    def add_grade(self, subject, score):
        self.grades.append((subject, score))

    def average_score(self):
        if not self.grades:
            return 0
        return sum(score for _, score in self.grades) / len(self.grades)

    def __str__(self):
        return f"Student({self.name}, Roll No: {self.roll_number})"

Look at grades=None followed by grades if grades is not None else []. That isn't clutter, it's dodging one of the most common bugs new developers hit. If you set a default argument directly to a mutable object, like grades=[], that same list gets shared across every instance that doesn't pass its own value, so one student's grades silently leak into another's. Using None as the default and building a fresh list inside the constructor avoids the trap.

Instance methods like add_grade() always take self first, which is how they know whose data to work with. When you call student1.add_grade("Math", 92), Python passes student1 in as self for you. The __str__ method controls what prints when you print(student1).

When you don't want to hardcode every attribute in advance, **kwargs gives you a flexible constructor:

class Employee:
    def __init__(self, name, **details):
        self.name = name
        self.details = details

emp = Employee("Meera", department="Design", experience=4)
print(emp.details)  # {'department': 'Design', 'experience': 4}

ORMs, API wrappers, and configuration objects use this pattern so new attributes can be added later without rewriting the constructor's signature.

The four pillars of object-oriented programming in Python

The four pillars of object-oriented programming in Python — encapsulation, inheritance, polymorphism, and abstraction.

Everything above sets the foundation, but the real payoff of object-oriented Python comes from four principles: encapsulation, inheritance, polymorphism, and abstraction. Here's the short version before the deep dives.

| Principle | What it does | Everyday analogy | |---|---|---| | Encapsulation | Restricts direct access to an object's internal data | An ATM controlling access to your balance | | Inheritance | Reuses and extends an existing class's behavior | A child inheriting traits from a parent | | Polymorphism | Same method name, different behavior per object | One remote that behaves differently on a TV vs an AC | | Abstraction | Hides implementation, exposes only the essentials | Driving a car without knowing engine mechanics |

On real projects they work together. A payment system might use abstraction for the shared interface, inheritance for reused logic, polymorphism to process any type through one function, and encapsulation to protect card data.

Encapsulation: controlling access to an object's data

Encapsulation means restricting direct access to an object's internal data and exposing only what's necessary through controlled methods. Python doesn't enforce access control the way Java or C++ do; no compiler stops you from reaching in and changing internals. Instead it relies on naming conventions, plus one real language feature, name mangling, that adds friction against accidental misuse.

| Access level | Naming convention | Accessibility | Typical use | |---|---|---|---| | Public | name | Reachable from anywhere | Data meant to be freely read and changed | | Protected | _name | Reachable, but flagged "internal use only" | Data for the class and its subclasses | | Private | __name | Name-mangled, discouraged from outside | Data needing stricter internal control |

class Employee:
    def __init__(self, name, salary):
        self.name = name               # public
        self._department = "General"   # protected
        self.__salary = salary         # private

self.name is fully public. self._department uses a single leading underscore, a convention that tells other developers "treat this as internal," even though nothing physically stops them. self.__salary, with the double underscore, triggers name mangling: Python quietly renames it to _Employee__salary. That doesn't make it unbreakably private, but it prevents accidental access and name clashes in subclasses, which is usually enough for real code.

Instead of manual get_salary() and set_salary() methods, Python gives you the @property decorator:

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.__salary = salary

    @property
    def salary(self):
        return self.__salary

    @salary.setter
    def salary(self, value):
        if value < 0:
            raise ValueError("Salary cannot be negative.")
        self.__salary = value

Now you can write emp.salary = 60000 like a normal attribute, but the assignment runs through the setter and gets validated first. Try emp.salary = -500 and Python raises an error instead of quietly storing bad data. That's encapsulation the Pythonic way: controlled access that still reads like plain syntax.

Inheritance: reusing and extending code

Inheritance lets a new class take on the attributes and methods of an existing one, then add or override behavior. It isn't a single fixed pattern, though. Python supports several forms, and knowing the difference stops you from over-engineering a hierarchy when something simpler would do.

Single inheritance is the most common: one child extends one parent.

class Person:
    def __init__(self, name):
        self.name = name

    def introduce(self):
        print(f"Hi, I'm {self.name}.")

class Teacher(Person):
    def __init__(self, name, subject):
        super().__init__(name)
        self.subject = subject

    def teach(self):
        print(f"{self.name} teaches {self.subject}.")

Teacher gets everything Person has and adds subject and teach(). The super().__init__(name) call hands name up to the parent's constructor instead of duplicating that logic.

Multilevel inheritance extends the idea into a chain, where a class inherits from a class that itself inherited from another:

class Principal(Teacher):
    def __init__(self, name, subject, school):
        super().__init__(name, subject)
        self.school = school

    def announce(self):
        print(f"{self.name}, principal of {self.school}, has an announcement.")

Principal sits three levels deep. It inherits from Teacher, which inherits from Person, so a Principal object can call introduce(), teach(), and announce().

Multiple inheritance is where one class inherits from more than one parent at once:

class Swimmer:
    def swim(self):
        print("Swimming across the pool.")

class Runner:
    def run(self):
        print("Running on the track.")

class Triathlete(Swimmer, Runner):
    pass

athlete = Triathlete()
athlete.swim()
athlete.run()

Triathlete combines behavior from two unrelated classes, neither of which knows about the other. Powerful, but it raises the classic diamond problem: what happens when two parents define a method with the same name? Python settles it with the Method Resolution Order (MRO), the exact sequence it searches when looking up a method. Inspect it with print(Triathlete.__mro__), which prints Triathlete, then Swimmer, then Runner, then object.

One myth worth clearing up: the MRO is computed by an algorithm called C3 linearization, and C3 is not a plain depth-first search. Python used depth-first, left-to-right resolution before version 2.3, and replaced it precisely because depth-first produced inconsistent orderings in diamond hierarchies. C3 keeps the left-to-right order you declared parents in and guarantees every ancestor appears exactly once. Here Swimmer still wins over Runner because it's listed first, but once hierarchies get deeper, print __mro__ rather than guess.

Polymorphism: one interface, many behaviors

Polymorphism lets different classes respond to the same method call in their own way. In Python it shows up in two forms, and separating them matters because they solve different problems.

Method overriding happens inside an inheritance relationship. A subclass redefines a method it inherited so the behavior fits its context. A Dog and a Cat might both inherit a generic sound() from Animal, then override it. Standard and expected.

Duck typing is the more distinctly Python form, and it needs no shared parent. The name comes from the old line: if it walks like a duck and quacks like a duck, treat it as a duck. Python doesn't check an object's type before letting you use it, only whether the object has the method you're calling.

class Duck:
    def sound(self):
        print("Quack!")

class Dog:
    def sound(self):
        print("Woof!")

def make_it_speak(animal):
    animal.sound()

make_it_speak(Duck())
make_it_speak(Dog())

Duck and Dog share no inheritance at all, yet make_it_speak() works with both, because Python only cares that each object has a sound() method when it's called. For anyone coming from a strictly typed language, that's a real shift: behavior matters more than declared type.

Polymorphism also reaches into operator overloading, where you redefine how built-in operators behave for your own classes using special "dunder" methods:

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __repr__(self):
        return f"${self.amount}"

wallet1 = Money(50)
wallet2 = Money(30)
print(wallet1 + wallet2)  # $80

Without __add__, wallet1 + wallet2 throws an error, since Python has no idea how to add two custom objects. Defining it teaches + exactly what to do with Money objects.

Abstraction: hiding complexity behind a simple interface

Abstraction is about designing a clear contract: deciding what a class must be able to do, without dictating how each subclass does it internally. Python formalizes this with the abc (abstract base class) module, which lets you require that certain methods be implemented before a class can be used.

from abc import ABC, abstractmethod

class Notification(ABC):
    @abstractmethod
    def send(self, message):
        pass

    def log(self, message):
        print(f"[LOG] Sending: {message}")

class EmailNotification(Notification):
    def send(self, message):
        self.log(message)
        print(f"Email sent: {message}")

class SMSNotification(Notification):
    def send(self, message):
        self.log(message)
        print(f"SMS sent: {message}")

Notification can never be instantiated directly; Notification() raises a TypeError because Python knows it's incomplete by design. Any subclass that skips send() fails to instantiate too, which forces everyone on the project to honor the contract. Notice that log() is fully written in the abstract class. Abstract classes can mix working methods with ones that must be overridden, giving you shared logic and a required interface in one structure.

It's worth separating abstraction from encapsulation, since the two get confused. Encapsulation protects data by controlling who can read or change internal state. Abstraction simplifies behavior by defining what an object can do without exposing how. Calling sorted() is abstraction in daily use: you don't need to know that CPython sorts with an algorithm called Timsort under the hood, you just trust the result comes back sorted. The same principle drives well-designed hierarchies, where teams build against interfaces rather than specific implementations.

Instance methods vs class methods vs static methods

Instance methods vs class methods vs static methods in Python — self, cls, and no first parameter, with alternative constructors and utility functions.

Every method inside a Python class falls into one of three categories, and mixing them up is a common source of confusion. Each answers a different question: does this method need a specific object's data, does it need the class as a whole, or does it need neither?

Instance methods are the default. They take self and operate on one object's data. Class methods, marked with @classmethod, take cls instead and work with the class itself, which makes them ideal for alternative constructors. Static methods, marked with @staticmethod, receive neither; they behave like plain functions that happen to live inside the class because they're logically related to it.

class Pizza:
    def __init__(self, size, toppings):
        self.size = size
        self.toppings = toppings

    def describe(self):  # instance method
        print(f"{self.size}-inch pizza with {', '.join(self.toppings)}")

    @classmethod
    def margherita(cls):  # alternative constructor
        return cls(size=12, toppings=["tomato", "mozzarella", "basil"])

    @staticmethod
    def is_valid_size(size):  # standalone utility
        return size in (10, 12, 14, 16)

describe() needs self because it prints details specific to one pizza. margherita() doesn't need an object to exist yet, it creates one, which is why it uses cls. And is_valid_size() touches no instance or class data; it's a validation check that makes sense to group with Pizza.

One subtle detail: because class methods receive cls rather than a hardcoded class name, they respect inheritance. If you subclassed Pizza with StuffedCrustPizza, then StuffedCrustPizza.margherita() would return a StuffedCrustPizza, not a plain Pizza, because cls refers to whichever class the method was called on.

| Aspect | Instance method | Class method | Static method | |---|---|---|---| | Decorator | None | @classmethod | @staticmethod | | First parameter | self | cls | None | | Access to instance data | Yes | No | No | | Access to class data | Via self | Directly, via cls | No | | Typical use | Object-specific behavior | Alternative constructors, class-wide logic | Utilities related to the class |

Best practices, common mistakes, and where OOP shows up

Best practices, common mistakes, and where object-oriented programming shows up in real Python projects like Django, Flask, and Pandas.

Knowing the syntax is one thing; keeping object-oriented Python clean as a project grows is another, and it's where a lot of developers plateau. They can build classes, but the hierarchies turn tangled once a real application scales.

Best practices worth following

  • Favor composition over inheritance when the relationship isn't a strict "is-a." If a Car has an Engine, don't make Car inherit from Engine; give it an Engine object as an attribute. This keeps classes flexible and avoids fragile, overly deep hierarchies.
  • Keep each class focused on one responsibility. A class that handles authentication, sends email, and generates reports is a sign the design needs splitting.
  • Use @property instead of manual getters and setters wherever validation or controlled access is needed.
  • Write meaningful __repr__ methods. Debugging gets easier when printing an object shows useful information instead of a memory address.
  • Follow PEP 8 naming: classes in PascalCase, methods and attributes in snake_case.

Common mistakes to watch for

  • Forgetting self in an instance method definition, which throws a confusing error the first few times you meet it.
  • Using mutable default arguments like def __init__(self, items=[]), which silently shares one list across every instance that doesn't pass its own.
  • Reaching for inheritance purely to reuse code, even when the classes don't have a genuine "is-a" relationship. This is one of the most frequent design mistakes in real projects.
  • Forgetting to call super().__init__() in a subclass constructor, which leaves inherited attributes unset.
  • Overusing private attributes in simple scripts, where that restriction adds friction without real benefit.

Real Python projects built on OOP

Object-oriented Python isn't a teaching exercise. It's the backbone of tools you've already used. Django represents every database table as a class, where each row becomes an object with its own attributes and methods. Flask's class-based views group related request-handling logic in one class instead of scattering it across functions. Pandas, despite being a data-analysis library, is built entirely around objects: a DataFrame is an object with dozens of methods attached, which is why .groupby(), .merge(), and .plot() all work as method calls on your data.

Conclusion

Object-oriented programming in Python conclusion — structuring code around real entities with classes, the four pillars, and the three method types.

Object-oriented programming in Python lets you structure code around real entities instead of scattered variables and functions, which makes applications easier to build, extend, and maintain as they grow. Work through classes and objects, constructors, the four pillars, the three method types, and the common design mistakes, and you have a full mental model of how professional Python is organized.

The syntax settles in with practice. The harder skill is recognizing when a problem genuinely suits an object-oriented structure and when a plain function will do. Keep building small class-based projects and refactoring old procedural scripts, and the concepts stick far faster than reading about them ever could.

Frequently asked questions

Quick answers to the questions developers ask most about this topic.

What is object-oriented programming in Python used for?

It structures code around reusable objects that combine data and behavior, which makes larger applications easier to organize, extend, and maintain than a pile of loose functions.

Is Python a fully object-oriented language?

Python supports OOP fully but doesn't force it. You can write procedural, functional, or object-oriented code, and mix all three in one project.

What are the four pillars of OOP in Python?

Encapsulation, inheritance, polymorphism, and abstraction. Each handles a different part of organizing and protecting your code.

What's the difference between a class and an object?

A class is the blueprint that defines attributes and methods. An object is a specific instance created from that blueprint, holding its own real data.

Can a Python class inherit from more than one class?

Yes, that's multiple inheritance. Python resolves method conflicts between parents using the Method Resolution Order, computed by C3 linearization. Inspect it with `ClassName.__mro__`.

What's the difference between encapsulation and abstraction?

Encapsulation restricts access to an object's internal data. Abstraction hides implementation details and exposes only the behavior a caller needs.

Do I need OOP for every Python project?

No. Small scripts and simple automation often work fine procedurally. OOP earns its place once a project involves several related, stateful entities interacting.

Keep reading

Have a project in mind? Let's build it.

Tell us what you're working on. We'll reply within one business day with honest, practical next steps — no pressure, no jargon.