Skip to content

Proxy

Intent

Give the client a stand-in that presents the real object's interface and decides what happens to each call: create the real object only when it is first needed, answer from a cache, refuse the call, or send it across a network. The calling code never changes, and usually never learns which one it holds.

When to use and when not to

Use it when

  • Creation is expensive and often unnecessary — the virtual proxy. Building 1,000 compressed photos at ~200 KB each is ~200 MB; proxies holding paths cost nothing until someone scrolls.
  • The same call keeps giving the same answer — the caching proxy, with the cache behind the interface, not in every caller.
  • Authorisation belongs outside the domain object — the protection proxy. TextDocument should not have to know what a role is.
  • The real object is in another process — the remote proxy. The stub makes the call look local, though a datacenter round trip is ~500 µs against ~100 ns for a memory reference: 5,000x.

Leave it out when

  • The wrapper adds behaviour the client deliberately chose and stacks: that is a Decorator. If it changes the interface it is an Adapter — adapt first, then proxy.
  • Nothing is ever skipped. A lazy layer that always resolves on the first call is one indirection and no benefit.
  • Hiding the cost would hide a budget: an ORM lazy relationship is a virtual proxy, and N+1 is the bill — 100 rows one at a time is 100 round trips, ~50 ms of network.

Structure

The virtual and caching proxy: one Subject, a RealSubject whose constructor does the expensive work, and a stand-in that postpones it and remembers the answers.

classDiagram
    class Image {
        <<interface>>
        +path: str
        +render(width: int) str
    }
    class RealImage {
        +path: str
        +render(width: int) str
    }
    class CachedImageProxy {
        -_real: RealImage
        -_renders: Dict~int,str~
        +path: str
        +is_loaded: bool
        +render(width: int) str
    }
    class ImageStore {
        +paths: Tuple~str~
        +load_count: int
        +load(path: str) Tuple~int,int~
    }
    Image <|.. RealImage
    Image <|.. CachedImageProxy
    CachedImageProxy o-- RealImage : creates on first use
    RealImage ..> ImageStore : loads from

The protection and remote proxies: the same Subject, two stand-ins that decide whether the call reaches the real document and how.

classDiagram
    class Document {
        <<interface>>
        +doc_id: str
        +read() str
        +write(text: str) None
    }
    class TextDocument {
        +doc_id: str
        +read() str
        +write(text: str) None
    }
    class AccessControlledDocument {
        -_user: str
        -_policy: AccessPolicy
        +read() str
        +write(text: str) None
    }
    class RemoteDocumentStub {
        -_transport: Transport
        +read() str
        +write(text: str) None
    }
    class DocumentServer {
        +handle(request: Message) Message
    }
    Document <|.. TextDocument
    Document <|.. AccessControlledDocument
    Document <|.. RemoteDocumentStub
    AccessControlledDocument o-- Document : guards
    RemoteDocumentStub ..> DocumentServer : one message per call
    DocumentServer o-- Document : holds the real one

Every arrow into an interface is the point: a proxy is a Subject, so it stands where one was expected.

Canonical example in Python

The Subject and the expensive RealSubject first (code/patterns/proxy.py, tested by code/patterns/tests/test_proxy.py):

code/patterns/proxy.py — the Subject, the store and the RealSubject
@runtime_checkable
class Image(Protocol):
    """The Subject: what the client calls, satisfied by the real image and by its proxy."""

    @property
    def path(self) -> str: ...

    def render(self, width: int) -> str: ...


class ImageStore:
    """The disk or blob store: ``load`` is the expensive call a proxy postpones.

    ``_lock`` guards ``_loaded``; ``load_count`` proves how often the store was hit.
    """

    def __init__(self, files: Mapping[str, tuple[int, int]]) -> None:
        self._files = dict(files)
        self._loaded: list[str] = []
        self._lock = threading.Lock()

    @property
    def paths(self) -> tuple[str, ...]:
        return tuple(sorted(self._files))  # listing is cheap; it is the pixels that cost

    def load(self, path: str) -> tuple[int, int]:
        """Returns (width, height); a real store would return megabytes of pixels."""
        dimensions = self._files.get(path)
        if dimensions is None:
            raise NotFoundError(f"no image at {path!r}")
        with self._lock:
            self._loaded.append(path)
        return dimensions

    @property
    def load_count(self) -> int:
        with self._lock:
            return len(self._loaded)


