RabbitMQ exchanges and routing
How messages find queues. The exchange types are catalogued in 03_exchanges_queues_dlx.md; this note is about routing behavior and the topologies you build from it — the part interviewers dig into after you’ve named direct/topic/fanout/headers.
The routing pipeline
producer --(exchange, routing_key)--> exchange --(bindings match?)--> queue(s) --> consumers
Three facts that drive everything:
- Producers never publish to a queue — always to an exchange (possibly the default one).
- A binding = (exchange, queue, binding key). Routing is matching the message’s routing key against binding keys — the algorithm depends on exchange type.
- If a message matches multiple queues, each gets its own copy; if it matches none, it’s silently dropped (unless
mandatoryor an alternate exchange is set — below).
The default exchange — the invisible one
The nameless direct exchange ("") auto-binds every queue by its queue name. basic_publish(exchange="", routing_key="tasks") therefore looks like publishing to a queue — it’s still exchange routing. Worth saying explicitly in interviews; “you can publish straight to a queue” is technically never true.
Topic wildcards — the exact semantics
Binding keys on a topic exchange are dot-separated words with two wildcards:
*matches exactly one word#matches zero or more words
| Routing key | logs.* |
logs.# |
*.error |
# |
|---|---|---|---|---|
logs.error |
yes | yes | yes | yes |
logs.app.error |
no | yes | no | yes |
logs |
no | yes | no | yes |
Note the classics: logs.* does not match logs (star needs a word) while logs.# does; # alone turns a topic exchange into a fanout.
The five canonical topologies
| Pattern | Exchange | Shape | Use |
|---|---|---|---|
| Work queue | default/direct | 1 queue, N competing consumers | background jobs, Celery |
| Pub/sub | fanout | 1 exchange → queue per subscriber | broadcast invalidation, notifications |
| Selective routing | direct | binding key per severity/type | route error to pager queue, info to log queue |
| Topic routing | topic | wildcard subscriptions | orders.eu.*, *.payment.failed |
| Request/reply (RPC) | default + reply_to |
caller declares exclusive reply queue; correlation_id matches responses |
sync calls over AMQP (04_rabbitmq_interview.md Q18) |
The key design insight: routing logic lives in the broker topology, not in consumers. Contrast with Kafka, where the broker only knows partitions and any filtering is consumer-side (../00_broker_comparison.md).
Declaring a topology (pika)
Idempotent declarations — every declare is create-if-missing, so producers and consumers can both declare defensively:
import pika
conn = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
ch = conn.channel()
ch.exchange_declare("events", exchange_type="topic", durable=True)
ch.queue_declare("billing.payments", durable=True)
ch.queue_bind("billing.payments", "events", routing_key="payment.*")
ch.queue_bind("billing.payments", "events", routing_key="refund.issued") # multi-bind is fine
ch.basic_publish(
exchange="events",
routing_key="payment.captured",
body=b'{"order_id": 42}',
properties=pika.BasicProperties(delivery_mode=pika.DeliveryMode.Persistent),
)
Re-declaring with different properties (durable, type, arguments) raises a channel error — topology changes in production usually mean new names + migration, not in-place edits.
Unroutable messages — mandatory flag and alternate exchange
Two safety nets for “matched no queue”:
mandatory=Trueon publish: broker returns the message to the producer (asyncbasic.returncallback) instead of dropping. Easy to publish-and-forget past it; you must register the return handler.- Alternate exchange (AE):
exchange_declare(..., arguments={"alternate-exchange": "unrouted"})— anything unroutable is diverted to the AE (typically a fanout into an audit queue). Set-and-forget, preferred in production over per-publish flags.
Same idea at the consumer end is the DLX — dead-lettering on reject/TTL/overflow (03_exchanges_queues_dlx.md); AE catches routing misses, DLX catches consumption failures.
Exchange-to-exchange bindings and other specials
- E2E bindings (
exchange_bind): compose routing in layers — e.g., oneeventstopic exchange feeding per-team fanouts. Keeps one producer-facing exchange while teams own their sub-topologies. - Consistent-hash exchange (plugin): shards messages across N queues by hashing the routing key — RabbitMQ’s answer to partition-style parallelism with per-key ordering (each key always lands in the same queue).
- Delayed message exchange (plugin): schedule delivery with
x-delay— the standard way to do retry-with-backoff topologies (retry queue with TTL + DLX back to the work queue is the plugin-free alternative). - Headers exchange: match on message headers with
x-match: all|anyinstead of routing key — rare; reach for it only when routing criteria genuinely aren’t a single string.
Common pitfalls
- Publishing before any queue is bound → message dropped, no error, “RabbitMQ is losing messages.” Declare bindings before producing, or use AE/mandatory.
- Fanout used where topic was needed → every consumer parses and discards 95% of traffic that the broker could have filtered.
- One binding per event type on a direct exchange scaling into hundreds of bindings — that’s the sign you wanted
topicwith a key convention (domain.entity.action). - Treating routing keys as free-form strings — settle a convention early; wildcards are only as good as the key schema.
Interview angle
- “Walk a message from producer to consumer.” — Exchange → binding match → copies to queues → competing consumers per queue. Bonus points for the default exchange detail.
- “Difference between
*and#in topic bindings?” — One word vs zero-or-more; give thelogs.*vslogs.#edge case. - “What happens to a message that matches no binding?” — Dropped silently; mitigate with alternate exchange or
mandatory. This distinguishes people who ran it in production. - “How do you get per-key ordering with parallel consumers?” — Consistent-hash exchange (or one queue per key group) — and compare with Kafka’s partition-key model.