If you program in Python and want to take your code organization to the next level, SOLID principles are a game-changer. They're not a fad or an academic whim: they are five practical rules that help you write clearer software that is easier to test and much simpler to maintain over time.
Although they originated in the world of classic object-oriented programming (Java, C++, C#…), they can be applied without problem to Python and its object modelIn this tutorial, we'll take a step-by-step look, using examples, at how to understand and apply SOLID in Python: what each principle means, where it comes from, what real-world problems it solves, and how it translates into everyday classes, methods, and interfaces.
What are the SOLID principles and where did they come from?
The term SOLID is a well-known acronym in software design. It was popularized by Michael Feathers based on a set of principles defined by Robert C. Martin (Uncle Bob), Bertrand Meyer, and Barbara Liskov, among others. The idea was to condense into five letters a minimal guide for creating object-oriented designs that wouldn't become a nightmare as they grew.
These principles became famous as a result of Uncle Bob's essay “Design Principles and Design Patterns” and from books like CleanCode o Clean ArchitectureFurthermore, they fit very well with other clean coding rules such as DRY (Don't Repeat Yourself) o KISS (Keep It Simple, Stupid)all of them aimed at reducing complexity and unnecessary dependencies.
The name SOLID comes from the first letter of each principle in English, which you should memorize because you'll see them in any remotely serious conversation about object-oriented architecture:
- S – Single Responsibility Principle (SRP): principle of sole responsibility.
- O – Open/Closed Principle (OCP): open/closed principle.
- L – Liskov Substitution Principle (LSP)Liskov substitution principle.
- I – Interface Segregation Principle (ISP): principle of interface segregation.
- D – Dependency Inversion Principle (DIP): principle of dependency reversal.
Their common goal is to make your classes coherent, decoupled, and predictableFollowing them not only helps you write better new code, but also makes maintaining a project that grows over the years or that involves several teams working simultaneously much easier.
Why is it worthwhile to apply SOLID in Python?
Although Python is a flexible language that allows for very different styles, the SOLID principles provide a robust design base for any medium or large project. Adopting them usually translates into more agile deployments, fewer regressions when touching old code, and less "code smell" (that strange smell that tells you something is wrong even though it works).
Among the clearest benefits of applying SOLID in your Python developments are a greater clarity and legibility, and the use of Essential IDEs and editors This helps maintain it, since classes have well-defined responsibilities, interfaces are small, and modules don't know each other too well. This makes opening someone else's file not like reading hieroglyphics, but something reasonably understandable.
It's also very noticeable in the maintenance and evolution of the systemA solid design (literally) allows you to add new features by extending existing behaviors, not by breaking what's already there. This reduces the number of traumatic refactorings and the dreaded "spaghetti code" that no one dares touch without breaking three things, and allows you to work with best IDEs for programming It facilitates those refactorings.
Another key point is the reduction of errors and vulnerabilitiesFewer cross-dependencies mean fewer places where a silly change in one class impacts a completely different one. And when you have to fix bugs, finding the source of a problem in a module with a single responsibility is much easier.
Finally, following SOLID helps keep phenomena like code smell (bad designer smells), code rot (code that “rots” over time) or the appearance of “spaghetti code” full of hidden dependencies. In agile contexts, where many people work on the same code, these rules make the difference between a living system and one that ends up being almost unmodifiable.
S – Single Responsibility Principle (SRP) in Python

