Originally published at warrenops.io.
Every message in a dead-letter queue carries its own incident report. RabbitMQ writes it into the x-death header when it dead-letters the message. Most people have seen it in the management UI as a wall of nested tables and closed the tab. Here is how to read it.
Where it comes from
When RabbitMQ dead-letters a message, it does not just move it. It republishes the message to the queue's dead-letter exchange (set by the x-dead-letter-exchange queue argument or a dead-letter-exchange policy), optionally with a new routing key (x-dead-letter-routing-key). Before publishing, it modifies the headers:
- It adds or updates an
x-deathheader, an array of tables. - The first time a message is dead-lettered it also sets
x-first-death-reason,x-first-death-queueandx-first-death-exchange. These are never changed afterwards. - Recent RabbitMQ versions also set
x-last-death-reason,x-last-death-queueandx-last-death-exchange, updated on every dead-lettering. - If the message died because it expired and had a per-message TTL, the
expirationproperty is removed (otherwise it would expire again in the DLQ) and preserved in the x-death entry asoriginal-expiration.
Everything else, payload, properties, your own headers, is untouched.
The entry
A typical header, as the management UI or a client shows it:
x-death: [
{
"count": 3,
"reason": "rejected",
"queue": "orders.process",
"time": 1759042215,
"exchange": "orders",
"routing-keys": ["order.created"]
},
{
"count": 2,
"reason": "expired",
"queue": "orders.retry.wait",
"time": 1759042155,
"exchange": "orders.retry",
"routing-keys": ["order.created"]
}
]
x-first-death-exchange: orders
x-first-death-queue: orders.process
x-first-death-reason: rejected
| Field | Meaning |
|---|---|
queue |
The queue the message was in when it was dead-lettered. This is the queue whose consumer rejected it, whose TTL ran out, or whose length limit hit. Not the queue it landed in. |
reason |
rejected: a consumer did basic.reject or basic.nack with requeue=false. expired: message or queue TTL ran out. maxlen: the queue was over x-max-length or x-max-length-bytes and the overflow policy is drop-head or reject-publish-dlx. delivery_limit: a quorum queue redelivered the message more often than its delivery-limit allows. |
time |
Unix timestamp (seconds) of the first time this queue dead-lettered the message for this reason. When count grows, time does not move. |
exchange |
The exchange the message was originally published to before it arrived in queue. Together with routing-keys this is where a replay sends it back. |
routing-keys |
The routing key(s) the message was published with. Usually one. Can be several if the message was CC'd or BCC'd. |
count |
How many times this message was dead-lettered from this queue for this reason. Entries are not appended per event; RabbitMQ finds the entry with the same queue and reason and increments it. |
original-expiration |
Only present when reason is expired and the message had a per-message expiration. The value that was removed. |
Reading the array
The array is ordered most recent first: x-death[0] is the last thing that happened to the message. In the example above, the story reads:
- The message was published to exchange
orderswith keyorder.createdand landed inorders.process. - The consumer rejected it. It went to
orders.retry.wait, a queue with a TTL and no consumers. - It expired there and came back to
orders.process. Rejected again. Expired again. Rejected a third time. - After the third rejection (
count: 3) the message ended up wherever you are looking at it now, presumably a final DLQ, because the consumer stopped requeueing after three attempts or the retry queue's TTL routed it elsewhere.
Note what the header does not tell you: which DLQ the message is in now (you know that, you are looking at it), and what went wrong in the consumer. For that you need the consumer's log at time, and time is the first rejection, not the last. If your retry cycle is 60 seconds and count is 3, the last rejection was about two minutes after time.
Same queue twice with different reasons
Because entries are keyed by queue and reason, a queue can appear twice: once with rejected, once with expired. That usually means a consumer was down for a while (messages expired) and then came back and rejected the same message. Both entries carry their own time, so you can tell when each phase started.
Why time does not update
People look at time, see a timestamp from three days ago, and conclude the message has been sitting in the DLQ for three days. It may have arrived a minute ago after its tenth cycle. To know when a message actually entered the DLQ you need something outside the header: a timestamp property your producer set (that is when it was published, also wrong), a message-level TTL trick, or a tool that remembers when it first saw the message.
What the header looks like for each cause
| Reason | Typical pattern | What to check |
|---|---|---|
rejected, count 1 |
Single entry, one queue | Consumer log at time. Parsing error or a business rule, usually deterministic. Fix, then replay. |
rejected, count > 1 |
Alternating with an expired entry from a wait queue |
Retry topology did its job. Either a transient failure that outlasted the retries, or a real bug. The consumer log at the last attempt tells which. |
expired, from a work queue |
Single entry, the exchange is the normal one | Nobody consumed in time. Consumer count on that queue, consumer throughput, was the TTL sane. |
maxlen |
Many messages with identical time, seconds apart |
A burst the consumer could not absorb. Producer side, or scale consumers. These messages are usually fine to replay once the backlog is gone. |
delivery_limit |
Quorum queue, no rejected entry |
The consumer crashed or its connection dropped repeatedly while holding the message, without ever rejecting it. Classic poison message or a consumer that dies on this payload. |
Messages with no x-death at all
Not every message in a dead-letter queue was dead-lettered by RabbitMQ. Many frameworks catch the exception and republish the message to an error queue themselves. The broker never dead-letters it, so there is no x-death. Instead you get framework headers:
-
Spring AMQP (
RepublishMessageRecoverer):x-exception-message,x-exception-stacktrace,x-original-exchange,x-original-routingKey. -
MassTransit (
_errorqueues):MT-Fault-Message,MT-Fault-ExceptionType,MT-Fault-StackTrace,MT-Reason. -
NServiceBus:
NServiceBus.ExceptionInfo.ExceptionType,NServiceBus.ExceptionInfo.Message,NServiceBus.FailedQ.
These are often more useful than x-death because they carry the actual exception. But the "where to send it back" question then has to be answered from x-original-* or NServiceBus.FailedQ, and there is no count. Your replay tooling should read both conventions.
How to look at it
Management UI
Queue page → Get messages, ack mode Nack message requeue true, and a small count. The UI renders x-death as nested tables. It works for one or two messages; for fifty it is unreadable, and the peek marks every fetched message as redelivered.
CLI
rabbitmqadmin -f raw_json get queue=orders.dlq ackmode=ack_requeue_true count=5 \
| jq '.[] | .properties.headers["x-death"]'
-f raw_json makes rabbitmqadmin print the messages as JSON instead of a table, and jq pulls out the header. ack_requeue_true puts the messages back; they are marked redelivered afterwards, which is a property of RabbitMQ and cannot be avoided when peeking.
Code
def death_story(headers):
for d in headers.get("x-death", []):
keys = ",".join(d.get("routing-keys", []))
print(f"{d['count']}x {d['reason']:<14} in {d['queue']} "
f"(from {d['exchange']!r} / {keys}, first at {d['time']})")
In the Java client the values arrive as List<Map<String,Object>> with LongString values for strings; call toString() on them before comparing.
Two rules for your own consumers
-
Read
x-death[0].countto decide when to give up, not your own header. Your own header is lost if the message passes through a broker-side dead-lettering, and the broker's counter is authoritative. Look for the entry whosequeueis your own queue, not blindly[0]. -
Strip the death headers when you deliberately republish for a fresh start. A replay tool should do the same. A message that keeps its
x-deathafter a replay will be rejected on arrival by any consumer that follows rule one.
Where Warren fits
Warren shows each dead letter's x-death as a readable timeline: which queue, why, how often, when, and a plain-language verdict such as "rejected three times by orders.process after cycling through orders.retry.wait". Messages a framework republished itself are recognised by their x-exception-* or MT-Fault-* headers, and "dead since" is filled from when Warren first saw the message, which x-death cannot tell you.
Try Warren in a minute (one compose file, demo broker with real dead letters included) · README on GitHub






