May 27, 2025
Python OOP Classes

Python Classes and Instances: OOP Fundamentals

At some point in your Python journey, you hit a wall. Not a syntax wall, you know how to write loops, functions, and list comprehensions. The wall is a design wall. Your script that started as fifty lines is now five hundred. You've got functions calling functions, passing the same five variables back and forth like they're on a relay team, and you're starting to lose track of what belongs where. You paste a bug fix in one place and somehow break three other things. You realize you need a better way to organize your code, but you're not sure what that looks like yet.

That's the exact moment Object-Oriented Programming starts to make sense, not as an abstract academic concept, but as a practical solution to a real problem you're living with. OOP is a way of organizing code around things rather than around procedures. Instead of writing a function that operates on a dictionary, you write a class that bundles the dictionary and the functions together into one coherent unit. Instead of passing state around as arguments, you store it on an object. Instead of copy-pasting initialization logic ten times, you define it once and stamp out as many instances as you need.

This article is your ground-level introduction to Python's OOP system. We're going to start from the absolute basics, what a class is, how an instance differs from a class, what self actually means, and build up through attribute scoping, encapsulation, string representations, and a full practical example. Along the way, we'll also talk honestly about when you should not use classes, because overusing OOP is just as much a problem as underusing it. By the end, you'll have a solid mental model of how Python's class system works and, more importantly, the judgment to know when to reach for it. Let's get into it.

Table of Contents
  1. Why OOP Exists
  2. The `class` Keyword and `self`
  3. Self Explained
  4. Instance vs. Class Attributes
  5. Multiple Instances and Shared vs. Independent State
  6. Encapsulation and Name Conventions
  7. String Representations: `__repr__` and `__str__`
  8. A Practical Example: Building a Product Class
  9. Instance Methods, Class Methods, and Static Methods
  10. Common OOP Mistakes
  11. When NOT to Use Classes (The Important Part)
  12. Putting It Together: A Shopping Cart Example
  13. Key Takeaways
  14. Conclusion

Why OOP Exists

Before we write a single line of code, let's talk about why OOP exists, because if you don't understand the problem it solves, you'll use it incorrectly.

The State Problem: Real software deals with things that change over time. A user logs in, their is_logged_in flag flips. Their session expires, their role gets updated, their profile photo changes. Without a coherent structure, you end up with scattered variables, deeply nested dictionaries, or a long list of function arguments that you update in one place and forget to update in another. State is the root cause of most bugs, and OOP gives you a structured way to manage it in one place.

The Encapsulation Problem: Some data should be hidden from the outside world. A database object shouldn't expose its raw connection pool; it should expose clean methods like query() and fetch(). If outside code can reach in and mess with internal state directly, you lose the ability to guarantee that your object is always in a valid state. Encapsulation lets you protect the integrity of your data by controlling what outside code can and can't touch.

The Modeling Problem: Real systems have things, users, products, orders, sensors, payments. These things have properties (a product has a name and price) and behaviors (a product can be sold and restocked). Classes let you model those things directly in code. Your code ends up reading like the domain it describes, which means a new engineer can look at it and understand what's happening without a lengthy tour. With raw dictionaries, you can get close, but classes make the intent explicit.

The Reusability Problem: You need ten users, fifty products, a hundred orders. Without classes, you copy-paste the same initialization logic, the same validation checks, the same calculation methods everywhere. With classes, you define that logic once and instantiate it as many times as you need. You fix a bug in one place. You add a feature once and every instance benefits. That's not just cleaner code, it's code you can actually maintain over time.

That's what OOP gives you. Now let's build it from scratch.

The class Keyword and self

Here's the simplest possible class. It doesn't do much yet, but it's the foundation everything else builds on:

python
class User:
    pass
 
u = User()
print(u)  # <__main__.User object at 0x...>

You've created a class called User and instantiated it once. The variable u holds a specific instance of that class. They're different things. The class is the blueprint; the instance is the actual object built from that blueprint. You can think of the class like a cookie cutter and the instance like an actual cookie, the cutter defines the shape, and you can use it to stamp out as many cookies as you want, each one independent.

