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.Lock or functools.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.

pythondesign-patternsoop

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.