The most surprising thing about ZeroMQ’s XPUB-XSUB pattern is that it doesn’t actually forward subscriptions; it reconstructs them at the proxy.

Let’s see this in action. Imagine a simple setup: a publisher, a proxy, and two subscribers.

Publisher (pub.py)

import zmq
import time

context = zmq.Context()
socket = context.socket(zmq.PUB)
socket.bind("tcp://*:5555")
print("Publisher bound to tcp://*:5555")

i = 0
while True:
    message = f"Message {i}"
    print(f"Sending: {message}")
    socket.send_string(message)
    i += 1
    time.sleep(1)

Proxy (proxy.py)

import zmq

context = zmq.Context()

# Frontend socket to receive messages from publisher and subscriptions from subscribers
frontend = context.socket(zmq.XSUB)
frontend.bind("tcp://*:5555")
print("Frontend bound to tcp://*:5555")

# Backend socket to send messages to subscribers
backend = context.socket(zmq.XPUB)
backend.bind("tcp://*:5556")
print("Backend bound to tcp://*:5556")

print("Starting proxy...")
zmq.proxy(frontend, backend)

print("Proxy stopped.")
frontend.close()
backend.close()
context.term()

Subscriber 1 (sub1.py)

import zmq

context = zmq.Context()
socket = context.socket(zmq.SUB)
# Connect to the proxy's backend
socket.connect("tcp://localhost:5556")
# Subscribe to all messages (empty string means subscribe to everything)
socket.setsockopt_string(zmq.SUBSCRIBE, "")
print("Subscriber 1 connected to tcp://localhost:5556 and subscribed to all.")

while True:
    message = socket.recv_string()
    print(f"Subscriber 1 received: {message}")

Subscriber 2 (sub2.py)

import zmq

context = zmq.Context()
socket = context.socket(zmq.SUB)
# Connect to the proxy's backend
socket.connect("tcp://localhost:5556")
# Subscribe to messages starting with "Message 2"
socket.setsockopt_string(zmq.SUBSCRIBE, "Message 2")
print("Subscriber 2 connected to tcp://localhost:5556 and subscribed to 'Message 2'.")

while True:
    message = socket.recv_string()
    print(f"Subscriber 2 received: {message}")

When you run these:

  1. Start pub.py.
  2. Start proxy.py.
  3. Start sub1.py.
  4. Start sub2.py.

You’ll see sub1.py receiving all messages. sub2.py will initially receive nothing. When sub2.py connects, the proxy.py will immediately send a SUBSCRIBE event for "Message 2" to its frontend socket. The frontend (which is an XSUB socket) receives this as a control message. The proxy then relays this subscription information to its backend socket (an XPUB socket). The backend socket, acting as a publisher itself, then sends this subscription to all connected subscribers (including sub1 and sub2).

Now, if you modify sub2.py to subscribe to something else, say "Message 3", and restart it, you’ll see it receive only messages starting with "Message 3". This demonstrates that the proxy is managing the subscriptions dynamically.

The core problem XPUB-XSUB solves is enabling a scalable fan-out architecture where subscribers don’t need to connect directly to the publisher, and the publisher doesn’t need to know about individual subscribers or their subscription patterns. The proxy acts as an intelligent intermediary.

Internally, the XSUB socket on the proxy’s frontend receives two types of messages:

  1. Data messages from the actual publisher.
  2. Control messages from subscribers indicating their subscription or unsubscription. These control messages are special zmq.Frame objects, not regular strings, and are identified by a flag in the zmq.RCVMORE property.

The XPUB socket on the proxy’s backend then does two things:

  1. Relays data messages it receives from the frontend to all connected subscribers that are subscribed to that message’s topic.
  2. Publishes control messages it receives from the frontend to all connected subscribers, effectively informing them about other subscribers’ interests.

The zmq.proxy() function handles this two-way communication automatically. It’s a blocking call that continuously moves messages between the frontend and backend sockets, respecting the message types and routing them appropriately.

The exact levers you control are the binding addresses of the proxy’s frontend and backend, and the connection addresses of the publisher and subscribers. For subscribers, the setsockopt_string(zmq.SUBSCRIBE, "topic") is the crucial part. An empty string "" subscribes to everything. Any non-empty string subscribes to messages that start with that string.

A common misconception is that the XPUB socket on the backend directly filters messages based on the SUBSCRIBE options set on the subscriber sockets. This is not true. The XPUB socket on the backend is a publisher; it sends all messages it receives from the frontend to all its connected subscribers. The filtering happens on the subscriber’s side. The proxy’s role is to manage the distribution of subscription information itself, so subscribers know what to filter. When a subscriber connects and sets its subscription, the proxy’s frontend (XSUB) receives this as a subscription request. The proxy then immediately sends this subscription request out on its backend (XPUB) socket. This XPUB socket then broadcasts this subscription to all subscribers, informing them of the new subscription interest. This is how sub2 eventually starts receiving messages if its subscription matches something published.

If you connect a subscriber and it never receives messages, even though the publisher is sending them, double-check that the subscriber’s setsockopt_string(zmq.SUBSCRIBE, ...) is correctly formatted and matches the beginning of the messages being sent.

Want structured learning?

Take the full Zeromq course →