ZeroMQ’s broker-less architecture lets your services talk directly, bypassing a central message queue.

Here’s a simple request-reply pattern in action:

# Server (responder)
import zmq

context = zmq.Context()
socket = context.socket(zmq.REP)
socket.bind("tcp://*:5555")

while True:
    message = socket.recv()
    print(f"Received request: {message.decode()}")
    socket.send(b"World")
# Client (requester)
import zmq
import time

context = zmq.Context()
socket = context.socket(zmq.REQ)
socket.connect("tcp://localhost:5555")

for request in range(10):
    socket.send(b"Hello")
    message = socket.recv()
    print(f"Received reply {request}: {message.decode()}")
    time.sleep(1)

When you run the server and then the client, you’ll see the client send "Hello," and the server respond with "World," printed on each side. This direct connection, without a middleman, is the core of the broker-less design.

The fundamental problem ZeroMQ solves here is efficient, decoupled inter-process communication. Instead of your services needing to know the exact network address and status of every other service, they just need to know how to connect to a type of service. The zmq.REP socket on the server says, "I’m ready to receive requests and send replies." The zmq.REQ socket on the client says, "I’m ready to send a request and wait for a reply." The bind operation on the server makes it discoverable at a specific address (tcp://*:5555), and the connect operation on the client establishes the direct link.

ZeroMQ handles the underlying network plumbing. It’s not a message broker like RabbitMQ or Kafka, which manage message queues and persistence. Instead, ZeroMQ provides sockets that behave like pipes, but over networks. The socket types (REQ, REP, PUB, SUB, PUSH, PULL, etc.) define the communication patterns. When a REQ socket sends a message, it automatically waits for a corresponding REP reply. If you try to send another message from the REQ socket before receiving a reply, you’ll get an error. This enforces the request-reply contract.

The magic happens in how ZeroMQ establishes these connections. When a REQ socket connects to an address where a REP socket binds, ZeroMQ establishes a direct TCP connection. If multiple REQ sockets connect to the same REP socket, ZeroMQ distributes the messages among them using a round-robin fashion by default. Conversely, if multiple REP sockets bind to the same address, a REQ socket connecting to that address will randomly select one of the available REP sockets. This automatic load balancing and failover is built into the transport layer.

The PUB/SUB pattern is another classic example. A PUBlisher socket sends messages without knowing or caring who is listening. A SUBscriber socket registers interest in specific message "topics" (which are just prefixes of the message payload) and only receives messages matching those topics.

# Publisher
import zmq

context = zmq.Context()
socket = context.socket(zmq.PUB)
socket.bind("tcp://*:5556")

while True:
    socket.send_string("A.Update", "This is an update message")
    time.sleep(1)
# Subscriber 1 (interested in A)
import zmq

context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.connect("tcp://localhost:5556")
socket.setsockopt_string(zmq.SUBSCRIBE, "A") # Subscribe to topic "A"

while True:
    topic, message = socket.recv_multipart()
    print(f"Subscriber 1 received: {topic.decode()} - {message.decode()}")
# Subscriber 2 (interested in B)
import zmq

context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.connect("tcp://localhost:5556")
socket.setsockopt_string(zmq.SUBSCRIBE, "B") # Subscribe to topic "B"

while True:
    topic, message = socket.recv_multipart()
    print(f"Subscriber 2 received: {topic.decode()} - {message.decode()}")

In this PUB/SUB setup, the publisher sends "A.Update" with a payload. Subscriber 1, subscribed to "A", will receive it. Subscriber 2, subscribed to "B", will not. If the publisher sent "B.Notification", Subscriber 2 would receive it, and Subscriber 1 would not. The setsockopt_string(zmq.SUBSCRIBE, "topic") is the crucial part for filtering.

A subtle but powerful aspect of ZeroMQ’s broker-less design is its handling of connection establishment and routing. When you connect a socket, ZeroMQ doesn’t immediately try to establish the underlying TCP connection. Instead, it enters an "in progress" state. The actual TCP connection is only made when the first message is sent. This lazy connection establishment means that your application can declare its intent to connect to many endpoints without incurring the overhead of establishing all those connections upfront. ZeroMQ will only establish a connection when it’s actually needed for sending a message. This is particularly useful in dynamic environments or when dealing with services that might not always be available.

The next step is to explore how to combine these patterns for more complex asynchronous workflows using patterns like PUSH/PULL or DEALER/ROUTER.

Want structured learning?

Take the full Zeromq course →