class RealImage:
    """The RealSubject: the load happens in the constructor, which is exactly what a proxy delays."""

    def __init__(self, path: str, store: ImageStore) -> None:
        self._path = path
        self._width, self._height = store.load(path)

    @property
    def path(self) -> str:
        return self._path

    def render(self, width: int) -> str:
        if width <= 0:
            raise ValidationError("width must be positive")
        height = max(1, round(self._height * width / self._width))
        return f"{self._path} {width}x{height}"

RealImage loads in its constructor, which is what makes it worth postponing; load_count turns "the proxy avoided work" into an assertion.

code/patterns/proxy.py — a virtual proxy with a per-width cache
class CachedImageProxy:
    """A virtual proxy with a cache: the ``Image`` interface, the ``RealImage`` made on first ``render``.

    ``path`` is answered from the proxy's own field, so listing a gallery of a thousand
    images touches the store zero times. ``_lock`` guards ``_real`` and ``_renders``,
    without which two threads rendering a cold image would both load it. A render that
    raises caches nothing, and a failed load leaves ``_real`` empty so the next retries.
    """

    def __init__(self, path: str, store: ImageStore) -> None:
        self._path = path
        self._store = store
        self._real: RealImage | None = None
        self._renders: dict[int, str] = {}
        self._lock = threading.Lock()

    @property
    def path(self) -> str:
        return self._path

    @property
    def is_loaded(self) -> bool:
        with self._lock:
            return self._real is not None

    def render(self, width: int) -> str:
        with self._lock:
            cached = self._renders.get(width)
            if cached is None:
                if self._real is None:
                    self._real = RealImage(self._path, self._store)
                cached = self._renders[width] = self._real.render(width)
            return cached

Three decisions to say out loud:

  • path is answered locally. The proxy keeps the path it was constructed with, so listing a gallery reads zero pixels. A proxy that had to load before answering any question would be pointless.
  • The lock exists because the load must happen once. Two threads rendering a cold image would otherwise both build a RealImage; the test drives 200 concurrent renders and asserts load_count == 1.
  • Failure is not cached, and the key is the width. A render that raises stores nothing and a failed load leaves the proxy cold, so the next call retries; caching a failure forever is the classic bug.

The protection proxy moves the policy out of the document:

code/patterns/proxy.py — a protection proxy bound to one user
class Permission(StrEnum):
    READ = "read"
    WRITE = "write"


class PermissionDeniedError(HandbookError):
    """The proxy refused to forward the call; the real document never saw it."""


@runtime_checkable
class Document(Protocol):
    """The Subject shared by the real document, the protection proxy and the remote stub."""

    @property
    def doc_id(self) -> str: ...

    def read(self) -> str: ...

    def write(self, text: str) -> None: ...


class TextDocument:
    """The RealSubject: it knows nothing about users, roles or networks."""

    def __init__(self, doc_id: str, text: str = "") -> None:
        self._doc_id = doc_id
        self._text = text

    @property
    def doc_id(self) -> str:
        return self._doc_id

    def read(self) -> str:
        return self._text

    def write(self, text: str) -> None:
        self._text = text


type AccessPolicy = Callable[[str, Permission], bool]


class AccessControlledDocument:
    """A protection proxy: the same ``read`` and ``write``, each preceded by a policy check.

    Bound to one user at construction, so the signatures stay identical to the subject's:
    nobody passes a user into ``read``. The policy is consulted on every call, so a
    revocation lands on the next call rather than the next login.
    """

    def __init__(self, inner: Document, user: str, policy: AccessPolicy) -> None:
        self._inner = inner
        self._user = user
        self._policy = policy

    @property
    def doc_id(self) -> str:
        return self._inner.doc_id

    def read(self) -> str:
        self._check(Permission.READ)
        return self._inner.read()

    def write(self, text: str) -> None:
        self._check(Permission.WRITE)
        self._inner.write(text)

    def _check(self, permission: Permission) -> None:
        if not self._policy(self._user, permission):
            raise PermissionDeniedError(f"{self._user} may not {permission} {self._inner.doc_id}")

Binding the user at construction keeps the signatures identical to the subject's — nobody passes a user into read — so the proxy stays substitutable. Consulting the policy on every call means a revoked permission lands on the next call, not the next login.

