PRODUCER publishes message routing.key EXCHANGE routes by rule binding A binding B queue-a queue-b CONSUMER processes msg CONSUMER processes msg

Producer → Exchange → Queue → Consumer — AMQP 0-9-1 message flow

The core idea: Producers never write directly to queues. They publish to an exchange, which applies routing rules to decide which queues receive a copy. This decouples senders from receivers.

The Four Actors

Every RabbitMQ topology is built from the same four building blocks.

Producer

Any application that publishes messages to an exchange. Producers specify a routing key and optional headers. They don't know which queues exist — that's the exchange's job.

channel.basic_publish(
  exchange='orders',
  routing_key='order.created',
  body=b'{"id": 42}'
)
Exchange

Receives messages and routes them to zero or more queues based on its type and binding rules. An unroutable message is silently discarded — or returned to the producer if mandatory=True.

  • Types: direct, fanout, topic, headers
  • Can be durable or transient
  • Can be internal (producer-invisible)
Queue

An ordered buffer that stores messages until a consumer is ready. Each queue is independent — a slow consumer in one queue doesn't stall another.

  • durable: survives broker restart
  • exclusive: single connection only
  • auto-delete: removed when unused
Consumer

An application that subscribes to a queue and processes messages. Sends an acknowledgment (ack) when done, telling the broker it's safe to permanently delete the message.

def on_message(ch, method, props, body):
    process(body)
    ch.basic_ack(
      delivery_tag=method.delivery_tag
    )
Bindings

A binding is a rule that tells an exchange to route messages to a specific queue. Bindings are created explicitly — queues don't automatically receive messages just because they exist. The binding can include a binding key (for direct and topic exchanges) or a headers map (for headers exchanges).

# Bind queue 'billing' to exchange 'payments' for routing key 'billing'
channel.queue_bind(
  queue='billing',
  exchange='payments',
  routing_key='billing'
)

Message Anatomy

Every AMQP message has two parts: routing metadata and an opaque payload body.

Properties (headers)
routing-key: order.created
delivery-mode: 2 (persistent)
content-type: application/json
message-id: a1b2c3d4
correlation-id: rpc-xyz
reply-to: amq.reply-to
timestamp: 1708617600
Body (opaque bytes)
{
  "order_id": 42,
  "user_id": "u-789",
  "items": [...]
}
delivery-mode: 2

Persistent — written to disk before delivery. Survives a broker crash.

correlation-id

Ties a reply to its originating request. Essential for RPC patterns.

reply-to

Queue name for the consumer to send a response back to the caller.

Connections & Channels

Opening a TCP connection per operation is expensive. AMQP multiplexes a single TCP socket into many lightweight virtual channels.

Connection

A persistent TCP socket to the broker. Creating one takes ~100ms. Reuse it for the lifetime of your process. One connection per process is the standard pattern.

Channel

A virtual sub-connection inside a TCP socket. Cheap to create. Not thread-safe — use one channel per thread. Typically: one channel to publish, one to consume.

Common mistake: Sharing a channel across threads causes interleaved frames and hard-to-debug errors. Always create one channel per thread, never share them.

Acknowledgments

Acks tell the broker a message was successfully processed. Without an ack, RabbitMQ keeps the message and redelivers it if the consumer disconnects.

basic.ack

Success. The broker permanently removes the message. Set multiple=True to ack all unacknowledged messages up to this delivery tag in one call.

channel.basic_ack(
  delivery_tag=tag
)
basic.nack

Failure. Use requeue=True to put the message back, or False to discard it (routing it to the dead-letter exchange if configured).

channel.basic_nack(
  delivery_tag=tag,
  requeue=False
)
Never use auto_ack=True in production. It acknowledges immediately on delivery — before your code runs. If your process crashes, the message is permanently lost with no trace.

The exchange type is set at declaration time and determines routing logic. It cannot be changed without deleting and recreating the exchange.

Direct exact routing key match → one queue