Now let's add some data so those instances actually mean something:

python
class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email
 
u1 = User("alice", "alice@example.com")
u2 = User("bob", "bob@example.com")
 
print(u1.username)  # alice
print(u2.username)  # bob

Here's what's happening, step by step. The __init__ method is the constructor. When you call User(...), Python creates a new instance and immediately calls __init__ on it, passing the arguments you provided. The self parameter refers to that newly created instance. When you write self.username = username, you're saying "attach the value of username to this specific instance." Then Python returns the instance to you. The variables u1 and u2 point to two completely separate objects in memory, Alice's username is independent from Bob's, and changing one won't affect the other.

Self Explained

If you're new to classes, self is probably the most confusing part. Let's slow down and make it concrete, because once you understand self, the rest of OOP clicks into place.

When you define a method inside a class, the first parameter is always self, and it represents the specific instance that the method was called on. It's not a keyword in Python; you could technically name it anything. Convention is self, and you should stick to that convention, but the name isn't magical. What's magical is the position: Python automatically passes the instance as the first argument to every instance method.

So when you write u1.greet(), Python translates that internally to User.greet(u1). The instance u1 gets passed as self. That's how the method knows whose data to work with. Without self, every method would be blind, it would have no way to reach the attributes stored on the specific instance you called it on.

Here's why this matters in practice. You can have a hundred User instances, each with a different username. When you call u1.greet(), the method uses self.username, and self is u1, so it reads Alice's username. When you call u2.greet(), self is u2, so it reads Bob's. Same method, same code, different instance, different result. That's the power: you define behavior once on the class, and it works correctly for every instance automatically. You don't write a greet_alice function and a greet_bob function, you write one greet method and let self do the routing.

Instance vs. Class Attributes

Here's where things get interesting, and where a lot of developers run into their first real OOP gotcha. You can attach data to instances, but you can also attach data to the class itself:

python
class User:
    role = "member"  # class attribute
 
    def __init__(self, username, email):
        self.username = username  # instance attribute
        self.email = email
 
u1 = User("alice", "alice@example.com")
u2 = User("bob", "bob@example.com")
 
print(u1.role)  # member
print(u1.username)  # alice
print(User.role)  # member (accessed on the class itself)

Both instances inherit the class attribute role. But here's the crucial bit you need to understand: if you set role on an individual instance, you create a new instance attribute that shadows the class attribute, the class attribute itself is untouched:

python
u1.role = "admin"
print(u1.role)  # admin (instance attribute shadows class attribute)
print(u2.role)  # member (class attribute, unchanged)
print(User.role)  # member (class attribute, unchanged)

Python's attribute lookup chain works like this: first check the instance's own attributes, then check the class's attributes, then check parent classes, and raise AttributeError if nothing is found. So when you access u1.role, Python finds the instance attribute you just set. For u2.role, there's no instance attribute, so Python climbs up to the class and finds the class attribute. Understanding this chain is essential, it explains a lot of behavior that otherwise seems magical or broken. Class attributes are great for defaults, constants, or shared configuration that all instances should start with. Instance attributes are for data that belongs to a specific individual instance.

Multiple Instances and Shared vs. Independent State

Here's a practical example that makes the distinction between class and instance attributes concrete, and shows you a common trap to avoid:

python
class BankAccount:
    interest_rate = 0.02  # class attribute: all accounts share this
 
    def __init__(self, owner, balance):
        self.owner = owner  # instance attribute
        self.balance = balance  # instance attribute
 
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
 
print(account1.balance)  # 1000
print(account2.balance)  # 500
print(account1.interest_rate)  # 0.02
print(account2.interest_rate)  # 0.02
 
# Change one instance's balance
account1.balance = 1500
print(account1.balance)  # 1500
print(account2.balance)  # 500 (unchanged)
 
# Change the class attribute
BankAccount.interest_rate = 0.03
print(account1.interest_rate)  # 0.03
print(account2.interest_rate)  # 0.03

