In RabbitMQ, producers don't send messages to queues. They publish to an exchange, and the exchange decides which queues receive a copy. There are four RabbitMQ exchange types: direct, fanout, topic and headers. Each one routes differently, and choosing between them is most of the design work in a RabbitMQ setup.
Routing is half the story. The other half is making sure messages survive crashes, restarts and slow consumers. This guide covers both, with Go examples.
Why use a message queue at all
Say service A needs service B to do some work. If A calls B's API directly:
- If B is down, the work is lost unless A retries.
- A waits for B before it can respond to its own caller.
- A traffic spike hits B directly and may knock it over.
A message queue sits between them. A drops a message into the queue and moves on, and B picks it up when it's ready. If B is down, the message waits. If a burst arrives, the queue absorbs it. This gives you decoupling (A doesn't need to know about B), asynchronous processing (A doesn't wait) and buffering (spikes are smoothed out).
The RabbitMQ model: producer, exchange, queue, consumer
RabbitMQ implements the AMQP 0-9-1 model with six main parts:
| Component | Role |
|---|---|
| Producer | Publishes messages to an exchange, never directly to a queue |
| Exchange | Routes each message to zero or more queues according to its type and bindings |
| Binding | A rule linking an exchange to a queue, often with a binding key or pattern |
| Routing key | A label the producer attaches to each message. The exchange compares it with bindings |
| Queue | Stores messages until a consumer takes them |
| Consumer | Receives messages from a queue and processes them |
Why put an exchange in front of queues? Because the producer only needs to know the exchange name and a routing key. Which queues receive the message is decided by bindings, which you can change without touching producer code. A new service that wants order events binds its own queue, and the order service never changes.
You may have seen code that appears to publish straight to a queue. That uses the default exchange, a nameless direct exchange to which every queue is automatically bound by its own name. It's convenient for simple work queues, but it's still an exchange.
RabbitMQ exchange types
Direct exchange
A direct exchange delivers a message to every queue whose binding key exactly equals the routing key. Several queues can bind with the same key, and one queue can have several bindings.
Typical use: routing by a fixed category, such as log severity or task type.
// messages with routing key "error" go to the errors queue
must(ch.ExchangeDeclare("logs", "direct", true, false, false, false, nil))
must(ch.QueueBind("log-errors", "error", "logs", false, nil))
must(ch.QueueBind("log-all", "error", "logs", false, nil))
must(ch.QueueBind("log-all", "info", "logs", false, nil))Fanout exchange
A fanout exchange ignores the routing key and copies every message to all bound queues. It's a broadcast.
Typical use: "tell everyone". For example, a user.registered event consumed by the email, analytics and CRM services, each with its own queue.
Topic exchange
A topic exchange matches routing keys against patterns. Routing keys are dot-separated words, and binding patterns can use two wildcards:
*matches exactly one word.#matches zero or more words.
| Binding pattern | order.created.asia | order.paid.europe | order.created.th.asia |
|---|---|---|---|
order.*.asia | Yes | No | No |
order.# | Yes | Yes | Yes |
*.paid.* | No | Yes | No |
#.asia | Yes | No | Yes |
A binding of # alone receives everything, which makes the topic exchange behave like fanout. A binding with no wildcards behaves like direct. That flexibility is why topic exchanges are a common default for event-driven systems.
Headers exchange
A headers exchange ignores the routing key and matches on message header values. The binding's x-match argument decides whether all or any of the listed headers must match:
must(ch.QueueBind("pdf-reports", "", "documents", false, amqp.Table{
"x-match": "all",
"format": "pdf",
"type": "report",
}))It suits routing on several attributes that don't fit a dotted key, but it's the least used of the four.
Comparing RabbitMQ exchange types
| Type | Routes by | Typical use |
|---|---|---|
| Direct | Exact routing key match | Task types, log levels, targeted commands |
| Fanout | Nothing, copies to every bound queue | Broadcast events, cache invalidation |
| Topic | Wildcard pattern on the routing key | Domain events with hierarchy (order.paid.asia) |
| Headers | Header values with x-match | Multi-attribute routing |
Most systems use direct and topic exchanges for nearly everything.
A topic exchange end to end in Go
This example uses the official Go client, github.com/rabbitmq/amqp091-go. The must helper keeps it short. In a service, return errors instead of exiting.
package main
import (
"context"
"log"
"time"
amqp "github.com/rabbitmq/amqp091-go"
)
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
conn, err := amqp.Dial("amqp://app:secret@localhost:5672/")
must(err)
defer conn.Close()
ch, err := conn.Channel()
must(err)
defer ch.Close()
// durable topic exchange
must(ch.ExchangeDeclare("orders", "topic", true, false, false, false, nil))
// durable quorum queue that dead-letters failed messages
_, err = ch.QueueDeclare("orders.asia", true, false, false, false, amqp.Table{
"x-queue-type": "quorum",
"x-dead-letter-exchange": "orders.dlx",
"x-delivery-limit": 5,
})
must(err)
must(ch.QueueBind("orders.asia", "order.*.asia", "orders", false, nil))
// publisher confirms: the broker acknowledges each publish
must(ch.Confirm(false))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
dc, err := ch.PublishWithDeferredConfirmWithContext(ctx,
"orders", "order.created.asia",
true, // mandatory: return the message if no queue matches
false, // immediate: not supported by RabbitMQ
amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
MessageId: "ord-10231-created",
Body: []byte(`{"orderId":10231,"region":"asia"}`),
})
must(err)
if !dc.Wait() {
log.Println("broker nacked the message, publish again")
}
}Reliable messaging in RabbitMQ
A message can be lost in three places: between producer and broker, inside the broker, and between broker and consumer. Each needs its own setting.
Publisher confirms: producer to broker
Without confirms, Publish returns as soon as the bytes leave your process. If the broker crashes a moment later, you'll never know. With ch.Confirm, the broker acknowledges each message once it has taken responsibility for it, which for persistent messages means after it is stored. Treat a publish as done only when the confirm arrives.
Set mandatory as well, and listen on ch.NotifyReturn. Otherwise a message that matches no binding is silently dropped.
Durable queues and persistent messages: surviving a restart
A durable queue survives a broker restart. A persistent message (DeliveryMode: amqp.Persistent) is written to disk. You need both, because a persistent message in a non-durable queue is still lost on restart.
For data you can't afford to lose, use quorum queues. They replicate each message to a majority of nodes using Raft, and are always durable. Classic mirrored queues were removed in RabbitMQ 4.0, so quorum queues (or streams) are the replicated option today.
Consumer acknowledgements: broker to consumer
With manual acknowledgements, RabbitMQ keeps a message until the consumer confirms it has been processed. If the consumer's connection or channel closes before it acks, the message is requeued and delivered to another consumer.
must(ch.Qos(20, 0, false)) // prefetch: at most 20 unacked messages for this consumer
msgs, err := ch.Consume("orders.asia", "billing-worker", false, false, false, false, nil)
if err != nil {
log.Fatal(err)
}
for d := range msgs {
if err := handleOrder(d.Body); err != nil {
d.Nack(false, false) // don't requeue: send to the dead letter exchange
continue
}
d.Ack(false)
}Ack after the work is done and committed, not when the message arrives. Auto-ack mode (autoAck: true) removes the message the moment it's sent, so a crash mid-processing loses it.
This gives you at-least-once delivery. A consumer can finish the work, crash before acking, and receive the message again. Consumers must be idempotent. Record processed message IDs in the same transaction as the side effect, the same idea as the idempotency keys in preventing race conditions in payment systems.
Dead letter exchanges: handling poison messages
Some messages will never succeed, for example because of malformed JSON or a reference to a deleted record. Requeueing them forever (Nack(false, true)) creates a hot loop that blocks everything behind them. Instead, configure a dead letter exchange (DLX). A message is dead-lettered when:
- a consumer rejects or nacks it with
requeue=false, - its TTL expires (
x-message-ttlon the queue, orexpirationon the message), - the queue exceeds its length limit, or
- it exceeds a quorum queue's
x-delivery-limit.
Bind a queue to the DLX, alert on its depth, and give operators a way to inspect and replay messages. For delayed retries, a common pattern is a retry queue with a TTL whose DLX points back at the main exchange.
Prefer setting DLX and TTL through policies (rabbitmqctl set_policy) rather than queue arguments. Policies can be changed later without deleting and redeclaring the queue.
Prefetch: spreading work evenly
By default a consumer has no prefetch limit, so one worker can end up holding thousands of unacked messages while others sit idle. Qos(n, 0, false) caps the unacked messages per consumer. Start with a small number for slow, heavy jobs and a larger one for fast, light ones, then tune by watching throughput and consumer utilisation.
When RabbitMQ fits, and when it doesn't
RabbitMQ fits task distribution, flexible routing, per-message acknowledgement and request/reply patterns. If you need a long-lived, replayable event log that many consumers read at their own offsets, Kafka's model suits better. See Apache Kafka topics, partitions and consumer groups. For fire-and-forget broadcasts where losing a message is acceptable, Redis Pub/Sub is lighter.
FAQ
What are the four RabbitMQ exchange types?
Direct (exact routing key match), fanout (broadcast to all bound queues), topic (wildcard patterns on dot-separated keys) and headers (match on message headers).
What is the difference between a direct and a topic exchange?
Direct compares the routing key to the binding key exactly. Topic matches it against patterns with * (one word) and # (zero or more words), so one binding can cover many related keys.
What is the default exchange in RabbitMQ?
A nameless direct exchange that every queue is automatically bound to, using the queue name as the binding key. Publishing with exchange "" and routing key my-queue delivers to my-queue.
Does RabbitMQ guarantee exactly-once delivery?
No. With publisher confirms, durable queues, persistent messages and manual acks you get at-least-once delivery. Make consumers idempotent to get exactly-once effects.
What happens to unacknowledged messages when a consumer crashes?
When the consumer's channel or connection closes, RabbitMQ requeues its unacked messages and delivers them to other consumers, marked as redelivered.
Reliable RabbitMQ checklist
- Exchange type chosen deliberately, usually direct or topic
- Durable exchanges and quorum queues for important data
- Persistent messages and publisher confirms on the producer
-
mandatoryset, with returned messages handled - Manual acks, sent only after the work is committed
- Idempotent consumers keyed on message ID
- Dead letter exchange with monitoring and a replay path
- Prefetch set and tuned
If you're designing an event-driven system and want help getting the messaging layer right, Vectorkub builds backend systems on RabbitMQ and Kafka.