The remote proxy keeps the interface and nothing else:

code/patterns/proxy.py — a remote proxy over a transport
class RemoteError(HandbookError):
    """The failure a local object never has: the call may not have happened at all."""


type Message = dict[str, str]
type Transport = Callable[[Message], Message]


class DocumentServer:
    """The far side of the wire: unpack, call the real document, pack the reply; a gRPC service in production."""

    def __init__(self, documents: Mapping[str, Document]) -> None:
        self._documents = dict(documents)

    def handle(self, request: Message) -> Message:
        document = self._documents.get(request.get("doc_id", ""))
        if document is None:
            return {"error": f"no document {request.get('doc_id')!r}"}
        match request.get("method"):
            case "read":
                return {"result": document.read()}
            case "write":
                document.write(request.get("text", ""))
                return {"result": ""}
            case other:
                return {"error": f"unknown method {other!r}"}


class RemoteDocumentStub:
    """A remote proxy: the ``Document`` interface, one message per call.

    ``doc_id`` is local state; ``read`` and ``write`` cross the transport. The interface
    hides the network but not its cost or its failure modes: every call is a round trip,
    and either side's failure arrives as ``RemoteError``.
    """

    def __init__(self, doc_id: str, transport: Transport) -> None:
        self._doc_id = doc_id
        self._transport = transport

    @property
    def doc_id(self) -> str:
        return self._doc_id

    def read(self) -> str:
        return self._call("read")

    def write(self, text: str) -> None:
        self._call("write", text=text)

    def _call(self, method: str, **fields: str) -> str:
        request: Message = {"method": method, "doc_id": self._doc_id, **fields}
        try:
            reply = self._transport(request)
        except OSError as exc:
            raise RemoteError(f"transport failed: {exc}") from exc
        if "error" in reply:
            raise RemoteError(reply["error"])
        return reply.get("result", "")

doc_id is local state and costs nothing; read and write each become one message. Failures on either side of the wire arrive as one RemoteError — the thing a remote call has that a local one does not is a failure where you cannot tell whether it happened.

Running python -m patterns.proxy prints:

--- virtual proxy: list four images and load none; render one and load one ---
listed 4 paths, loads: 0
render 200: photos/a.jpg 200x133  loads: 1
render 200: photos/a.jpg 200x133  loads: 1 (cache hit)
render 400: photos/a.jpg 400x267  loads: 1 (new width, same image)
loaded 1 of 4 images
--- protection proxy: one document, two users, the policy outside the document ---
alice writes, bob reads: 'Loan policy: 5 items, 14 days'
bob writes: PermissionDeniedError: bob may not write doc-1
alice writes after revocation: PermissionDeniedError: alice may not write doc-1
--- remote proxy: the same interface, one message per call ---
stub.read() -> 'Loan policy: 5 items, 14 days' via {'method': 'read', 'doc_id': 'doc-1'}
unknown document: RemoteError: no document 'doc-9'
--- pythonic forms: __getattr__ delegation, cached_property, weakref.proxy ---
LazyProxy created; builds so far: 0
lazy.read() -> 'built on first attribute access'; builds: 1
thumbnail.image twice: photos/d.jpg 80x60, photos/d.jpg 40x30; loads: 1
weakref.proxy parent: root/docs
after the parent is collected: ReferenceError: weakly-referenced object no longer exists

Pythonic variant

Three smaller forms come before a class per subject: __getattr__ forwards every attribute, cached_property is a virtual proxy for one attribute, and weakref.proxy is the smart reference:

code/patterns/proxy.py — generic delegation, a cached attribute and a weak reference
class LazyProxy[T]:
    """``__getattr__`` delegation: build the target on first use, forward every attribute after that.

    ``__getattr__`` runs only when normal lookup fails, so the proxy's own fields never
    recurse into it. It never sees dunder lookups either: ``len(proxy)``, ``proxy == other``
    and ``with proxy:`` go to ``type(proxy)``, and ``isinstance`` reports the proxy.
    """

    def __init__(self, factory: Callable[[], T]) -> None:
        self._factory = factory
        self._target: T | None = None
        self._lock = threading.Lock()

    def __getattr__(self, name: str) -> Any:
        if name.startswith("_"):  # never forward private names; also ends recursion before __init__
            raise AttributeError(name)
        with self._lock:
            if self._target is None:
                self._target = self._factory()
        return getattr(self._target, name)