Each instance has its own independent state: balance and owner. But they share the class attribute interest_rate. If the bank updates the interest rate, both accounts reflect it instantly when you set it on the class directly. That synchronized behavior is exactly what class attributes are designed for, configuration or constants that all instances should read from a single source of truth.

But watch what happens if you try to modify a class attribute through an instance, this is the trap:

python
account1.interest_rate = 0.05
print(account1.interest_rate)  # 0.05
print(account2.interest_rate)  # 0.02 (unchanged!)
print(BankAccount.interest_rate)  # 0.02 (class attribute unchanged!)

You didn't modify the class attribute. You created a new instance attribute on account1 that shadows it. Account2 and the class itself still have the original 0.02. If you wanted to change the rate for all accounts, you needed to write BankAccount.interest_rate = 0.05, not account1.interest_rate = 0.05. This trips up even experienced developers, so burn it into your memory: to change a class attribute, set it on the class, not on an instance.

Encapsulation and Name Conventions

Python doesn't have true privacy like Java or C++. There's no private keyword. But there are naming conventions that signal intent, and following them is how you write Python code that other developers can work with confidently:

python
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner  # public: okay to access directly
        self._balance = balance  # protected: signals "internal, but accessible"
        self.__pin = "1234"  # private: signals "keep out"
 
    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
 
account = BankAccount("Alice", 1000)
print(account.owner)  # alice (public)
print(account._balance)  # 1000 (protected, but accessible)
account.withdraw(200)
print(account._balance)  # 800

A single leading underscore on _balance signals "this is internal implementation, you can technically access it, but you probably shouldn't." It's a gentlemen's agreement between you and the next developer. A double underscore on __pin signals "this is private." Python doesn't actually hide it, it does something called name mangling under the hood, which renames the attribute to _BankAccount__pin. You can still access it if you really want to, but the double underscore makes it obvious you're deliberately breaking the contract:

python
print(account.__pin)  # AttributeError
print(account._BankAccount__pin)  # 1234 (Python's name mangling)

Name mangling exists to prevent accidental attribute name collisions in inheritance hierarchies, not to enforce strict privacy. Python trusts you to be responsible. The conventions are there to communicate intent, not to enforce it.

Real code uses these conventions to keep the public interface clean while hiding implementation complexity:

python
class DatabaseConnection:
    def __init__(self, host, port, password):
        self._host = host  # internal
        self._port = port  # internal
        self._password = password  # sensitive; keep out
        self._connection = None  # internal state
 
    def connect(self):
        # implementation details hidden; you call this
        self._connection = self._open_connection()
 
    def execute(self, query):
        # users interact with this public method
        return self._connection.execute(query)
 
    def _open_connection(self):
        # internal helper; signals "don't call this directly"
        pass

Users of this class interact with connect() and execute(). They don't touch _host, _password, or _connection directly. The underscore convention makes it immediately clear what's part of the public interface and what's internal implementation detail. This is encapsulation in practice, not enforced by the language, but communicated by the code.

String Representations: __repr__ and __str__

When you print an instance without any special handling, Python gives you something completely unhelpful:

python
class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email
 
u = User("alice", "alice@example.com")
print(u)  # <__main__.User object at 0x7f8b8c0c1a90>

That memory address tells you nothing useful when you're debugging at 11pm trying to figure out why Alice's account is broken. Two magic methods fix this, and you should add them to every class you write: __repr__ and __str__.

__repr__ is for developers. It should return a string that looks like the Python code you'd use to recreate the object:

python
class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email
 
    def __repr__(self):
        return f"User({self.username!r}, {self.email!r})"
 
u = User("alice", "alice@example.com")
print(repr(u))  # User('alice', 'alice@example.com')
print(u)  # <__main__.User object at 0x...> (no __str__ defined)

__str__ is for end users. It should return a human-readable string that presents the object in a friendly way:

python
class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email
 
    def __repr__(self):
        return f"User({self.username!r}, {self.email!r})"
 
    def __str__(self):
        return f"{self.username} <{self.email}>"
 
