Proxy Pattern — Deep Dive
Virtual proxy with lazy initialization
The most common Python proxy delays object creation until first use. Python’s __getattr__ makes this elegant:
class LazyProxy:
"""Delays creation of the real object until first attribute access."""
def __init__(self, factory):
# Use object.__setattr__ to avoid triggering __getattr__
object.__setattr__(self, "_factory", factory)
object.__setattr__(self, "_instance", None)
def _get_instance(self):
if self._instance is None:
object.__setattr__(self, "_instance", self._factory())
return self._instance
def __getattr__(self, name):
return getattr(self._get_instance(), name)
def __setattr__(self, name, value):
setattr(self._get_instance(), name, value)
def __repr__(self):
if self._instance is None:
return f"<LazyProxy(uninitialized)>"
return repr(self._instance)
Usage:
import time
class HeavyModel:
def __init__(self):
time.sleep(2) # simulate loading weights
self.ready = True
def predict(self, data):
return [x * 2 for x in data]
model = LazyProxy(lambda: HeavyModel()) # instant — no 2-second wait
# ... other setup code runs immediately ...
result = model.predict([1, 2, 3]) # NOW it loads (2 seconds), then predicts
The __getattr__ hook only fires for attributes not found on the proxy itself. By storing _factory and _instance via object.__setattr__, we bypass the proxy’s own __setattr__ and avoid infinite recursion.
Caching proxy
Wrap an expensive service call with result caching:
import hashlib
import json
from functools import lru_cache
from typing import Protocol
class GeocoderService(Protocol):
def geocode(self, address: str) -> tuple[float, float]: ...
class GoogleGeocoder:
def geocode(self, address: str) -> tuple[float, float]:
# actual API call — costs money and takes time
return (40.7128, -74.0060) # placeholder
class CachingGeocoderProxy:
def __init__(self, real: GeocoderService, maxsize: int = 1024):
self._real = real
self._cache: dict[str, tuple[float, float]] = {}
self._maxsize = maxsize
def geocode(self, address: str) -> tuple[float, float]:
key = address.strip().lower()
if key in self._cache:
return self._cache[key]
result = self._real.geocode(address)
if len(self._cache) >= self._maxsize:
# evict oldest entry (FIFO)
oldest = next(iter(self._cache))
del self._cache[oldest]
self._cache[key] = result
return result
@property
def cache_size(self) -> int:
return len(self._cache)
This proxy is transparent — any code expecting a GeocoderService works with either the real service or the caching proxy.
Protection proxy with role-based access
from dataclasses import dataclass
from enum import Enum, auto
class Role(Enum):
VIEWER = auto()
EDITOR = auto()
ADMIN = auto()
@dataclass
class User:
name: str
role: Role
class Document:
def __init__(self, content: str):
self.content = content
def read(self) -> str:
return self.content
def write(self, new_content: str) -> None:
self.content = new_content
def delete(self) -> None:
self.content = ""
class DocumentProxy:
"""Access-controlled proxy for Document."""
_permissions: dict[str, set[Role]] = {
"read": {Role.VIEWER, Role.EDITOR, Role.ADMIN},
"write": {Role.EDITOR, Role.ADMIN},
"delete": {Role.ADMIN},
}
def __init__(self, document: Document, user: User):
self._document = document
self._user = user
def _check(self, action: str) -> None:
allowed = self._permissions.get(action, set())
if self._user.role not in allowed:
raise PermissionError(
f"{self._user.name} ({self._user.role.name}) cannot {action}"
)
def read(self) -> str:
self._check("read")
return self._document.read()
def write(self, content: str) -> None:
self._check("write")
self._document.write(content)
def delete(self) -> None:
self._check("delete")
self._document.delete()
Authorization lives in the proxy, not in the document. The document remains a simple data container.
Dynamic proxy with __getattr__
For classes with many methods, manually proxying each one is tedious. A dynamic proxy forwards everything automatically and adds cross-cutting behavior:
import time
import logging
logger = logging.getLogger(__name__)
class TimingProxy:
"""Logs execution time for every method call on the wrapped object."""
def __init__(self, wrapped):
object.__setattr__(self, "_wrapped", wrapped)
def __getattr__(self, name):
attr = getattr(self._wrapped, name)
if not callable(attr):
return attr
def timed_call(*args, **kwargs):
start = time.perf_counter()
result = attr(*args, **kwargs)
elapsed = time.perf_counter() - start
logger.info(f"{type(self._wrapped).__name__}.{name}() took {elapsed:.4f}s")
return result
return timed_call
Wrap any object: timed_db = TimingProxy(database). Every method call gets logged with its execution time. No changes to the database class or the calling code.
Proxy with descriptors
Python’s descriptor protocol lets you create proxies at the attribute level:
class CachedProperty:
"""Descriptor that acts as a caching proxy for a computed property."""
def __init__(self, func):
self._func = func
self._attr_name = f"_cached_{func.__name__}"
def __set_name__(self, owner, name):
self._attr_name = f"_cached_{name}"
def __get__(self, obj, objtype=None):
if obj is None:
return self
try:
return getattr(obj, self._attr_name)
except AttributeError:
value = self._func(obj)
setattr(obj, self._attr_name, value)
return value
class DataAnalysis:
def __init__(self, raw_data: list[float]):
self.raw_data = raw_data
@CachedProperty
def statistics(self) -> dict:
# expensive computation
return {
"mean": sum(self.raw_data) / len(self.raw_data),
"count": len(self.raw_data),
"min": min(self.raw_data),
"max": max(self.raw_data),
}
The descriptor proxies access to statistics — computing it once on first access and returning the cached value thereafter. Python 3.8+ includes functools.cached_property which does this out of the box.
Real-world proxies in Python
weakref.proxy()
Creates a proxy to an object that doesn’t prevent garbage collection. The proxy raises ReferenceError if the object has been collected.
Django’s SimpleLazyObject
A virtual proxy used extensively in Django’s middleware. request.user is a lazy proxy — it doesn’t query the database until you actually access user attributes.
SQLAlchemy’s lazy-loaded relationships
When you access user.orders, SQLAlchemy returns a proxy that only queries the database when iterated. This is a combination of virtual proxy and caching proxy.
Thread safety considerations
If multiple threads share a proxy:
- Virtual proxy: Use a lock around initialization to prevent double-creation
- Caching proxy: Use
threading.Lockorfunctools.lru_cache(which is thread-safe) - Dynamic proxy:
__getattr__itself is thread-safe, but the wrapped object might not be
import threading
class ThreadSafeLazyProxy:
def __init__(self, factory):
object.__setattr__(self, "_factory", factory)
object.__setattr__(self, "_instance", None)
object.__setattr__(self, "_lock", threading.Lock())
def _get_instance(self):
if self._instance is None:
with self._lock:
if self._instance is None: # double-check locking
object.__setattr__(self, "_instance", self._factory())
return self._instance
def __getattr__(self, name):
return getattr(self._get_instance(), name)
Tradeoffs
Benefits:
- Transparent to callers — no code changes needed
- Cross-cutting concerns (caching, logging, auth) stay separate from business logic
- Enables lazy loading of expensive resources
Costs:
- Extra indirection — slight performance overhead per call
- Debugging can be confusing when
__getattr__hides the real object - isinstance checks fail unless the proxy inherits from the same base
The one thing to remember: Python’s __getattr__ and descriptor protocol make proxies natural and lightweight — use them to add lazy loading, caching, access control, or monitoring without modifying the objects you’re protecting.
See Also
- Python Adapter Pattern How Python's Adapter Pattern works like a travel power plug — making incompatible things work together.
- Python Bridge Pattern Why separating what something does from how it does it keeps your Python code from becoming a tangled mess.
- Python Builder Pattern Why building complex Python objects step by step beats cramming everything into one giant constructor.
- Python Composite Pattern How the Composite Pattern lets you treat a group of things the same way you'd treat a single thing in Python.
- Python Facade Pattern How the Facade Pattern gives you one simple button instead of a confusing control panel in Python.