Skip to main content
Azure · lesson 15 of 19
Azure intermediate Lesson 15 of 19

Azure Service Bus: Dead-Lettering

Reject a bad message into Azure Service Bus's dead-letter queue with the Python SDK against the official emulator, and read it back from the DLQ with the reason attached — a message a consumer actively rejects, not one that silently vanished.

By SkillByExample · Published

azure service-bus messaging

A consumer doesn’t have to just complete or abandon a message — it can actively reject one it knows it can’t process, with a reason attached, so the message goes somewhere a human or a later job can inspect it, rather than silently discarding it or leaving it to redeliver up to the queue’s delivery limit before the broker does the same thing automatically. This lesson continues against the same Service Bus Emulator image as the previous one.

Rejecting One Message While Completing the Others

from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-0"))
        sender.send_messages(ServiceBusMessage("order-1-bad-total"))
        sender.send_messages(ServiceBusMessage("order-2"))

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        for msg in receiver.receive_messages(max_message_count=3, max_wait_time=5):
            body = str(msg)
            if "bad-total" in body:
                receiver.dead_letter_message(msg, reason="bad-total")
                print("dead-lettered:", body)
            else:
                receiver.complete_message(msg)
                print("completed:", body)

    with client.get_queue_receiver(
        "orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
    ) as dlq_receiver:
        for msg in dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5):
            print("DLQ:", str(msg), "reason:", msg.dead_letter_reason)
            dlq_receiver.complete_message(msg)
{
  "UserConfig": {
    "Namespaces": [
      {
        "Name": "sbemulatorns",
        "Queues": [
          {
            "Name": "orders",
            "Properties": {
              "DeadLetteringOnMessageExpiration": false,
              "DefaultMessageTimeToLive": "PT1H",
              "DuplicateDetectionHistoryTimeWindow": "PT20S",
              "ForwardDeadLetteredMessagesTo": "",
              "ForwardTo": "",
              "LockDuration": "PT30S",
              "MaxDeliveryCount": 3,
              "RequiresDuplicateDetection": false,
              "RequiresSession": false
            }
          }
        ],
        "Topics": []
      }
    ],
    "Logging": { "Type": "File" }
  }
}
$ docker network create az-sb-net-15
4e13b1808b924be801881a972b9eea0c79242fb166d5c4f5af2ca4fa815416af
$ docker run -d --name az-sb-sql-15 --network az-sb-net-15 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/mssql/server:2025-CU10-ubuntu-24.04
0c1819475c4ac2c6c626daacb7f2792b32bd01a30f05c438c65ed64926d5b494
$ until docker logs az-sb-sql-15 2>&1 | grep -q 'SQL Server is now ready for client connections'; do sleep 1; done
$ docker run -d --name az-sb-emu-15 --network az-sb-net-15 -v "$(pwd)"/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json -e ACCEPT_EULA=Y -e SQL_SERVER=az-sb-sql-15 -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1
f288213a66ac94cdbb92dbb30e17c38624c50df9c1478f34dbbdf8741b2ea931
$ until docker logs az-sb-emu-15 2>&1 | grep -q 'Emulator Service is Successfully Up'; do sleep 1; done
$ docker run --rm --network container:az-sb-emu-15 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_dlq.py"
[notice] To update, run: pip install --upgrade pip
completed: order-0
dead-lettered: order-1-bad-total
completed: order-2
DLQ: order-1-bad-total reason: bad-total

order-1-bad-total never reaches the “completed” branch — the consumer recognized it as bad and called dead_letter_message with a reason instead of either completing it (which would discard the problem silently) or abandoning it (which would just redeliver the same bad message, up to MaxDeliveryCount times, before the broker dead-letters it automatically anyway — see Practice 3). Reading from sub_queue=ServiceBusSubQueue.DEAD_LETTER afterward shows exactly that one message, with dead_letter_reason carrying the string the consumer passed in.

Practice

1. Dead-letter a message with both a reason and an error_description, and read both back.
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-negative-total"))

    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        for msg in receiver.receive_messages(max_message_count=1, max_wait_time=5):
            receiver.dead_letter_message(
                msg,
                reason="validation-failed",
                error_description="total was negative",
            )

    with client.get_queue_receiver(
        "orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
    ) as dlq_receiver:
        for msg in dlq_receiver.receive_messages(max_message_count=1, max_wait_time=5):
            print("reason:", msg.dead_letter_reason)
            print("description:", msg.dead_letter_error_description)
            dlq_receiver.complete_message(msg)
$ docker run --rm --network container:az-sb-emu-15 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_dlq_description.py"
[notice] To update, run: pip install --upgrade pip
reason: validation-failed
description: total was negative

Both strings round-trip onto the DLQ message exactly as passed — error_description is a free-form detail alongside the shorter, more categorical reason, and neither is interpreted by Service Bus itself; they’re there purely for whatever reads the DLQ afterward.