class Thumbnail:
    """``cached_property`` is the virtual proxy for one attribute: computed on first access, then stored.

    Since 3.12 it holds no lock: two threads racing on a cold attribute may both run it.
    """

    def __init__(self, path: str, store: ImageStore) -> None:
        self.path = path
        self._store = store

    @cached_property
    def image(self) -> RealImage:
        return RealImage(self.path, self._store)


class TreeNode:
    """A tree whose back-reference to the parent is a ``weakref.proxy``.

    The parent owns its children; a child must not own its parent, or no subtree is ever
    collected. The proxy forwards ``path`` and raises ``ReferenceError`` once it is gone.
    """

    def __init__(self, name: str, parent: TreeNode | None = None) -> None:
        self.name = name
        self.children: list[TreeNode] = []
        self.parent: TreeNode | None = None
        if parent is not None:
            self.parent = weakref.proxy(parent)
            parent.children.append(self)

    def path(self) -> str:
        if self.parent is None:
            return self.name
        return f"{self.parent.path()}/{self.name}"
  • __getattr__ runs only when normal lookup fails, so the proxy's own fields never recurse into it; guarding names that start with _ stops an unfinished proxy from recursing during __init__.
  • It never sees dunder lookups. len(proxy), proxy == other and with proxy: are looked up on the type, and isinstance(proxy, list) is False. Generic delegation suits services; write the class when the subject has protocol behaviour.
  • cached_property holds no lock since Python 3.12, so two threads can both run a cold getter. When the load must happen exactly once, keep the explicit lock.
Reach for When
functools.cached_property One expensive attribute per instance, single-threaded or idempotent
functools.lru_cache The expensive thing is a pure function of its arguments
weakref.proxy A back-reference that must not keep its target alive
__getattr__ delegation Many forwarded methods, no dunder or isinstance requirement
An explicit proxy class The checks are per method, or the wrapper must be named

Real-world usage

  • types.MappingProxyType is a protection proxy in the standard library: a read-only view of a dict. Every class's __dict__ is one, which is why you cannot assign into it.
  • weakref.proxy forwards attribute access and raises ReferenceError once the referent is collected, so parent back-pointers do not leak whole subtrees.
  • multiprocessing.Manager().dict() returns a BaseProxy whose every operation is an inter-process round trip, and xmlrpc.client.ServerProxy turns attribute access into a remote call.
  • Frameworks: Django's SimpleLazyObject (what request.user is) and its lazy settings; SQLAlchemy and Django relationships that query on first access; gRPC stubs; every API gateway and sidecar.

The structural patterns all wrap something; classify by two questions: does the interface change, and why does the wrapper exist?

Looks like Proxy How to tell them apart
Decorator Same interface, but a decorator always forwards and adds, and the client stacks it deliberately. A proxy decides whether the real call happens, and the client usually did not choose it.
Adapter Changes the interface and adds no behaviour. Adapt first, then proxy: CachingProxy(StripeAdapter(client)).
Facade A new, simpler interface over a subsystem of several objects, so it is not interchangeable with what it hides and cannot be layered.
Cache-aside Same cache, different owner. A caching proxy hides the cache behind the interface; cache-aside makes every caller check and populate it, and one that forgets is a bug.
Lazy initialisation cached_property is a virtual proxy one field wide, not one object wide.
Ambassador or sidecar The remote proxy on a deployment diagram: retries, TLS and routing in a process next to yours.

Where it appears in LLD problems

Interview tips

Interview tip

Name which proxy you mean in the same breath as the word: "a virtual proxy, so listing the gallery loads nothing", or "a protection proxy, so TextDocument never learns what a role is". Then add the two details that mark an SDE2: the lock, because the expensive load must happen exactly once under concurrency, and the rule that failures are never cached.

Common mistake

Calling a Decorator a Proxy. If the client deliberately stacked the wrapper to add behaviour it is a decorator; a proxy exists to control access and is usually invisible. Runner-up: a proxy that hides latency it should expose — the lazy attribute that turns one list into N queries — and __getattr__ forwarding that silently drops len(), ==, with and isinstance, so code that worked with the real object breaks.