The Single Responsibility Principle states that Each class should have only one reason to change.This does not mean that a class can only have one method, nor that it only performs "one microtask," but rather that it must be responsible for a single, well-defined axis of change.
In practice, this means that a class should represent a clear entity or type of service: a system user, an order, a data repository, a report generator, a validator, etc. When you start mixing business logic, database access, report formatting, and email sending in the same class, you've crossed the line.
For example, imagine a Python class that represents a user and does a little bit of everything:
class User:
def __init__(self, name: str):
self.name = name
def get_user_from_database(self, user_id: int) -> dict:
# Lógica de acceso a base de datos
pass
def save_user_to_database(self) -> None:
# Lógica de persistencia
pass
def generate_user_report(self) -> str:
# Lógica de generación de informes
pass
Here the class is mixed domain model, persistence, and reportingChanges in any of these areas require modifying the same class, multiplying the risk of side effects and making isolated unit tests more difficult.
A more sensible application of SRP involves clearly separating responsibilities into several coordinated classes:
class User:
def __init__(self, name: str):
self.name = name
class UserDB:
@staticmethod
def get_user(user_id: int) -> User:
# Obtiene usuarios de la BBDD
return User("John Doe")
@staticmethod
def save_user(user: User) -> None:
# Guarda un usuario
pass
class UserReportGenerator:
@staticmethod
def generate_report(user: User) -> str:
return f"Report for user: {user.name}"
Now each class has one clear responsibilityIf the way to save users changes, you'll just need to tap UserDBIf the report format changes, you will adjust UserReportGenerator without risk of breaking persistence.
Another common example in Python is when a domain class is overloaded with object interaction. Consider this duck class with several capabilities:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self):
print(f"{self.name} is flying not very high")
def swim(self):
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
def greet(self, duck2: "Duck"):
print(f"{self.name}: {self.do_sound()}, hello {duck2.name}")
Here the class deals with both define the duck as well as the logic of communication between ducks. If you change the way they greet each other, you'll have to touch the very definition of Duck, which introduces a second reason to switch.
An SRP-compliant solution involves moving the conversational logic to a separate, dedicated class:
class Duck:
def __init__(self, name: str):
self.name = name
def fly(self):
print(f"{self.name} is flying not very high")
def swim(self):
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
class Communicator:
def __init__(self, channel: str):
self.channel = channel
def communicate(self, duck1: Duck, duck2: Duck):
sentence1 = f"{duck1.name}: {duck1.do_sound()}, hello {duck2.name}"
sentence2 = f"{duck2.name}: {duck2.do_sound()}, hello {duck1.name}"
conversation =
print(*conversation, f"(via {self.channel})", sep="\n")
Thanks to this separation, you can change the communication protocol creating other communicative classes or specific conversations, without having to rewrite the duck domain class itself.
O – Open/Closed Principle (OCP) in extensible designs
The Open/Closed Principle states that a software entity must be Open to expansion, but closed to modificationIn other words, you should be able to add new features without touching code that is already tested and in production.
In Python, this is usually achieved by combining Inheritance, abstract classes, and compositionThe trick is to design your classes so that new features arrive in the form of extensions (new subclasses, new injected objects), not as a cascade of if/elif about specific types that force you to edit the same module every time you add something.
A classic example is a poorly designed area calculator that inspects specific types:
class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
class Circle:
def __init__(self, radius: float):
self.radius = radius
class AreaCalculator:
def calculate_area(self, shape) -> float:
if isinstance(shape, Rectangle):
return shape.width * shape.height
elif isinstance(shape, Circle):
return 3.14159 * shape.radius * shape.radius
else:
raise ValueError("Forma no soportada")
This design violates OCP because each new geometric figure involves touching AreaCalculator and put another one in elifThe module is no longer "closed" to modifications.
By applying OCP, we move the area logic to each shape and work with a common abstraction:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
pass
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius * self.radius
class AreaCalculator:
def calculate_area(self, shape: Shape) -> float:
return shape.area()
If you need triangles tomorrow, you'll just need to create another subclass of Shape that implements area(), without touching the calculator:
class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
This idea can also be applied to the example of ducks and communication. Imagine you want different types of conversation without changing the basic communicative class. You can introduce a conversation abstraction and have the communicator execute it automatically:
from typing import final
class AbstractConversation:
def do_conversation(self) -> list:
pass
class SimpleConversation(AbstractConversation):
def __init__(self, duck1: Duck, duck2: Duck):
self.duck1 = duck1
self.duck2 = duck2
def do_conversation(self) -> list:
s1 = f"{self.duck1.name}: {self.duck1.do_sound()}, hello {self.duck2.name}"
s2 = f"{self.duck2.name}: {self.duck2.do_sound()}, hello {self.duck1.name}"
return
class Communicator:
def __init__(self, channel: str):
self.channel = channel
@final
def communicate(self, conversation: AbstractConversation):
print(*conversation.do_conversation(), f"(via {self.channel})", sep="\n")
The method communicate is Closed to changes and new forms of conversation are added by creating more subclasses of AbstractConversationYou don't need to revisit the communication class every time you want a different dialogue.
L – Liskov Substitution Principle (LSP) and well-understood inheritance
The Liskov Substitution Principle states that a subclass must be able to seamlessly replace your superclass anywhere the latter is used, without changing the expected behavior of the program.
Formally, the base class contract (return types, preconditions, postconditions, invariants) must continue to be fulfilled in derived classes. If a subclass breaks that contract—for example, by throwing unexpected exceptions or excessively restricting what can be done—the code that depends on the base class will begin to fail in subtle ways.
A typical example of LSP violation is naively modeling flying and non-flying birds:
class Bird:
def fly(self) -> None:
pass
class Duck(Bird):
def fly(self) -> None:
print("¡El pato está volando!")
class Ostrich(Bird):
def fly(self) -> None:
# Las avestruces no pueden volar
raise NotImplementedError("Las avestruces no pueden volar")
Any function that receives a Bird and call fly() hoping it works, it will fail if something happens to it. OstrichThe subclass is no longer a valid substitute from your father, so you've broken LSP.
A correct way to model it is to introduce a intermediate abstraction For birds that do fly:
class Bird:
pass
class FlyingBird(Bird):
def fly(self) -> None:
pass
class Duck(FlyingBird):
def fly(self) -> None:
print("¡El pato está volando!")
class Ostrich(Bird):
# No implementa fly() porque no vuela
pass
Now any function that works with FlyingBird knows he can call fly() without surprises, while a function that accepts a Bird generic should not assume that everyone flies.
Something similar happens with classic examples like rectangle and squareAt a mathematical level, a square is a rectangle, but at an object-oriented design level, this inheritance often causes problems because the restrictions of a square (all sides equal) make the height and width setters behave differently than expected by code that only knows rectangles.
A common strategy in these cases is Avoid forcing inheritance relationships just because they “seem” natural In the real world, in many domains it is better to have separate classes without direct inheritance and, if necessary, a more generic common abstraction.
I – Interface Segregation Principle (ISP) and small interfaces