u = User("alice", "alice@example.com")
print(repr(u))  # User('alice', 'alice@example.com')
print(str(u))  # alice <alice@example.com>
print(u)  # alice <alice@example.com> (print() calls __str__)

If __str__ isn't defined, Python falls back to __repr__. But good practice is to define both. __repr__ is your debugging lifeline, it tells you exactly what object you're looking at. __str__ is for when you're logging, displaying to users, or generating output. Think of __repr__ as the technical label and __str__ as the display name.

A Practical Example: Building a Product Class

Let's build something more realistic that shows all these concepts working together, a product with a price, inventory, discounts, and tax calculation. Pay attention to how each design choice follows from the principles we've covered:

python
class Product:
    """Represents a product in an e-commerce system."""
 
    # Class attribute: tax rate applies to all products
    tax_rate = 0.08
 
    def __init__(self, name, price, quantity):
        # Instance attributes
        self.name = name
        self.price = price
        self.quantity = quantity
        self._discount = 0  # private: managed via apply_discount()
 
    def apply_discount(self, percent):
        """Apply a discount as a percentage."""
        if not 0 <= percent <= 100:
            raise ValueError("Discount must be between 0 and 100")
        self._discount = percent
 
    def final_price(self):
        """Calculate final price after discount and tax."""
        discounted = self.price * (1 - self._discount / 100)
        return discounted * (1 + self.tax_rate)
 
    def in_stock(self):
        """Check if product is available."""
        return self.quantity > 0
 
    def sell(self, amount):
        """Reduce inventory after a sale."""
        if amount > self.quantity:
            raise ValueError("Not enough inventory")
        self.quantity -= amount
 
    def __repr__(self):
        return f"Product({self.name!r}, ${self.price})"
 
    def __str__(self):
        status = "In Stock" if self.in_stock() else "Out of Stock"
        return f"{self.name} - ${self.final_price():.2f} ({status})"
 
# Create instances
laptop = Product("MacBook Pro", 1500, 10)
mouse = Product("Magic Mouse", 79, 0)
 
print(laptop)  # MacBook Pro - $1620.00 (In Stock)
print(mouse)  # Magic Mouse - $85.32 (Out of Stock)
 
laptop.apply_discount(10)
print(f"Laptop after 10% discount: ${laptop.final_price():.2f}")  # $1458.00
 
laptop.sell(2)
print(f"Remaining laptops: {laptop.quantity}")  # 8

Notice the deliberate structure here: attributes are set up in __init__, methods operate on those attributes, and _discount is protected through apply_discount() rather than being directly accessible. This means you can add validation logic, like making sure discounts are between 0 and 100, in exactly one place, and it applies no matter who calls the method. The tax_rate class attribute means if the tax rate changes, you update it in one place and every product instance immediately uses the new rate. The __str__ and __repr__ methods make debugging painless. This is OOP doing its job.

Instance Methods, Class Methods, and Static Methods

We've been using instance methods throughout this article, they receive self and operate on the data of a specific instance:

python
class User:
    def __init__(self, username):
        self.username = username
 
    def greet(self):  # instance method
        return f"Hello, {self.username}"
 
u = User("alice")
print(u.greet())  # Hello, alice

But sometimes you need methods that work on the class itself rather than on any particular instance. That's where class methods come in, and they're particularly useful as alternative constructors or for tracking class-level state:

python
class User:
    count = 0  # track how many users exist
 
    def __init__(self, username):
        self.username = username
        User.count += 1  # increment class attribute
 
    @classmethod
    def total_users(cls):  # class method receives class as first arg
        return cls.count
 
u1 = User("alice")
u2 = User("bob")
print(User.total_users())  # 2

The @classmethod decorator tells Python "this method receives the class, not the instance." You can call it on the class directly, and the class itself is passed as the first argument, conventionally named cls rather than self. This is useful any time you need a method that should work at the class level rather than on a specific instance.

Static methods don't receive either self or cls. They're essentially regular functions that happen to be grouped with the class for organizational purposes:

python
class Math:
    @staticmethod
    def add(a, b):
        return a + b
 
    @staticmethod
    def multiply(a, b):
        return a * b
 
print(Math.add(3, 4))  # 7
print(Math.multiply(3, 4))  # 12

Static methods are less common than instance and class methods, but they're useful for utility functions that belong logically with a class even though they don't need to access instance or class state. If you find yourself writing a method that never uses self or cls, it's probably a candidate for @staticmethod. We'll go much deeper on all three method types in the next article in this series, there's a lot more to say about when to use each one.

Common OOP Mistakes

Even experienced developers make predictable mistakes with OOP. Knowing these ahead of time will save you hours of debugging.

Mutable class attributes are the most common trap. If you use a list or dictionary as a class attribute, all instances share the exact same object, not copies of it. So if one instance modifies the list, every instance sees the change. This is almost never what you want:

python
class Cart:
    items = []  # WRONG: all instances share this same list!
 
    def add(self, item):
        self.items.append(item)  # modifies the shared class attribute
 
c1 = Cart()
c2 = Cart()
c1.add("laptop")
print(c2.items)  # ['laptop'], oops, Bob sees Alice's laptop!

The fix is to always initialize mutable attributes in __init__, never as class attributes:

python
class Cart:
    def __init__(self):
        self.items = []  # CORRECT: each instance gets its own list

Forgetting self is another classic, trying to call a method or access an attribute without self. prefix means you're looking for a local variable that doesn't exist, not the instance attribute you intended.

Overusing classes for stateless operations bloats your code unnecessarily. A class with one method that doesn't use self is just a function with extra ceremony. Keep it simple.

Not defining __repr__ might seem minor, but the moment you're debugging with a list of twenty objects printing as <__main__.Product object at 0x...>, you'll wish you'd defined it from the start. Make it a habit, always define __repr__.

When NOT to Use Classes (The Important Part)

Here's what most tutorials won't tell you: you don't always need classes, and the instinct to wrap everything in a class is one of the most common over-engineering mistakes you'll encounter in Python code.

Don't use a class for a single function. If you're tempted to write:

python
class Calculator:
    def add(self, a, b):
        return a + b
 
calc = Calculator()
result = calc.add(3, 4)

Just write a function:

python
def add(a, b):
    return a + b
 
result = add(3, 4)

The function is simpler, faster, and easier to test. No instantiation needed.

Don't use a class for simple data storage. If you're writing:

python
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

And never adding methods, use a dictionary or a namedtuple:

python
from typing import NamedTuple
 
class Point(NamedTuple):
    x: float
    y: float
 
p = Point(3, 4)
print(p.x)  # 3

NamedTuple gives you immutability, cleaner syntax, and automatic __repr__ and __eq__. Simpler is better.

Don't use a class to avoid function parameters. This is tempting:

python
# Bad: using class to avoid passing state around
class DataProcessor:
    def __init__(self, data):
        self.data = data
 
    def filter(self):
        self.data = [x for x in self.data if x > 0]
 
    def transform(self):
        self.data = [x * 2 for x in self.data]
 
processor = DataProcessor([1, -2, 3, -4])
processor.filter()
processor.transform()

This is harder to test and reason about than:

python
# Good: pure functions, explicit data flow
def filter_positive(data):
    return [x for x in data if x > 0]
 
def transform_double(data):
    return [x * 2 for x in data]
 
data = [1, -2, 3, -4]
data = filter_positive(data)
data = transform_double(data)

The functional approach is clearer, more testable, and easier to follow. Each function is a single responsibility with no hidden state.