2. Complete a message from the DLQ and confirm it doesn't come back a second time.
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-to-drain"))
    with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
        for msg in receiver.receive_messages(max_message_count=1, max_wait_time=5):
            receiver.dead_letter_message(msg, reason="draining-practice")

    with client.get_queue_receiver(
        "orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
    ) as dlq_receiver:
        first_pass = dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5)
        print("DLQ messages before drain:", len(first_pass))
        for msg in first_pass:
            dlq_receiver.complete_message(msg)

    with client.get_queue_receiver(
        "orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
    ) as dlq_receiver:
        second_pass = dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5)
        print("DLQ messages after drain:", len(second_pass))
$ docker run --rm --network container:az-sb-emu-15 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_dlq_drain.py"
[notice] To update, run: pip install --upgrade pip
DLQ messages before drain: 1
DLQ messages after drain: 0

Completing a DLQ message removes it from the DLQ the same way completing a main-queue message removes it from there — permanently, not just marking it read. The second read comes back empty because there’s nothing left to hand out, not because anything is being hidden from a receiver that already saw it once.

3. Abandon a message repeatedly, without ever dead-lettering it explicitly, until it exceeds MaxDeliveryCount — and see who dead-letters it this time.
from azure.servicebus import ServiceBusClient, ServiceBusMessage, ServiceBusSubQueue

CONN_STR = "Endpoint=sb://localhost;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"

with ServiceBusClient.from_connection_string(CONN_STR) as client:
    with client.get_queue_sender("orders") as sender:
        sender.send_messages(ServiceBusMessage("order-poison"))

    # Config.json sets MaxDeliveryCount to 3 — abandon it that many times, never completing it
    for attempt in range(3):
        with client.get_queue_receiver("orders", max_wait_time=5) as receiver:
            for m in receiver.receive_messages(max_message_count=1, max_wait_time=5):
                print(f"attempt {attempt}: delivery_count={m.delivery_count}")
                receiver.abandon_message(m)

    with client.get_queue_receiver(
        "orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER, max_wait_time=5
    ) as dlq_receiver:
        for m in dlq_receiver.receive_messages(max_message_count=5, max_wait_time=5):
            print("auto-dead-lettered:", str(m), "reason:", m.dead_letter_reason)
            dlq_receiver.complete_message(m)
$ docker run --rm --network container:az-sb-emu-15 -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-servicebus==7.15.0 2>&1 | tail -1 && python sb_auto_dlq.py"
[notice] To update, run: pip install --upgrade pip
attempt 0: delivery_count=0
attempt 1: delivery_count=1
attempt 2: delivery_count=2
auto-dead-lettered: order-poison reason: MaxDeliveryCountExceeded

Nobody ever called dead_letter_message on this one — Service Bus itself moved it to the DLQ once its delivery count hit MaxDeliveryCount, and set dead_letter_reason to MaxDeliveryCountExceeded rather than whatever string application code would have passed. That reason string is exactly how code reading the DLQ later tells “the broker gave up on this” apart from “a consumer explicitly rejected this.”

Cleanup

$ docker rm -f az-sb-emu-15 az-sb-sql-15
az-sb-emu-15
az-sb-sql-15
$ docker network rm az-sb-net-15
az-sb-net-15

Next: topics and subscriptions — one message fanned out to multiple consumers, filtered by a SQL expression on each subscription.

Frequently Asked Questions

What is a dead-letter queue (DLQ) in Azure Service Bus?
A separate sub-queue that exists automatically on every queue and topic subscription, holding messages that were explicitly dead-lettered by a consumer or that exceeded MaxDeliveryCount without ever being completed. It's read the same way as the main queue — with a receiver — just pointed at the DLQ sub-queue instead of the main one, via sub_queue=ServiceBusSubQueue.DEAD_LETTER.
Does dead-lettering a message delete it?
No — it moves the message to the DLQ sub-queue, where it stays until something explicitly reads and completes it from there. Dead-lettering is Service Bus's way of saying 'a consumer looked at this and decided it can't be processed as-is,' not 'discard this.'
What's attached to a message when it's dead-lettered on purpose?
The reason and error_description strings passed to dead_letter_message, readable afterward on the DLQ message as dead_letter_reason and dead_letter_error_description — enough for whatever reads the DLQ later to know why each message ended up there without having to re-run whatever validation originally rejected it.
Does a message that exceeds MaxDeliveryCount end up in the same DLQ as one dead-lettered explicitly?
Yes, the same sub-queue — but its dead_letter_reason is set by Service Bus itself (something like MaxDeliveryCountExceeded) rather than whatever string application code would have passed to dead_letter_message. Reading dead_letter_reason is how code tells the two cases apart after the fact.