When two parts of your code are inseparable, changing one part forces you to change the other. That is tight coupling. When the same two parts can change independently, that is loose coupling. Learning to tell the difference — and knowing which to use — is one of the skills that separates workable Python code from fragile Python code.
Think of coupling as the degree to which one piece of code relies on the internal details of another. High coupling means the two pieces know too much about each other. Low coupling means they communicate only through a simple, well-defined interface and do not care what is happening on the other side. This concept applies whether you are writing functions, classes, or whole modules.
What Coupling Means in Python
In Python, coupling appears most clearly when one class creates an instance of another class directly inside itself. That act of creation locks the two classes together. The class doing the creating now depends on a specific other class — its name, its constructor signature, its behavior. If the other class changes, the first class breaks.
Coupling is not inherently bad. Some coupling is unavoidable, and some is intentional. The goal is not to eliminate all coupling but to make sure dependencies are explicit, limited, and easy to replace. When coupling is too tight, your code becomes difficult to test, difficult to extend, and difficult to hand off to someone else.
Coupling describes a relationship between code units. It is not a bug or an error. It is a design quality that exists on a spectrum, and your goal is to understand where your code sits on that spectrum and why.
Python makes it easy to write tightly coupled code by accident, especially when you are learning. The language does not enforce any separation by default. You can call any class from any other class with no warnings. That freedom is powerful, but it means the responsibility for keeping dependencies manageable falls entirely on the programmer.
Build a tightly coupled class that creates its own Logger directly inside __init__. Place the tokens in the correct order:
__init__ starts with the def __init__(self): signature, then assigns self.logger = Logger(). The class hardcodes its own dependency by calling Logger() directly — there is no way to swap it out from the outside.
Tight Coupling Up Close
Here is a common example of tight coupling. A Notifier class wants to send messages. To do that it needs an EmailSender object. The tightly coupled version creates that EmailSender inside its own constructor:
# Tight coupling example
class EmailSender:
def send(self, message):
print(f"Sending email: {message}")
class Notifier:
def __init__(self):
# Notifier creates its own EmailSender — tightly coupled
self.sender = EmailSender()
def notify(self, message):
self.sender.send(message)
notifier = Notifier()
notifier.notify("Your order has shipped.")
This looks fine at first glance. It works. But consider what happens when requirements change. What if you need to send SMS messages instead of emails? You would have to open the Notifier class and change the line that creates EmailSender. Then what if you need to test Notifier without actually sending emails? You cannot, because the email sender is buried inside the class and you have no way to replace it.
Tight coupling makes unit testing painful. When a class builds its own dependencies, your tests run real code that may have side effects — sending emails, writing files, hitting databases. You cannot isolate the unit you want to test.
Another sign of tight coupling is when changing the name or constructor of EmailSender requires you to update every class that instantiates it. That ripple effect is one of the most common sources of bugs in growing codebases.
This class is meant to be loosely coupled, but one line reintroduces tight coupling. Find it.
self.sender = sender. The constructor accepts sender as a parameter but then ignores it and creates a new EmailSender() anyway. The parameter is never used, so the class is still tightly coupled to EmailSender despite appearing to accept an injection.
Loose Coupling Up Close
The loosely coupled version of the same code does not create its own EmailSender. Instead, it receives a sender object from the outside. This pattern is called dependency injection.
# Loose coupling example — dependency injection
class EmailSender:
def send(self, message):
print(f"Sending email: {message}")
class SmsSender:
def send(self, message):
print(f"Sending SMS: {message}")
class Notifier:
def __init__(self, sender):
# The sender is passed in — loosely coupled
self.sender = sender
def notify(self, message):
self.sender.send(message)
# Pass in whichever sender you need
email_notifier = Notifier(EmailSender())
email_notifier.notify("Your order has shipped.")
sms_notifier = Notifier(SmsSender())
sms_notifier.notify("Your order has shipped.")
Notifier no longer knows or cares what kind of sender it has. It only knows that the sender has a send method. This is Python's duck typing in action: if the object passed in has a send method, it works. No inheritance required, no formal interface declaration required.
For testing, you can pass a fake sender that does nothing real. This lets you test Notifier without sending actual messages. That kind of substitution is only possible when code is loosely coupled.
Here is what a test-friendly fake sender looks like:
# A fake sender used in tests — no real messages are sent
class FakeSender:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message)
# Test without side effects
fake = FakeSender()
notifier = Notifier(fake)
notifier.notify("Test message")
assert fake.sent == ["Test message"]
print("Test passed.")
This works because Notifier does not check the type of its sender. It calls .send() and trusts that the method exists. FakeSender has that method, so it works perfectly in tests.
Comparing the Two Approaches
The table below summarizes how tight coupling and loose coupling compare across the qualities that matter to a working programmer. Tap any row to expand the detail.
- Tight coupling
- Hard to test in isolation. Real dependencies run during tests, often causing side effects like emails or database writes.
- Loose coupling
- Easy to test. You pass in a fake dependency that records calls without doing real work.
- Tight coupling
- Swapping one component requires editing the class that depends on it. Multiple classes may need changes for a single swap.
- Loose coupling
- You pass in a different object at the call site. The class itself does not change at all.
- Tight coupling
- Dependencies are hidden inside the class. A reader must look inside to understand what the class needs.
- Loose coupling
- Dependencies appear in the function signature or constructor. They are visible and documented automatically.
- Tight coupling
- Difficult to reuse in a different context because the class brings its dependencies with it whether you want them or not.
- Loose coupling
- Easy to reuse. The class works with any compatible dependency, so it can be dropped into new projects without modification.
- Tight coupling
- Small scripts, one-off tools, or prototype code where simplicity matters more than future flexibility.
- Loose coupling
- Any production code, any shared library, any class you plan to test, or any code that may need to change over time.
How to Write Loosely Coupled Python Code
Converting tightly coupled code to loosely coupled code follows a consistent pattern. These four steps apply whether you are refactoring an existing class or designing a new one from scratch.
-
Identify the hard-coded dependency
Look for any line inside a class where you call another class by name to create an instance. A line like
self.sender = EmailSender()inside__init__is the signal. That is where the tight coupling lives. -
Accept the dependency as a parameter
Change
def __init__(self)todef __init__(self, sender). Remove the line that creates the object, and replace it withself.sender = sender. The class now receives its dependency rather than creating it. -
Pass the dependency from the outside
At the point where you create the class, pass in the object you want:
Notifier(EmailSender()). This is now explicit. Anyone reading the code can immediately see whatNotifierdepends on, and can pass in something different whenever needed. -
Rely on the interface, not the class name
Inside your class methods, only call the methods you actually need. Do not use
isinstance()to check what type the dependency is. Write your code so that anything with the right method works. This is duck typing, and it is what makes Python's loose coupling so practical.
Loose Coupling with Functions as Arguments
Loose coupling does not only apply to classes. Functions can be loosely coupled too. Instead of calling a specific function by name, you pass the function in as an argument. This is a common Python pattern and is even simpler than class-based injection.
# Tightly coupled: process() always writes to a file
def process_tight(data):
with open("output.txt", "w") as f:
f.write(str(data))
# Loosely coupled: process() uses whatever writer you give it
def process_loose(data, writer):
writer(str(data))
# Use it with a real file writer
def file_writer(content):
with open("output.txt", "w") as f:
f.write(content)
# Use it with a test writer that does nothing
def null_writer(content):
pass # Used in tests — no file created
process_loose("hello", file_writer) # writes to file
process_loose("hello", null_writer) # does nothing — safe for tests
process_loose("hello", print) # prints to console
Passing print directly as the writer is a useful debugging trick. Python functions are first-class objects, meaning they can be passed around just like any other value. This flexibility makes function-based loose coupling feel natural in Python.
"Explicit is better than implicit." — The Zen of Python, Tim Peters
That principle applies directly here. Loose coupling makes dependencies explicit by putting them in the function signature or class constructor, where they are visible to every caller and readable by every developer.
Python Learning Summary Points
- Tight coupling occurs when a class creates its own dependencies inside itself. Loose coupling occurs when dependencies are passed in from the outside.
- Dependency injection is the most common technique for achieving loose coupling in Python. Pass objects or callables into constructors or functions rather than creating them internally.
- Python's duck typing supports loose coupling naturally. A class does not need to know the type of its dependency — only that the dependency has the methods it needs.
- Loosely coupled code is easier to test because you can substitute fake objects for real ones. Tightly coupled code forces real dependencies to run during tests.
- Loose coupling does not mean zero coupling. Some coupling is necessary and correct. The goal is to make dependencies explicit, limited in scope, and substitutable.
Coupling is one of those design concepts that clicks once you have seen it cause a real problem. A class that was simple to write becomes a headache to change, and when you trace the problem back, you find a hard-coded dependency buried six layers deep. Starting to think about coupling early — even in small projects — builds habits that pay off as code grows.
Frequently Asked Questions
Tight coupling occurs when one class or function directly creates and depends on another specific class. This makes the code hard to change, test, and reuse because both pieces are locked together. A common sign is seeing self.x = SomeClass() inside a constructor.
Loose coupling means a class or function depends on an abstraction or a passed-in object rather than creating its own dependencies. Changes to one part do not force changes in another. The two most common ways to achieve it in Python are passing objects into constructors and passing callables as arguments.
Loose coupling is generally preferred in larger codebases and anywhere you plan to write tests. For small, single-purpose scripts where flexibility is not needed, tight coupling may be acceptable and simpler to read. The decision depends on context, not a fixed rule.
Dependency injection is a technique where an object receives its dependencies from the outside rather than creating them itself. In Python this is typically done by passing objects or callables into a class constructor or function. It is the primary mechanism for achieving loose coupling.
You can reduce tight coupling by using dependency injection, passing callables as arguments, relying on duck typing instead of hard-coded class names, and defining shared behavior through Python protocols or abstract base classes from the abc module.
Duck typing means Python checks whether an object has the required method or attribute rather than checking its type. This supports loose coupling because your code works with any object that provides the expected interface. You do not need to declare which class an object belongs to — only that it has the methods you need.
Python does not have formal interfaces, but you can achieve the same result using abstract base classes from the abc module, or by relying on duck typing and Protocol classes from the typing module. In practice, duck typing alone is often sufficient for loose coupling in Python.
Once you start writing classes that interact with each other, or functions that call specific other functions by name, it is worth asking whether those dependencies are necessary or whether they could be passed in instead. You do not need to apply these patterns everywhere, but being aware of coupling from the start prevents some of the most common code-quality problems.