Use a class when:

  • You have related data and behaviors (a User has attributes and methods)
  • You need multiple instances with independent state (ten different shopping carts)
  • You want to hide implementation details (encapsulation matters)
  • You're modeling a domain concept (a DatabaseConnection, a Logger, a PaymentProcessor)
  • You plan to extend the class through inheritance (we'll cover that soon)

Use a function or module when:

  • You're doing a one-off operation
  • Data doesn't change or is immutable
  • Logic is stateless and pure
  • You want maximum simplicity and testability

Code should be simple enough to understand at first glance. If a class makes your code more complex, not less, reconsider.

Putting It Together: A Shopping Cart Example

Here's where classes shine, modeling a real domain concept with multiple instances that each have their own independent state and behavior. This is a complete example that pulls together everything we've covered:

python
class ShoppingCart:
    """Represents a user's shopping cart."""
 
    def __init__(self, user_id):
        self.user_id = user_id
        self._items = {}  # product_id -> quantity
 
    def add(self, product_id, quantity=1):
        """Add items to cart."""
        if product_id in self._items:
            self._items[product_id] += quantity
        else:
            self._items[product_id] = quantity
 
    def remove(self, product_id):
        """Remove a product entirely."""
        if product_id in self._items:
            del self._items[product_id]
 
    def clear(self):
        """Empty the cart."""
        self._items = {}
 
    def item_count(self):
        """Total number of items."""
        return sum(self._items.values())
 
    def __repr__(self):
        return f"ShoppingCart(user={self.user_id}, items={self.item_count()})"
 
# Multiple independent instances
cart1 = ShoppingCart("alice")
cart2 = ShoppingCart("bob")
 
cart1.add("laptop", 1)
cart1.add("mouse", 2)
cart2.add("monitor", 1)
 
print(cart1)  # ShoppingCart(user=alice, items=3)
print(cart2)  # ShoppingCart(user=bob, items=1)
 
print(cart1._items)  # {'laptop': 1, 'mouse': 2}
print(cart2._items)  # {'monitor': 1}

Each cart is completely independent. Alice's cart doesn't affect Bob's. The class encapsulates the logic of managing items cleanly, you can extend it with discount handling, tax calculation, shipping estimates, and inventory checks, all contained within the class. That's the payoff of the OOP approach: as complexity grows, your code stays organized rather than sprawling into a tangle of functions passing dictionaries around.

Key Takeaways

  • Classes are blueprints; instances are actual objects. A class defines structure; an instance is a specific thing built from that blueprint.
  • __init__ is the constructor. It runs when you create an instance and sets up initial attributes.
  • self refers to the instance. Every instance method receives self automatically, it's how the method knows whose data to operate on.
  • Instance attributes are independent; class attributes are shared. Multiple instances can have their own username, but they all share tax_rate until you shadow it.
  • Encapsulation protects data integrity. Use underscore conventions to signal which attributes are internal and shouldn't be touched directly.
  • __repr__ and __str__ make instances debuggable. Define them in every class you write, your future self will thank you at 11pm when something breaks.
  • Not everything needs a class. Use classes for modeling domain concepts with state and behavior. Use functions for stateless operations. Use namedtuples or dataclasses for simple data containers.

Conclusion

OOP is one of those concepts that sounds intimidating on paper but makes immediate sense the moment you're dealing with a real problem it solves. The core insight is simple: bundle related data and the functions that operate on it into a single, reusable unit. Give that unit a clear interface. Protect its internal state. Make it easy to stamp out as many copies as you need.

We covered a lot of ground here, the class and instance distinction, how self actually works under the hood, the difference between instance and class attributes and the lookup chain that connects them, encapsulation through naming conventions, __repr__ and __str__ for human-readable output, and the full practical example of a Product class that ties it all together. We also covered the common mistakes, mutable class attributes, forgetting self, over-engineering with classes where functions would do, because knowing what not to do is just as important as knowing what to do.

The honest truth about OOP is that it rewards you most when you use it to model the real things in your domain: the users, the products, the carts, the connections. When you catch yourself writing user["email"] ten times across five files, that's your cue that a User class would serve you better. When you have one function and no shared state, skip the class and keep it simple. Use the right tool for the job, and OOP becomes a superpower rather than a burden.

In the next article, we go deeper into the three kinds of methods, instance, class, and static, because the difference between them is subtle but important, and knowing when to use each one is what separates clean OOP code from confused OOP code.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project