How to peek messages in an Azure Service Bus dead-letter queue
By Factodus · updated
Every queue and every topic subscription in Azure Service Bus has a dead-letter queue (DLQ): a subqueue where messages go when they can’t be delivered or processed. Nothing cleans it up for you, and time to live isn’t applied there, so dead letters stay until someone receives them. Before you fix or resubmit anything you want to look, and looking is exactly what a peek does: it reads messages without locking or removing them.
Short version. In the portal, open the queue or subscription, go to Service Bus Explorer, pick Peek Mode and the DeadLetter tab, and press Peek from start (up to 100 messages). In code, create a receiver on the dead-letter subqueue and call peek in a loop, starting each page one sequence number after the last message of the previous page. Then read
DeadLetterReasonandDeadLetterErrorDescription.
Where the dead-letter queue lives
The DLQ has no name of its own; it hangs off its parent entity. Raw paths, used by REST, the CLI and most tools, look like this:
orders/$deadletterqueue
billing-events/Subscriptions/invoices/$deadletterqueue
The SDKs build the path for you. You ask for the dead-letter subqueue when you create the receiver: SubQueue.DeadLetter
in .NET, subQueueType: "deadLetter" in JavaScript, ServiceBusSubQueue.DEAD_LETTER in Python.
There is also a transfer dead-letter queue, $Transfer/$DeadLetterQueue. It holds messages that auto-forwarding or
send-via could not deliver, and it lives on the source entity, not the destination. If a forwarded message seems to
have vanished, look there (SubQueue.TransferDeadLetter in .NET).
Peek is not receive
A peek returns a snapshot. It takes no lock, doesn’t touch the delivery count and doesn’t remove anything, so it is safe on a production queue that consumers are reading at the same time. A receive in peek-lock mode takes a lock and, if you abandon it or the lock expires, counts one more delivery. On a DLQ that only matters if something else also reads it, but on a main queue a curious receive can push a message closer to its own max delivery count.
Two properties of peek are worth knowing before you write the loop:
- A peek call returns at most the number of messages you ask for, with no minimum. A short page doesn’t mean you reached the end; only an empty page does.
- Peek walks in sequence-number order, the order messages were enqueued. To page, pass the sequence number of the last message you saw plus one.
In the Azure portal
- Open the namespace, then the queue (or the topic and its subscription).
- Select Service Bus Explorer in the left menu.
- Choose Peek Mode, then the DeadLetter tab. The tab shows the dead-lettered message count.
- Press Peek from start. Up to 100 messages appear in the grid; select one to see its body and its Message Properties, including the dead-letter reason and description.
- For more than the first 100, use Peek with options and give a start sequence number and a count.
You need the Azure Service Bus Data Receiver role (or Data Owner) for peek. Service Bus Explorer can export the grid to an Excel worksheet, but it has no search across bodies, and Microsoft lists sessions as unsupported. If the namespace is reachable only through a private endpoint, the browser has to run inside that virtual network.
.NET (Azure.Messaging.ServiceBus)
using Azure.Identity;
using Azure.Messaging.ServiceBus;
await using var client = new ServiceBusClient(
"<namespace>.servicebus.windows.net", new DefaultAzureCredential());
// For a subscription: client.CreateReceiver("<topic>", "<subscription>", options)
await using ServiceBusReceiver receiver = client.CreateReceiver(
"orders", new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });
long from = 0;
while (true)
{
IReadOnlyList<ServiceBusReceivedMessage> page =
await receiver.PeekMessagesAsync(maxMessages: 100, fromSequenceNumber: from);
if (page.Count == 0) break;
foreach (ServiceBusReceivedMessage m in page)
Console.WriteLine($"{m.SequenceNumber} {m.DeadLetterReason}: {m.DeadLetterErrorDescription}");
from = page[^1].SequenceNumber + 1;
}
JavaScript (@azure/service-bus)
import { ServiceBusClient } from "@azure/service-bus";
import { DefaultAzureCredential } from "@azure/identity";
const client = new ServiceBusClient("<namespace>.servicebus.windows.net", new DefaultAzureCredential());
// For a subscription: client.createReceiver("<topic>", "<subscription>", { subQueueType: "deadLetter" })
const receiver = client.createReceiver("orders", { subQueueType: "deadLetter" });
let fromSequenceNumber; // undefined: start at the beginning
for (;;) {
const page = await receiver.peekMessages(100, fromSequenceNumber ? { fromSequenceNumber } : {});
if (page.length === 0) break;
for (const m of page) {
console.log(m.sequenceNumber.toString(), m.deadLetterReason, m.deadLetterErrorDescription);
}
fromSequenceNumber = page[page.length - 1].sequenceNumber.add(1); // sequence numbers are Long
}
await receiver.close();
await client.close();
Python (azure-servicebus)
from azure.identity import DefaultAzureCredential
from azure.servicebus import ServiceBusClient, ServiceBusSubQueue
with ServiceBusClient("<namespace>.servicebus.windows.net", DefaultAzureCredential()) as client:
# For a subscription: client.get_subscription_receiver("<topic>", "<subscription>", sub_queue=...)
with client.get_queue_receiver("orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER) as receiver:
start = 0
while True:
page = receiver.peek_messages(max_message_count=100, sequence_number=start)
if not page:
break
for m in page:
print(m.sequence_number, m.dead_letter_reason, m.dead_letter_error_description)
start = page[-1].sequence_number + 1
All three samples use Microsoft Entra ID through DefaultAzureCredential. A namespace-level connection string works
too. Entity-level strings (with EntityPath) are fine for one queue.
What the dead-letter reason tells you
Service Bus sets the reason itself in these cases, and your own code can set any other value when it dead-letters a message explicitly:
DeadLetterReason |
What happened | Where to look |
|---|---|---|
MaxDeliveryCountExceeded |
The message was delivered more times than the entity’s max delivery count (10 by default) without being completed | Consumer exceptions, or messages received but never settled before the receiver closed |
TTLExpiredException |
The message expired and dead-lettering on expiration is on | Time to live on the message or entity, consumer throughput |
Session ID is null |
A message without a session ID was sent to a session-enabled entity | The sender |
HeaderSizeExceeded |
The message headers went over the size quota | Oversized application properties |
MaxTransferHopCountExceeded |
Auto-forwarding went through more than four hops | The forwarding chain |
MaxDeliveryCountExceeded is by far the most common and the least specific. It means “the consumer kept failing”,
so the useful information is in the body and properties: peek the whole DLQ and look for what the failing messages
have in common, such as one customer, one message type or one schema version.
From VS Code
If you do this often, Queue Studio for Service Bus (our extension) shows every queue and subscription with its dead-letter queue and counts in a tree. Clicking a DLQ peeks all of it in pages, up to a limit you set, with the reason in the table and the formatted body below. That part is free. It works with the local emulator as well as Azure.