Python Design Patterns — Singleton Connection Pool Leak
After a microservice deployment, a Singleton duplicate pool opened 3x connections — find out why dynamic imports broke the pattern and fix it..
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- Design patterns are reusable solutions to recurring software design problems
- Singleton: one instance per process, often for configuration or connection pooling
- Factory: decouples object creation from business logic, enabling swap of implementations
- Observer: publish-subscribe that keeps components loosely coupled but can leak memory
- Strategy: lets you swap algorithms at runtime, reduces if/elif chains
- Performance insight: Singleton with module-level caching beats class-based singleton by ~40% due to no lock overhead
- Production insight: Observer with weak references still leaks if callbacks hold closures over large object graphs
- Biggest mistake: using Singleton as a global variable — it hides dependencies and kills testability
Imagine you're building a LEGO city. Instead of figuring out from scratch how to build every house, shop, and bridge, LEGO gives you instruction booklets — proven blueprints that always work. Python design patterns are exactly that: battle-tested blueprints for solving common software problems. You don't have to invent the wheel every time a new coding challenge appears. You just pick the right booklet, adapt it to your city, and keep building.
Every senior Python developer has felt it — that sinking moment when a codebase that worked perfectly at 1,000 users starts buckling at 100,000. New engineers can't follow the logic, a shared resource keeps getting instantiated twenty times, and event-driven code turns into an untraceable chain of callbacks. Design patterns are the vocabulary that lets experienced engineers name these problems and reach for proven solutions without reinventing them under pressure. They're not academic exercises — they're the difference between a codebase you're proud to hand off and one you're embarrassed to demo.
The real problem design patterns solve isn't complexity — it's communication and entropy. As a codebase grows, the same structural problems appear over and over: how do you ensure exactly one database connection pool exists? How do you add features to an object without editing its class? How do you notify dozens of components when state changes without coupling them tightly together? Without patterns, every developer on your team invents a slightly different answer, and the codebase quietly fractures. Patterns give you a shared language and a proven structure before the chaos sets in.
By the end of this article you'll understand the internal mechanics of Singleton, Factory, Observer, Strategy, and Decorator patterns in Python — not just the textbook definitions but the gotchas that bite production systems, the performance trade-offs that matter at scale, and how Python's own language features (metaclasses, descriptors, __call__, functools) interact with these patterns in ways that surprise even experienced devs. You'll be able to choose the right pattern for the right problem and explain your reasoning in a technical interview.
Why Python Design Patterns Are Not Templates
Python design patterns are reusable solutions to recurring architectural problems, but they are not templates you copy-paste. They are conceptual frameworks that exploit Python's dynamic typing, first-class functions, and duck typing to solve specific coupling, scalability, or maintainability issues. The core mechanic is separating what varies from what stays the same — encapsulating the variation behind a stable interface.
In practice, patterns in Python often collapse into simpler constructs. A Strategy pattern becomes a function passed as an argument; an Observer becomes a callback list. The key property is that patterns are not about class hierarchies — they are about communication structures between objects. Python's protocols (__enter__, __iter__, etc.) let you implement patterns with less boilerplate than in statically typed languages, but the same trade-offs apply: indirection costs readability, and over-engineering is the most common failure mode.
Use design patterns when you have a known, repeating structural problem — not because a book says so. In production systems, the Singleton pattern is the most misapplied: teams use it for connection pools, then hit thread-safety bugs and memory leaks because they ignored Python's module-level singletons (which are already singletons) or used a naive __new__ override without considering GIL interactions or garbage collection cycles.
close() and context managers, or the pool will leak silently.Why Design Patterns Matter in Python — Beyond the Hype
Design patterns are not about fancy UML diagrams or memorising GOF names. In Python, many patterns are built into the language itself: decorators, generators, context managers, and even the module system. But that doesn't make the patterns obsolete. It makes them more powerful — and more dangerous when applied carelessly.
Take the Singleton pattern. Python's module system gives you a Singleton for free: a module is imported once, and its top-level names persist for the process lifetime. But many teams still write a class-based Singleton and get it wrong — they forget thread safety, or they assume the initialisation in __new__ runs only once when it actually runs on every instance creation attempt. The result? A Singleton that creates multiple instances under concurrent load.
Patterns in Python aren't about how to implement them — they're about when to use them and how to adapt them to Python's idioms. The real value comes from understanding the trade-offs: a module-level Singleton is simple but untestable; a dependency-injected Singleton is testable but requires a container. Knowing which trade-off to accept for your context is what separates senior engineers from juniors.
- Without patterns, every code review turns into a debate: 'Why did you put this logic here?' With patterns, you say: 'This is the Factory pattern. It lives in the factory module.'
- Patterns define boundaries: Singleton for lifecycle management, Observer for event propagation, Strategy for algorithm selection.
- Python's dynamic nature means you can often implement patterns with fewer lines than in statically typed languages, but the trade-off is less compile-time safety.
Singleton Pattern — The One You Think You Know
Singleton ensures a class has only one instance and provides a global point of access. In Python, this seems trivial: just create a module-level object. But the devil is in the details. A thread-safe Singleton requires careful initialisation, and the classic double-checked locking pattern is tricky to get right because of Python's GIL and object ordering.
Let's break down the production-grade Singleton. The key is that __new__ is called every time you instantiate the class — even if the instance already exists. The common mistake is to put the instance check in __init__ instead of __new__, which means the constructor still runs on every call, potentially resetting state.
A better approach for most Python applications is to use a module-level function with @lru_cache. This gives you a thread-safe, lazily initialised singleton without locks. But it comes with a caveat: you cannot easily clear the cache for testing without accessing the wrapped function's cache clear method. Use this for objects that truly live for the entire process lifetime, like a configuration object that never changes after startup.
get_settings directly cannot be reused in a different context. Always ask: does this need to be unique per entire process, or just per request/thread?__new__ is slower than lru_cache by about 15% due to method overhead.lru_cache, but not for __new__.Factory Pattern — Object Creation Without the Pain
The Factory pattern separates object creation from business logic. In Python, this is often as simple as a function that returns different types based on input. The GoF Factory Method and Abstract Factory patterns translate naturally: Python's dynamic typing and duck typing mean you don't need complex interface hierarchies. A Factory can be a single function or a class hierarchy with a common interface.
The real power of Factory in Python comes with configuration-driven creation. You can map string keys to classes and instantiate them at runtime. This is how many frameworks (like Celery or Django's backends) work. But watch out for circular imports: if the factory function imports all possible classes, you may introduce a circular dependency. A pattern that works well is to use a lazy registry: class definitions register themselves in a dict at import time, and the factory only needs to know the dict.
Performance note: Factory dispatch via a dict is O(1) and preferable to a long if/elif chain. Use functools.singledispatch for function overloading based on argument type, but avoid using it when you need string-based dispatch for configuration.
typing.Protocol with structural subtyping (duck typing) allows you to define the interface without requiring inheritance. This decouples the factory from the concrete classes. No need to import all subclasses upfront.KeyError due to a typo in the configuration key.Observer Pattern — Event-Driven Without the Memory Leak
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. Python has several built-in mechanisms for this: property setters can trigger callbacks, and the blinker library or PyPubSub provide publish-subscribe. But the classic GoF Observer often leads to memory leaks in Python because observers hold strong references to subjects, or vice versa.
In Python, the biggest gotcha is the __del__ method. If a subject holds a strong reference to an observer that goes out of scope normally, the observer is not garbage collected because the subject still has a reference. This can cause unbounded memory growth, especially in long-running processes like web servers.
The fix: use weakref.ref for the observer references. Python's WeakSet and WeakValueDictionary are perfect for this. But you can't use weak references for bound methods easily — a bound method holds a strong reference to its object, so even a weak reference to the method won't help. Instead, store the actual object and call the method via weak reference, or use the weakref module's finalize for cleanup.
Another approach: use callbacks as simple functions (not methods) stored in a set of weak references to functions. But functions can be closures that capture large objects — so be mindful of what the closure captures.
- The subject keeps a weak reference to the observer — the subject doesn't prolong the observer's lifetime.
- When the observer is garbage collected, the weak reference returns
None. The subject cleans up dead refs during the next notification. - This pattern is essential for preventing memory leaks in event-driven systems with dynamic subscription.
self (the method's object), creating a reference cycle that weak references can't break.weakref.finalize to auto-unregister when the observer is garbage collected.Strategy Pattern — Replace if/elif Chains with Dispatch
Strategy lets you define a family of algorithms, encapsulate each one, and make them interchangeable. In Python, you don't need a formal interface; you can use a dictionary of callables. This is often simpler and more readable than a class hierarchy. The Strategy pattern shines when you have multiple ways to process the same input, like different compression algorithms or payment gateways.
Performance consideration: if the strategy selection logic is based on a configuration string that rarely changes, use a functools.lru_cache on the strategy selection function to avoid repeated parsing. If the strategies themselves have different performance characteristics, profile them independently and choose based on input characteristics (e.g., small data use compression-X, large data use compression-Y).
The biggest mistake is putting the selection logic in the caller's code, scattered across the codebase. Centralise the dispatch in a dedicated strategy registry so that adding a new strategy doesn't require changes in multiple places.
isinstance on every item in a data pipeline.Decorator Pattern — Python's Superpower and Its Pitfalls
Python has a built-in syntax for the Decorator pattern: the @decorator syntax. It's one of the few languages where the GoF Decorator pattern is directly supported. But with great power comes great responsibility. Decorators can change the signature of the decorated function, break type hints, and introduce subtle performance overhead.
The three most common production issues with decorators: 1. Signature loss: The decorated function loses its original __name__, __doc__, and argument signature. Always use @functools.wraps. 2. Performance overhead: Every call to a decorated function adds the decorator's overhead. If you chain several decorators, each one adds a function call. For latency-critical paths, consider combining decorators or using a single decorator that does multiple things. 3. State leakage: If a decorator has mutable state (e.g., a cache), concurrent access can cause data races. Use thread-safe structures or limit to single-threaded contexts.
The GoF Decorator pattern (wrapping objects) is also useful in Python: e.g., wrapping a file object to add compression or encryption. Use io.IOBase as the base class and implement the wrapper, or simply use functools.partial for simple cases.
@functools.wraps on the inner async function. Otherwise, the decorated async def becomes a regular function that returns a coroutine, but calling it twice creates two different coroutines — a common bug.time.perf_counter and reconsider the chain for hot paths.@functools.wraps to preserve function metadata.The Pattern That Bites Back — Adapter Is Not a Band-Aid
You inherited that third-party API with the worst interface in existence. First instinct: slap an Adapter on it and call it a day. That works — until it doesn't. The Adapter pattern wraps an incompatible interface to make it talk to your system. But here's the trap: wrapping garbage doesn't make it clean. Every adapter adds a layer of indirection, and that layer hides the underlying complexity. When your adapter starts sprouting if/else chains to handle edge cases, you've built a leaky abstraction. The real fix is to understand why the interface is broken and fix it at the source. If you can't, then and only then, write an adapter. Keep it thin. Single responsibility. Test it like your production depends on it — because it does. I've seen adapters become the single point of failure in a critical payment pipeline. Don't let that be you.
Composite Pattern — When Trees Go to War
You've got a hierarchy: orders, line items, discounts. Each one needs the same operation: calculate total, apply tax, serialize. The Composite pattern lets you treat individual objects and groups uniformly. It's a tree structure where every node can be a leaf or a composite. In Python, this is deceptively simple. You define a common interface, then implement it in both classes. The composite class internally calls the same method on its children. Beautiful recursion. But I've seen teams turn this into a performance disaster. They add hundreds of thousands of nodes, then wonder why a simple sum takes seconds. The WHY is important: Composite shines when you have stable, shallow trees. If your hierarchy runs deep or changes frequently, you're better off with a Visitor pattern or a flat iterator. Measure before you commit. And always cache results if you're recalculating more than once.
State Pattern — When if/elif Becomes a War Crime
You've seen that function. Two hundred lines of if/elif checking a status variable. It works until someone adds a new state and forgets to update every branch. That's how payment confirmations end up in limbo. The State pattern moves each state's behavior into its own class. Instead of checking the state, you delegate to the current state object. It's like having a bouncer that only lets the right people in. In Python, this maps naturally to classes that implement a common protocol. The state machine holds a reference to the current state and swaps it when a transition happens. The catch: you need a clear, enforced transition table. I've had to rewrite messes where states jumped arbitrarily because someone added a shortcut. Document every allowed transition. Use an enum for state names. And for the love of consistent logs, log every state change with a timestamp and caller. You'll thank me during the post-mortem.
The Singleton That Silently Burned Through Database Connections
class MyPool: with a class variable, but the import was done inside a conditional block that ran on every request due to Python's module caching nuances. In Python, module-level objects are created once, but if the module was re-imported dynamically (e.g., via importlib.reload during hot-reload), the Singleton was recreated. Additionally, multiple threads called the factory function without a lock, causing race conditions that created temporary duplicate pools before the second one was discarded, but the connections remained open._pool = None and a thread-safe factory function using threading.Lock. Used atexit to register clean shutdown of the pool. Also made the connection pool a module attribute imported by all services, not passed as a dependency.- A Singleton is only as singleton as the module import system allows — watch out for dynamic reloads or multiple interpreters.
- Thread-safety in Singleton creation isn't optional in Python; always use a lock or
functools.lru_cachefor thread-safe caching. - Dependency injection beats Singleton for testability. Use Singleton only for truly stateless or globally unique resources like connection pools with cleanup.
id(pool) to confirm it's the same object.weakref.WeakSet to avoid duplicates but ensure callbacks are properly unregistered.raise ValueError(f"Unknown type: {type_name}").isinstance checks. Use a dictionary dispatch instead. Profile with time.perf_counter() before and after the selection.functools.wraps to preserve metadata. For decorators that change the return type, ensure the wrapped function still reports correct annotations. Check inspect.signature in tests.python -c "from io.thecodeforge.db import pool; print(id(pool))"grep -rn "import.*pool" /app | head -20@functools.lru_cache(maxsize=1) on the factory function.Key takeaways
lru_cache for thread safety and simplicity; avoid class-based Singletons unless you need reset capability.self.@functools.wraps; measure overhead on hot paths; prefer composition for object-level decoration.Common mistakes to avoid
5 patternsUsing Singleton as a global variable for state that should be scoped (e.g., per request)
g object or dependency injection) instead of Singleton. Reserve Singleton for truly global resources like connection pools or configuration.Implementing Factory with a long if/elif chain instead of a dictionary dispatch
Not using weak references in Observer pattern, causing memory leaks in long-running processes
weakref.ref or WeakSet. Ensure callbacks are top-level functions or have short lifecycles. Use weakref.finalize for automatic unregistration.Forgetting `@functools.wraps` in decorators, breaking introspection and libraries that use function signatures
help(decorated_func) shows generic wrapper info. Unit tests that inspect signatures break.@functools.wraps(func) to the inner wrapper function. For async decorators, use @functools.wraps on the inner async function.Using Strategy pattern with class hierarchy when a simple dict would suffice
Interview Questions on This Topic
Explain how you would implement a thread-safe Singleton in Python. What are the trade-offs between different approaches?
@functools.lru_cache(maxsize=1). This is thread-safe because lru_cache locks internally (via the GIL for the cache but also via a real lock in CPython for the wrapper). Alternative: use a class with __new__ and double-checked locking with threading.Lock. The lru_cache approach is cleaner and faster for most cases, but it's harder to clear the cache in tests; you need to call get_settings.cache_clear(). The class-based approach allows more control over lifecycle (e.g., resetting the instance on config change) but risks race conditions if not implemented correctly. Real production choice: use lru_cache for configuration objects that never change after startup; use class-based with lock for objects that need manual reset.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
That's Advanced Python. Mark it forged?
10 min read · try the examples if you haven't