Routes a message to every queue whose binding key exactly matches the routing key. Multiple queues can share the same binding key, getting identical copies. The default exchange is a pre-declared nameless direct exchange — binding to it uses the queue name as the routing key.

PRODUCER rk: billing DIRECT exchange ✓ billing ✗ shipping billing shipping worker
# Declare and bind
channel.exchange_declare('payments', exchange_type='direct')
channel.queue_bind('billing',  'payments', routing_key='billing')
channel.queue_bind('shipping', 'payments', routing_key='shipping')

# Only the 'billing' queue receives this message
channel.basic_publish('payments', routing_key='billing', body=msg)
Fanout broadcasts to all bound queues — routing key ignored

Delivers a copy of every message to all queues bound to it. The routing key is completely ignored. Each subscriber receives every event independently — a slow consumer's queue fills up independently of a fast one's.

PRODUCER FANOUT all queues analytics notifications audit-log worker worker worker
# Fanout ignores routing key completely
channel.exchange_declare('events', exchange_type='fanout')
channel.queue_bind('analytics',     'events')
channel.queue_bind('notifications', 'events')
channel.queue_bind('audit-log',     'events')

channel.basic_publish('events', routing_key='', body=msg)
# All three queues receive an independent copy
Topic wildcard pattern matching on dot-separated routing keys

Routes based on pattern matching against a dot-segmented routing key like usa.stocks.news. Binding patterns use two wildcards:

* (star)

Matches exactly one word. *.error matches app.error but not app.db.error.

# (hash)

Matches zero or more words. app.# matches app.error, app.db.error, and app.

PRODUCER usa.stocks.news TOPIC exchange ✓ *.stocks.* ✓ usa.# ✗ europe.# stocks-feed usa-all europe-all worker worker
# Topic exchange — wildcard binding patterns
channel.exchange_declare('market', exchange_type='topic')
channel.queue_bind('stocks-feed', 'market', routing_key='*.stocks.*')
channel.queue_bind('usa-all',     'market', routing_key='usa.#')
channel.queue_bind('europe-all',  'market', routing_key='europe.#')

# 'usa.stocks.news' → matches *.stocks.* AND usa.# → 2 queues
channel.basic_publish('market', routing_key='usa.stocks.news', body=msg)
Headers routes on message header attributes, not routing key

Ignores the routing key entirely. Routes based on message header values. Each binding specifies a set of header key-value pairs plus an x-match argument — all (AND) or any (OR).

x-match: all

ALL headers must match. Precise targeting — use for narrowly scoped consumers.

x-match: any

ANY header must match. Broader routing — use when one attribute is sufficient.

# Headers exchange — routing key is ignored
channel.exchange_declare('reports', exchange_type='headers')
channel.queue_bind('eu-pdf-queue', 'reports', arguments={
    'x-match': 'all',     # both headers must match
    'format':  'pdf',
    'region':  'eu',
})

channel.basic_publish('reports', routing_key='', body=msg,
    properties=pika.BasicProperties(
        headers={'format': 'pdf', 'region': 'eu'}))
Rarely used in practice. Headers exchanges are slower and harder to debug than topic exchanges. Prefer a topic exchange with a structured routing key like eu.pdf.report for the same result with better observability.

At a glance