The Interface Segregation Principle states that A customer should not be forced to rely on methods they do not use.In other words: it's better to have several small, specific interfaces than one large, generic interface full of methods that some classes don't need.
In Python, although you don't have interfaces like in Java, you can use abstract classes (ABC) or protocols (with typing.Protocol) to achieve the same effect: describe minimum capabilities that each implementation will actually use.
Let's first look at an example that violates ISP with an overly broad interface:
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def work(self) -> None:
pass
@abstractmethod
def eat(self) -> None:
pass
class Human(Worker):
def work(self) -> None:
print("El humano está trabajando")
def eat(self) -> None:
print("El humano está comiendo")
class Robot(Worker):
def work(self) -> None:
print("El robot está trabajando")
def eat(self) -> None:
# Los robots no comen, pero se ven obligados a implementar este método
pass
Class Robot It depends on a method irrelevant to its natureAlthough we've left it blank here, in more complex designs this leads to exceptions, inconsistent states, or false logic just to comply with the interface.
The ISP-friendly version separates capabilities into smaller interfaces, which are then combined as needed:
class Workable(ABC):
@abstractmethod
def work(self) -> None:
pass
class Eatable(ABC):
@abstractmethod
def eat(self) -> None:
pass
class Human(Workable, Eatable):
def work(self) -> None:
print("El humano está trabajando")
def eat(self) -> None:
print("El humano está comiendo")
class Robot(Workable):
def work(self) -> None:
print("El robot está trabajando")
The same pattern can be applied to the case of birds that fly and swim. Instead of an interface Bird which forces all subclasses to implement fly() y swim()You can segregate the hierarchy:
from abc import ABC, abstractmethod
class Bird(ABC):
def __init__(self, name: str):
self.name = name
@abstractmethod
def do_sound(self) -> str:
pass
class FlyingBird(Bird):
@abstractmethod
def fly(self):
pass
class SwimmingBird(Bird):
@abstractmethod
def swim(self):
pass
class Crow(FlyingBird):
def fly(self):
print(f"{self.name} is flying high and fast!")
def do_sound(self) -> str:
return "Caw"
class Duck(SwimmingBird, FlyingBird):
def fly(self):
print(f"{self.name} is flying not very high")
def swim(self):
print(f"{self.name} swims in the lake and quacks")
def do_sound(self) -> str:
return "Quack"
If you wanted to model a penguin, you would simply extend SwimmingBird without implementing fly()Because That interface is no longer imposed artificially..
D – Dependency Inversion Principle (DIP) and dependence of abstractions
The Dependency Inversion Principle can be summarized in two sentences: high-level modules should not depend on low-level modules; both must depend on abstractionsAnd abstractions should not depend on details, but rather details should depend on abstractions.
In practical terms, this means that your (high-level) business logic shouldn't be directly coupled to concrete implementations of databases, external APIs, file systems, etc. Instead, define interfaces (or abstract classes) that describe what you need and make the concrete implementations dependent on those abstractions.
A typical example of a DIP violation is a repository that directly instantiates its concrete database:
class MySQLDatabase:
def connect(self) -> None:
# Conectar a MySQL
pass
def query(self, sql: str) -> list:
# Ejecutar consulta
return []
class UserRepository:
def __init__(self):
self.database = MySQLDatabase() # Dependencia directa
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
Here's the repository. tied to MySQLIf you want to switch to PostgreSQL or use an in-memory database for testing, you'll be modifying the production code in the repository, which goes against OCP and makes the system less flexible.
By applying DIP, we introduce an abstraction Database and we make both MySQL and PostgreSQL implement it. The repository will then depend solely on that abstraction:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def connect(self) -> None:
pass
@abstractmethod
def query(self, sql: str) -> list:
pass
class MySQLDatabase(Database):
def connect(self) -> None:
# Conectar a MySQL
pass
def query(self, sql: str) -> list:
return []
class PostgreSQLDatabase(Database):
def connect(self) -> None:
# Conectar a PostgreSQL
pass
def query(self, sql: str) -> list:
return []
class UserRepository:
def __init__(self, database: Database):
self.database = database # Depende de una abstracción
def get_users(self) -> list:
return self.database.query("SELECT * FROM users")
This way of constructing objects, where You inject the dependencies from outside (constructor, setter or method parameter), is known as Dependency Injection and is the usual way to implement DIP in clean architectures.
The same approach can be applied to other contexts, such as the communication channel of our birds. Instead of a specific communicator creating its own message internally, SMSChannelYou can make any communicator depend on a channel abstraction:
from abc import ABC
from typing import final
class AbstractChannel(ABC):
def get_channel_message(self) -> str:
pass
class AbstractCommunicator(ABC):
def get_channel(self) -> AbstractChannel:
pass
@final
def communicate(self, conversation: AbstractConversation):
print(*conversation.do_conversation(),
self.get_channel().get_channel_message(),
sep="\n")
If in a specific implementation you do this:
class SMSChannel(AbstractChannel):
def get_channel_message(self) -> str:
return "(via SMS)"
class SMSCommunicator(AbstractCommunicator):
def __init__(self):
self._channel = SMSChannel()
def get_channel(self) -> AbstractChannel:
return self._channel
You still have a direct coupling between SMSCommunicator y SMSChannelTo fully respect DIP, you can create a simple communicator that receives the channel as an external dependency:
class SimpleCommunicator(AbstractCommunicator):
def __init__(self, channel: AbstractChannel):
self._channel = channel
def get_channel(self) -> AbstractChannel:
return self._channel
In this way, any new implementation of AbstractChannel (email, push, internal notification…) can be used without changing the communicator. Both high-level and low-level modules depend on abstractions., as the principle states.
When does it make sense to break or relax SOLID
Although we have portrayed the SOLID principles here as a kind of compass for object-oriented design, it is important to understand that they are guides, not immutable dogmasIn small scripts, quick prototypes, or simple automation tasks in Python, applying all these layers can be like using a cannon to kill flies.
Where they truly shine is in large, long-lived, or highly collaborative projectsMicroservices with multiple teams, complex data platforms, backends that will evolve over years, or software where quality and security are critical—these are the situations where the initial effort to carefully consider abstractions, separate responsibilities, design for extensibility, and invert dependencies truly pays off.
Ultimately, internalizing SOLID gives you a very useful vocabulary for reasoning about design and for detecting early code smells: classes that do everything, bloated interfaces, poorly planned inheritance hierarchies, or high-level modules glued to infrastructure details.
If you use these principles intelligently and with some common sense, your Python code will tend to be cleaner, more professional, and easier to maintain, even as the project grows and the people working on it change. Share this Python programming guide so other users can learn about SOLID principles.