Type Routes on Match logic Best for
direct routing key exact string task routing, RPC
fanout (ignored) all bound queues broadcast, pub/sub
topic routing key wildcard (* #) selective broadcast
headers headers map all/any attributes attribute routing

These patterns combine exchange types, queue configuration, and consumer behavior to solve common distributed systems problems.

Work Queues competing consumers, one message per worker

Multiple workers subscribe to the same queue. RabbitMQ dispatches each message to exactly one worker using round-robin. Add more workers to scale throughput linearly. Perfect for CPU-heavy jobs: image processing, report generation, sending emails.

PRODUCER task_queue worker-1 processing worker-2 idle round-robin ✓ done waiting
# All workers subscribe to the same queue
channel.basic_qos(prefetch_count=1)  # fair dispatch — one in-flight msg per worker
channel.basic_consume('task_queue', on_message_callback=do_work, auto_ack=False)
prefetch_count=1 is critical. Without it, RabbitMQ pre-buffers messages to workers regardless of their load. A slow worker piles up while fast workers sit idle. Setting it to 1 sends a new message only after the current one is acknowledged.
Publish / Subscribe every subscriber receives every message independently

Each subscriber has its own private queue bound to a fanout exchange. Every subscriber receives every event, independently. A slow subscriber doesn't delay a fast one — their queues are completely separate. Built on top of the fanout exchange.

Work Queue (wrong for pub/sub)

Shared queue → messages split among consumers. Each message processed by only one worker.

Pub/Sub (correct)

Own queue per subscriber → all subscribers get all messages. Fanout broadcasts copies.

# Each subscriber declares its own exclusive, auto-delete queue
result = channel.queue_declare(queue='', exclusive=True)
queue_name = result.method.queue

channel.queue_bind(queue_name, exchange='events')
channel.basic_consume(queue_name, on_message_callback=handler)
Exclusive + auto-delete queues are a natural fit. When the subscriber disconnects, its queue is deleted automatically. No stale queues accumulate after services restart.
Request / Reply (RPC) synchronous call-response over async messaging

Enables synchronous-looking remote calls over async messaging. The client publishes a request and blocks on a reply queue. A correlation_id ties each response back to its request, allowing a single reply queue to handle many concurrent calls.

1
Client creates a reply queue
Declares an exclusive reply queue (or uses amq.rabbitmq.reply-to for the direct reply shortcut). Generates a unique correlation_id UUID.
2
Client publishes request
Sends message to the RPC queue with reply_to and correlation_id properties set. Then blocks, consuming from the reply queue.
3
Server processes and replies
Picks up from the RPC queue, executes work, publishes result to reply_to with the same correlation_id. Acks the original request.
4
Client matches correlation ID
On each message from the reply queue, checks if correlation_id matches the pending request. If not, discards it (stale reply from a previous call). If yes, unblocks and returns the result.
# RPC client — using amq.rabbitmq.reply-to for zero-latency direct reply
corr_id = str(uuid.uuid4())

channel.basic_publish(
    exchange='',
    routing_key='rpc_queue',
    body=json.dumps({'n': 35}),
    properties=pika.BasicProperties(
        reply_to='amq.rabbitmq.reply-to',
        correlation_id=corr_id,
    ),
)
Dead Letter Queues capture failed, expired, or rejected messages

When a message becomes "dead", RabbitMQ can route it to a Dead Letter Exchange (DLX) rather than silently discarding it. From there it routes to a dead letter queue for inspection, alerting, or retry. There are four causes:

nack + requeue=False

Consumer explicitly rejects a message as unprocessable (basic.nack or basic.reject with requeue=false).

TTL expires

Message sits in queue longer than the per-message or per-queue TTL (x-message-ttl).

Queue length limit

Queue exceeds x-max-length (or x-max-length-bytes) — the message at the head of the queue is dropped and dead-lettered.

Quorum delivery limit

Message is redelivered to a quorum queue more times than its x-delivery-limit. Applies to quorum queues only.

# 1. Declare the DLX and dead letter queue
channel.exchange_declare('dlx', exchange_type='direct')
channel.queue_declare('dead-letters', durable=True)
channel.queue_bind('dead-letters', 'dlx', routing_key='failed')

# 2. Configure the main queue to use the DLX (via queue arguments)
#    Note: prefer policies over x-arguments in production — policies can
#    be updated without deleting/recreating the queue.
channel.queue_declare('orders', durable=True, arguments={
    'x-dead-letter-exchange':    'dlx',
    'x-dead-letter-routing-key': 'failed',  # omit to reuse original routing key
    'x-message-ttl':             30000,        # 30s TTL
})
Never lose a message silently. Configure a DLX on every production queue. A dead letter queue turns "that order just disappeared" into a traceable, actionable event with full message history preserved. If the DLX doesn't exist when a message is dead-lettered, the message is silently dropped.

Related Topics