Domain 3 of 4 · Chapter 1 of 4

Queue and process back-end operations with Azure Service Bus

Queues, topics, and subscriptions

A Python service that has to charge a card, re-index a document, or call a partner API that takes eight seconds should not do any of that inside the web request. It should hand the work to a broker and answer immediately. Azure Service Bus is the broker Azure provides for that hand-off, and the very first thing it makes you decide is the shape of the delivery: does exactly one worker act on this, or does every interested consumer get its own copy?

This page owns Service Bus itself: the entity types, the settlement and expiry rules that decide whether a message survives a crashed worker, and the Python client you drive them from, so that by the end you can pick an entity shape, write a handler that cannot silently lose work, and diagnose a message that never arrived. Its siblings own the neighbours you will reach for next: Azure Event Grid owns reactive event routing with its own event subscriptions, schemas, and retry story, and Azure Functions owns triggers and bindings, including the Service Bus trigger that can drive a receive loop on your behalf. What follows is the service-side behaviour both of those pages assume you already have.

Message or event: the word that separates the two products

Microsoft draws the line explicitly, and it is worth pinning before the vocabulary of two services starts overlapping. An event is "a lightweight notification of a condition or state change"[1] where "the publisher has no expectation about how the event is handled". A message is "raw data produced by a service to be consumed or stored elsewhere", and for it "a contract exists between publisher and consumer". Service Bus carries messages. The sender expects a particular consumer to do a particular job, and in the reliable receive mode it expects to learn that the job was done. That expectation is why almost everything else on this page exists.

Queues: competing consumers

A queue is point-to-point. Microsoft's wording is that queues "offer First In, First Out (FIFO) message delivery to one or more competing consumers"[2], and that "only one message consumer receives and processes each message". Ten worker processes reading one queue therefore give you ten times the throughput and not ten times the work: this is the competing consumers shape, and it is the reason a queue is the default answer for a back-end operation that must happen once.

The same page names the other things you get for free from that arrangement. Temporal decoupling, because "producers (senders) and consumers (receivers) don't have to send and receive messages at the same time". Load leveling, because "the consuming application only needs to handle average load instead of peak load". Loose coupling, because a consumer can be upgraded without touching the producer.

Topics and subscriptions: fan-out

A topic is the publish side and nothing else. Consumers "don't receive messages directly from the topic. Instead, consumers receive messages from subscriptions of the topic", and "a topic subscription resembles a virtual queue that receives copies of the messages that are sent to the topic". That is the fan-out shape: one send, one copy per subscription, each copy independently locked, retried, and dead-lettered by whoever reads that subscription. Because a subscription behaves like a queue, it also supports competing consumers inside itself, so the two shapes compose rather than compete. The figure below draws both, with the delivery count that matters marked on each.

The word "subscription" is overloaded across Azure, and on this page it always means the Service Bus sense: a named child entity of a topic that holds its own copy of the messages it matched. It is not your Azure subscription, the billing and resource container the namespace lives in. It is not an Event Grid event subscription, which is a routing rule binding a source to a handler endpoint and holds nothing. Readers who know one of the other two will silently map this term onto it, so where this page needs those senses it spells them out in full.

Subscription rules: choosing which copies arrive

A subscription starts out taking everything. "Each newly created topic subscription has an initial default subscription rule", and without an explicit filter condition "the applied filter is the true filter that enables all messages to be selected into the subscription". You narrow it by adding named rules, where each rule is "a filter condition that selects particular messages, and optionally contains an action that annotates the selected message"[3].

Service Bus supports exactly three filter types, and that list is complete:

Filter type What it matches When to reach for it
Correlation filter "a set of conditions that it matches against one or more of an arriving message's user and system properties"; multiple properties combine as a logical AND, and string comparison is case-sensitive Routing on exact values such as CorrelationId, Subject, or your own application properties. This is the default choice.
SQL filter "a SQL-like conditional expression that the broker evaluates against the arriving messages' user-defined properties and system properties", supporting EXISTS, IS NULL, NOT/AND/OR, relational operators, arithmetic, and LIKE Ranges, negation, pattern matching: anything an equality test cannot express.
Boolean filter TrueFilter selects every arriving message, FalseFilter selects none; both "derive from the SQL filter" Making the default behaviour explicit, or parking a subscription without deleting it.

Two constraints on that table decide real designs. First, Microsoft's own guidance is that "applications should choose correlation filters over SQL-like filters because they're much more efficient in processing and have less impact on throughput", and that SQL filter rules "cause lower overall message throughput at the subscription, topic, and namespace level". Second, and more often fatal: "All filters evaluate message properties. Filters can't evaluate the message body." If your routing key lives inside the JSON payload, no filter can see it. Promote it to an application property at send time or route in code after receiving.

Rule counting surprises people, so it is worth stating exactly. "All rules without actions are combined using an OR condition and result in a single message on the subscription even if you have multiple matching rules." But "each rule with an action produces a copy of the message." Microsoft's worked case: a subscription with five rules, two carrying actions and three not, receiving one message that matches all five, yields three messages on the subscription, one for the three action-free rules collectively and one each for the two rules with actions.

So the shape you choose is a statement about consumers, not about volume. One consumer that must act once means a queue. Several consumers that must each act on the same fact means a topic with one subscription apiece, narrowed by rules so each consumer only wakes for what concerns it.

Queue: competing consumersSenderQueueReceiver 1Receiver 2Receiver 3Each message goes to exactly one receiver.Adding receivers adds throughput, not copies.Topic: fan-outSenderTopicSubscription ASubscription BSubscription CEvery subscription gets its own copy.Consumers read a subscription, never the topic.
The two delivery shapes: one queue splitting work across receivers, one topic copying to every subscription.

How a receive ends: receive modes and settlement

Receiving a message from Service Bus is not one operation, it is a negotiation with an outcome. Microsoft calls that outcome a settlement: when the broker hands a message over, "the broker and client want to establish an understanding of whether the message is successfully processed and can be removed, or whether the message delivery or processing failed, and thus the message might have to be delivered again", and that acknowledgment "settles the understanding of both the client and broker"[4]. Two receive modes exist, and they differ entirely in when that settlement happens.

The two modes, before any detail

ReceiveAndDelete settles at delivery. It "instructs the broker to consider all messages it sends to the receiving client as settled when sent", so "the message is consumed as soon as the broker puts it onto the wire. If the message transfer fails, the message is lost." The queues article[2] names this at-most once processing. It is fast, it costs no round trip, and it is only defensible when the data "has low value or is only meaningful for a very short time".

PeekLock settles after your work. It "tells the broker that the receiving client wants to settle received messages explicitly. The message is available for the receiver to process, while the service holds an exclusive lock so that other competing receivers can't see it." A crash after processing but before settlement means "Service Bus redelivers the message when the application restarts", which the same article names at-least once processing. In the Python client, PeekLock is the default[5]: receive_mode defaults to ServiceBusReceiveMode.PEEK_LOCK, and you have to opt into RECEIVE_AND_DELETE deliberately.

At-least once is the ceiling PeekLock itself gives you, and nothing on the receive side configures it away. Because a redelivery is always possible, Microsoft's advice is blunt: "Designing for idempotent message handling becomes critical." Make your handler safe to run twice on the same message_id and the whole model becomes comfortable. Microsoft's pages do use the phrase exactly once about Service Bus, but they attach it to different features; the last section of this page sets out that disagreement rather than resolving it.

The word "peek" in PeekLock is not the peek operation

This catches people, so it is worth killing on first contact. PeekLock is a receive mode that takes a lock. peek_messages()[6] is a different, browsing call: "Peeked messages are not removed from queue, nor are they locked. They cannot be completed, deferred or dead-lettered." Peeking is for looking at a backlog in a support tool. It never delivers work.

The four settlement outcomes

Once a message is locked, exactly four explicit actions can end that delivery, and each is a real API call on the receiver. The figure below traces the two-stage receive through all four, plus the fifth path where you settle nothing at all.

  • complete_message(message) indicates "to the broker that the message is successfully processed and the message is removed from the queue or subscription". This is the only outcome that consumes the message.
  • abandon_message(message) asks "for the message to be released and unlocked instantly". Microsoft's own summary of the effect: "This message will be returned to the queue and made available to be received again." Use it for a failure you expect to succeed on retry, such as a downstream timeout.
  • dead_letter_message(message, reason=None, error_description=None) is for the case where "redelivering the message and retrying the operation won't help". It moves the message to the entity's dead-letter queue (DLQ), a holding area for messages that cannot be delivered or processed, which The dead-letter queue section below covers in full. The two optional arguments are recorded on the message and are what an operator reads later.
  • defer_message(message) sets the message aside without consuming it. The Python reference is precise about the cost: "This message will remain in the queue but must be requested specifically by its sequence number in order to be received." A sequence number is the gap-free identifier the broker stamps on every message as it arrives; the Sessions and ordered processing section below explains what it does and does not guarantee. Deferral is the right answer when a message arrived out of order relative to some state you do not have yet, but you must persist that sequence number somewhere durable or the message is effectively stranded.

The fifth path is the one you get by accident. "When the lock on a message is explicitly released or when the lock expires, the message goes back to the front of the retrieval order for redelivery." Doing nothing is therefore not neutral. It is a slow abandon.

Settle before you close anything

One concrete failure mode is worth memorising because its symptom points at the wrong cause. "In peek-lock mode, the lock on a received message is tied to the receiver and its connection. If you close the receiver or its connection before you settle a message (complete, abandon, defer, or dead-letter), the settlement doesn't reach the service, so the message stays locked until the lock expires." The message is then redelivered, its delivery count climbs (that is the broker's per-message counter of delivery attempts, which the next section covers in full), and it eventually lands in the DLQ[7] with the reason MaxDeliveryCountExceeded, which reads like a poison-message problem when it is really a lifetime bug. Microsoft's rules are to settle each message before you close the receiver or the ServiceBusClient, never to hold a received message and settle it later, and, if settlement fails because the lock was lost, to "receive the message again and process it, rather than retrying the settlement on the original message". Note also that "the service closes an idle connection after 10 minutes, which also releases the lock".

The takeaway for a handler you are about to write: choose PeekLock, make the work idempotent, and make sure every branch, including the exception branch, reaches exactly one of the four settlement calls while the receiver is still open.

One delivery under PeekLock1. In the entityVisible to any receiver2. Locked to youOthers cannot see it3. Your handler runsThe lock is tickingcomplete_messageRemoved fromthe entityabandon_messageUnlocked now,delivery count + 1dead_letter_messageMoved to the DLQwith a reasondefer_messageStays put, only bysequence numberSettle nothing: the lock expires on its own.The message returns to the front of the retrieval order, delivery count + 1.
The two-stage PeekLock receive and the five ways one delivery can end.

The message lock and the delivery count

A lock is a deadline, and the delivery count is what happens when you keep missing it. These two together are the entire retry mechanism, and tuning them badly is the most common reason a healthy queue fills its dead-letter queue.

Lock duration is a property of the entity, not of the receive call

"The queue or subscription initially defines the duration of the lock." Its default value is one minute, and the maximum is five minutes[4]; you set it on the queue or on the subscription, not per receive. Microsoft's sizing rule is to "set the lock duration to a value that's higher than your normal processing time, so you don't have to renew the lock", with an explicit cost on the other side: "when your client stops working, the message becomes available again only after the lock duration passes". A five-minute lock therefore buys you headroom and costs you five minutes of stalled work every time a worker dies.

For work that genuinely runs longer than the maximum, you renew instead. The client owning the lock can call renew_message_lock(message)[6], which returns the new expiry instant, but note two constraints from the same reference: "an expired lock cannot be renewed", and "messages received via RECEIVE_AND_DELETE mode are not locked, and therefore cannot be renewed". The hands-off alternative is the automatic renewal feature, which in Python is an AutoLockRenewer passed as the auto_lock_renewer keyword[5] when you create the receiver, so "messages are automatically registered on receipt".

The delivery count counts attempts, and only some of them

"Whenever a message is delivered under a peek-lock, but is either explicitly abandoned or the lock has expired, the delivery count on the message is incremented. When the delivery count exceeds the limit, the message is moved to the DLQ" with the reason MaxDeliveryCountExceeded. The default limit is 10[7], and the escape hatch is narrow: "This behavior can't be disabled, but you can set the max delivery count to a large number." There is no way to run a queue with unlimited retries; you can only push the ceiling up.

One carve-out looks like a contradiction of that rule, so read the two together. A PeekLock or session lock "is volatile and can be lost" during a service update, an OS update, a change to the entity's properties while you hold the lock, a dropped connection, or a session whose SessionIdleTimeout is shorter than the message lock duration. In those cases the client sees a MessageLockLostException or SessionLockLostException, and, crucially, "the delivery count of the message isn't incremented". So the rule is not "any lost lock counts against you". It is that an abandon or an ordinary lock expiry counts, while a broker-side lock loss that raises one of those exceptions does not. Your retry budget is spent by your handler's failures, not by Azure's maintenance.

What the numbers should be

Pick the lock duration from your processing time and the max delivery count from how many attempts a genuinely transient failure deserves. A handler that normally finishes in two seconds and occasionally waits on a flaky dependency wants a short lock so a dead worker's message comes back fast, and the default 10 attempts is generous. A handler that does a two-minute model inference wants a long lock or an AutoLockRenewer, because with a one-minute lock the message is redelivered while the first attempt is still running, which produces duplicate work and burns the delivery budget until the message dead-letters. That combination, slow handler plus default lock, is the single most common way a working queue starts dead-lettering everything.

Message expiry and time to live

Time to live is the other clock, and it is unrelated to the one in the previous section. The message lock bounds a single attempt; time to live (TTL) bounds the message's whole existence, however many attempts it has had.

You set TTL as a relative duration on the message, and it becomes absolute at enqueue[8]: "the expires-at-utc property takes on the value enqueued-time-utc + time-to-live". Past that instant "messages become ineligible for retrieval".

The entity's default is a ceiling, and it is enforced silently

Every queue and topic carries a default expiration that "applies to all messages sent to the entity where time-to-live isn't explicitly set". The part worth memorising is the second job that setting does: "The default expiration also functions as a ceiling for the time-to-live value. If a message has a longer time-to-live expiration than the default value, the system silently adjusts it to the default message time-to-live value before enqueuing the message." No error, no warning. A sender asking for a seven-day TTL on an entity whose default is one hour gets one hour and is never told.

The defaults themselves rarely bite, but the basic tier does: the default TTL for a brokered message and for standard and premium entities is "the largest possible value for a signed 64-bit integer", whereas "for the basic tier, the default (also maximum) expiration time is 14 days". And when a topic and a subscription disagree, "if the topic specifies a smaller TTL than the subscription, the topic TTL is applied".

Expiring is not the same as disappearing

Four behaviours around expiry are all documented and all catch people:

  • Nothing expires if nobody is listening. "The time-to-live (TTL) setting on a brokered message isn't enforced when no clients are actively listening." A quiet queue does not self-clean.
  • Removal is lazy. "The broker might choose to lazily expire these messages", so "you might observe an incorrect message count when using message expiration, and you might even see these messages during a peek operation. However, when receiving messages, the expired message isn't included." A message count that disagrees with what you can receive is expected, not a bug.
  • A locked message is protected. "The expiration doesn't affect messages that are currently locked for delivery." If the lock expires or the message is abandoned, the expiration takes immediate effect, and if the message is successfully settled the system does not move it, because "settlement assumes that the application successfully handled the message, in spite of the nominal expiration".
  • Expiry destroys by default. "You can optionally move expired messages to a dead-letter queue... If you leave the option disabled, expired messages are dropped." This is a per-entity setting and it is the difference between a diagnosable outage and silent data loss.

Two interactions round it out. For a scheduled message the clock starts at the scheduled instant, not at send: Microsoft's example is a ScheduledEnqueueTimeUtc five minutes out with a TimeToLive of 10 minutes, giving an expiry "after 5 + 10 = 15 minutes from now". And on a session-enabled entity[9], meaning one where related messages are grouped under a shared session id and locked to a single receiver (the next section but one), expiry is collective: "if there's a single message in the session that passes the TTL, all the messages in the session expire".

The practical reading is that TTL is a business deadline, not a cleanup mechanism. Set it to the point past which processing the message would be wrong rather than merely late, and turn on dead-lettering on expiration so you find out that it happened.

The dead-letter queue

When a message cannot be delivered or cannot be processed, Service Bus does not discard it. It moves it to a dead-letter queue (DLQ), which is a secondary sub-queue belonging to the queue or subscription itself rather than a separate entity you provision. From an API perspective the DLQ "is mostly similar to any other queue"[7], with three differences that matter: "messages can only be submitted via the dead-letter operation of the parent entity", "time-to-live isn't observed", and "you can't dead-letter a message from a DLQ". It also "can't be deleted or managed independently of the main entity", and there is "no automatic cleanup of the DLQ": messages sit there until you retrieve and complete them.

Two Microsoft pages describe the sub-queue's existence differently, and neither supersedes the other. The dead-letter-queues article says the DLQ "doesn't need to be explicitly created", while the message transfers, locks, and settlement article[4] states that "a dead-letter subqueue exists for a queue or a topic subscription only when you enable the dead-letter feature for the queue or subscription". Both were revised within days of each other, so there is no newer-wins tiebreak, and nothing on this page depends on which reading is right. What you actually configure and what you can actually be asked about are the settings, which are undisputed: the max delivery count, dead-lettering on message expiration, and dead-lettering on filter-evaluation exceptions. Treat the sub-queue as something you turn on and read, never as something you create.

The five system reasons, and yours

When the broker dead-letters a message it records two properties, and "applications can define their own codes for the dead-letter reason property, but the system sets the following values". This is the complete published set:

Dead-letter reason Dead-letter error description
HeaderSizeExceeded The size quota for this stream exceeded the limit.
TTLExpiredException The message expired and was dead-lettered.
Session ID is null Session enabled entity doesn't allow a message whose session identifier is null.
MaxTransferHopCountExceeded The maximum number of allowed hops when forwarding between queues exceeded the limit. This value is set to 4.
MaxDeliveryCountExceeded Message couldn't be consumed after maximum delivery attempts.

On top of those, your own code can reject a message explicitly, which Microsoft calls application-level dead-lettering and recommends for "messages that hold malformed payloads" or that "fail authentication when some message-level security scheme is used". A sixth trigger sits behind a setting rather than a message property: "if you enable dead-lettering on filter evaluation exceptions, any errors that occur while a subscription's SQL filter rule executes are captured in the DLQ along with the offending message". The figure below collects all six paths in and shows how you read the result.

When you dead-letter deliberately, put diagnosis in the payload. Microsoft's guidance is to "include the type of the exception in the DeadLetterReason and the stack trace of the exception in the DeadLetterDescription", with the caveat that doing so "might result in some messages exceeding the 256 KB quota limit for the Standard tier".

Reading the dead-letter queue

The DLQ is addressable by path, which is what the portal, CLI, and tools such as Service Bus Explorer use:

<queue path>/$deadletterqueue
<topic path>/Subscriptions/<subscription path>/$deadletterqueue

From the SDK you do not build that string yourself. In Python you pass the sub_queue keyword when you create the receiver, using the ServiceBusSubQueue[10] enum, whose two members are DEAD_LETTER and TRANSFER_DEAD_LETTER. The same keyword is documented on get_queue_receiver and get_subscription_receiver and accepts "equivalent string values "deadletter" and "transferdeadletter"".

Reading a dead-letter queue from Python

from azure.identity import DefaultAzureCredential
from azure.servicebus import ServiceBusClient, ServiceBusSubQueue

client = ServiceBusClient(
    fully_qualified_namespace="<namespace>.servicebus.windows.net",
    credential=DefaultAzureCredential(),
)
with client:
    # sub_queue is what targets the DLQ; without it you would read the main entity.
    with client.get_queue_receiver(
        queue_name="orders", sub_queue=ServiceBusSubQueue.DEAD_LETTER
    ) as dlq:
        for message in dlq.receive_messages(max_wait_time=5):
            # These two properties are why the message is here.
            print(message.dead_letter_reason, message.dead_letter_error_description)
            # ...triage, then settle so it leaves the DLQ.
            dlq.complete_message(message)

The sub_queue keyword in that listing is the whole trick, and dead_letter_reason maps to the DeadLetterReason column in the table above. Completing the message is what removes it, since the DLQ never cleans itself.

The transfer dead-letter queue is somewhere else

One distinction is easy to get backwards. When auto-forwarding or a send-via transaction cannot deliver to its destination, "the message is placed in the transfer dead-letter queue (TDLQ) of the source entity that did the forwarding, not on the destination entity". If you are hunting for messages that vanished on their way into a queue, look at the entity they came from. Its path is <queue path>/$Transfer/$DeadLetterQueue, and in Python it is ServiceBusSubQueue.TRANSFER_DEAD_LETTER.

Finally, do not go looking for a topic-level DLQ count. "Obtaining the count of messages in the dead-letter queue at the topic level isn't applicable because messages don't sit at the topic level", so the counts you want live on each subscription.

Treat the DLQ as instrumentation rather than as a failure. A queue whose dead-letter queue is empty is not necessarily healthy, because dead-lettering on expiration and on filter-evaluation exceptions are both settings you have to turn on, and with them off those messages are simply dropped. Enable them, alert on the dead-letter count, and read DeadLetterReason before you change any code: it distinguishes a handler that keeps failing, a message that ran out of time, a sender that forgot a session id, and an oversized payload, and those four have nothing in common except where they landed.

How a message reaches the dead-letter queueDelivery count exceeded (default 10)TTL expired, dead-lettering enabledHeader size exceededSession id null on a session entityMore than four auto-forward hopsYour code: dead_letter_message()Dead-letter sub-queueof that same entityReceiver opened withsub_queue=DEAD_LETTEREvery dead-lettered message carriesDeadLetterReason and DeadLetterErrorDescription
Six paths into the dead-letter sub-queue, and the two properties that tell you which one a message took.

Sessions and ordered processing

Ordering is where Service Bus surprises careful engineers, because the service really does hand messages out in order and that still is not enough.

Every entity stamps each message with a sequence number: "the broker stamps messages with a gap-free, increasing sequence number relative to the queue or topic", and it "represents the true order of arrival"[11]. But the same article draws the line immediately: "Sequence number on its own guarantees the queuing order and the extractor order of messages, but not the processing order, which requires sessions." Microsoft's own walkthrough is the clearest statement of the problem. Two consumers, three messages: consumer 1 takes message 1, consumer 2 takes message 2, finishes, takes message 3, finishes, and only then does consumer 1 finish message 1. Processing order: 2, 3, 1. Nothing malfunctioned. "If messages just need to be retrieved in order, you don't need to use sessions. If messages need to be processed in order, use sessions."

Sessions: a lock over a group instead of over a message

A sender opens a session "by setting the session ID property to unique identifier defined by the application"; at the wire level that maps to the group-id property of AMQP 1.0[9], the Advanced Message Queuing Protocol that every Service Bus client speaks. A receiver then takes the whole group: "When the client accepts and holds a session, it holds an exclusive lock on all messages with that session's session ID in the queue or subscription. It holds exclusive locks on all messages with the session ID that arrive later."

That last sentence is the mechanism. The session lock is not a bigger message lock, it is a lock over a stream that has not fully arrived yet, which is why later messages in the group cannot overtake earlier ones. Microsoft frames the relationship exactly: "The session lock held by the session receiver is an umbrella for the message locks used by the peek-lock settlement mode. Only one receiver can have a lock on a session." Different sessions are still fully parallel, so throughput comes from having many session ids rather than many receivers per session, and "an interleaved message stream in one queue or subscription is cleanly demultiplexed to different receivers". The figure below shows that demultiplexing, including a session that nobody currently holds and from which "no messages are delivered".

Sessions are a creation-time decision, and they change the contract

"You can't enable or disable message sessions after the queue or subscription is created. You can only do so at the time of creating the queue or subscription", which the enable-message-sessions article[12] states as an explicit Important block. The property is requiresSession in an ARM or Bicep template and --enable-session true on az servicebus queue create or az servicebus topic subscription create. Sessions are also unavailable on the basic tier; standard and premium support them.

Turning them on is not additive. "When you enable sessions on a queue or a subscription, client applications can no longer send or receive regular messages. Clients must send messages as part of a session by setting the session ID and receive messages by accepting the session." A sender that forgets gets its message dead-lettered with the reason Session ID is null. The one thing that still works normally is peeking, since "clients can still peek a queue or subscription that has sessions enabled".

Taking a session from Python

You do not call a separate accept method in Python; the session is chosen through the session_id keyword on the ordinary receiver factory. Pass a specific id to take that session, or pass the NEXT_AVAILABLE_SESSION sentinel to take whichever session is free: the reference[5] says "a specific session from which to receive. This must be specified for a sessionful queue, otherwise it must be None. In order to receive messages from the next available session, set this to ~azure.servicebus.NEXT_AVAILABLE_SESSION."

Receiving from the next free session

from azure.servicebus import ServiceBusClient, NEXT_AVAILABLE_SESSION

# ...client constructed as in the previous section...
with client.get_queue_receiver(
    queue_name="order-steps", session_id=NEXT_AVAILABLE_SESSION, max_wait_time=30
) as receiver:
    # receiver.session is None on a non-sessionful receiver.
    checkpoint = receiver.session.get_state()
    for message in receiver:
        # ...do the work...
        receiver.complete_message(message)
        receiver.session.set_state(b"last-step-done")

Two details in that listing are load-bearing. max_wait_time on the constructor is the timeout for connecting to a session, which is different from the per-receive timeout: setting it on receive_messages "will not impact the timeout for connecting to a session". And receiver.session is the session object; it "is only available to session-enabled entities, it would return None if called on a non-sessionful receiver".

The get_state and set_state calls are the session state facility, "an application-defined annotation of a message session inside the broker, so that the recorded processing state relative to that session becomes instantly available when the session is acquired by a new processor". It is an opaque binary object the size of one message, 256 KB on standard and 100 MB on premium, it survives after every message in the session is consumed, and it counts toward the entity's storage quota, so clear it when the workflow ends.

One Python-specific trap follows from the umbrella model: renew_message_lock raises TypeError "if the message is sessionful" and "is only available for non-sessionful messages". On a session you renew the session lock, not the individual message lock.

Delivery count and dead-lettering behave slightly differently in a session

The rules shift enough to be worth stating. Accepting a session and then letting the session lock expire on timeout does increment the delivery count of its messages; accepting a session, leaving its messages uncompleted, and closing the session does not. Abandoned session messages still count against MaxDeliveryCount, still default to 10, and still dead-letter once exceeded, after which "the receiver continues receiving subsequent messages from the session". And recovery has a sting: "if a dead-lettered message is later moved back to the original queue for reprocessing, the original ordering relative to other session messages is lost because the resubmitted message receives a new enqueue time and sequence number".

Ordering, partition keys, and a documented disagreement

One more claim circulates and you should know its status. The partitioning article[13] explains that a partition key pins related messages to one broker, and, describing what you lose without one, says you "don't achieve the guaranteed ordering that a partition key provides". The sequencing and sessions articles say plainly that processing order "requires sessions". Microsoft has not reconciled these two statements, and this guide does not pick a winner. They were revised in the same period, so neither is the later word.

What is not in dispute is enough to design with. A partition key controls placement: "Service Bus assigns all messages that use the same partition key to the same partition", and where a session id is set, "Service Bus uses it as the partition key", so that "the same message broker handles all messages that belong to the same session". If you set both, "both properties must be identical" or the send fails with an invalid-operation exception. And the mechanism Microsoft's FIFO article prescribes for ordered processing, the one that holds a single receiver over a group of related messages, is sessions. Design for sessions when processing order matters, and treat a partition key as an affinity and availability decision rather than a substitute.

One entity, sessions locked to separate receiversSession-enabled queueA1B1A2C1B2A3C2B3Receiver 1Holds session AA1, A2, A3 in orderReceiver 2Holds session BB1, B2, B3 in orderReceiver 3Holds session CC1, C2 in orderSession DNo receiverNothing deliveredThe session lock is an umbrella over the message locks.Only one receiver can hold a session at a time.
Sessions demultiplex one interleaved stream into per-session receivers; an unheld session delivers nothing.

The Python client model

The Python surface is small and consistent, which makes it easy to reason about once you know which object owns what.

ServiceBusClient is the connection factory and nothing else. You build it from a fully qualified namespace and a credential[5], ServiceBusClient(fully_qualified_namespace, credential), where the namespace takes the form <yournamespace>.servicebus.windows.net and the credential is anything from azure-identity, typically DefaultAzureCredential. The connection-string route is the class method from_connection_string(conn_str). The client is genuinely shared machinery rather than a handle you make per operation: closing it "shuts down" all "spawned senders, receivers and underlying connection".

From that client you get exactly four things, and the naming tells you the entity type:

Call Returns Reads or writes
get_queue_sender(queue_name) ServiceBusSender Sends to a queue
get_topic_sender(topic_name) ServiceBusSender Sends to a topic
get_queue_receiver(queue_name, ...) ServiceBusReceiver Receives from a queue
get_subscription_receiver(topic_name, subscription_name, ...) ServiceBusReceiver Receives from a subscription

There is no topic receiver, which follows directly from the entity model in the first section: nothing reads a topic. The figure below lays the whole object graph out, from the one client down to the message types each branch deals in. What you send is a ServiceBusMessage, and the sender's send_messages[14] takes one message, a list, or a ServiceBusMessageBatch built with create_message_batch(), "throwing a ValueError" if a list "cannot fit in a single batch".

Two ways to receive, and one knob that can lose messages

The receiver reference names both channels: "receive() to make a single request for messages, and for message in receiver: to continuously receive incoming messages in an ongoing fashion"[6]. The batch call is receive_messages(max_message_count=1, max_wait_time=None), and it "will prioritize returning quickly over meeting a specified batch size", so asking for 50 and getting 3 is normal behaviour and not a bug.

prefetch_count is the performance knob and it carries a documented hazard. It defaults to 0, meaning messages are "received from the service and processed one at a time". Raise it and the client caches messages locally, which improves throughput but "increase[s] the chance that messages will expire while they are cached". The warning that matters: "If prefetch_count > 0 and RECEIVE_AND_DELETE mode is used, all prefetched messages will stay in the in-memory prefetch buffer until they're received into the application. If the application ends before the messages are received into the application, those messages will be lost." Prefetch plus ReceiveAndDelete is the one combination that loses messages without any failure occurring, which is why the reference recommends "that PEEK_LOCK mode be used with prefetch".

The processor model, and why Python does not have one

Other Azure SDKs offer an event-driven pump that owns the receive loop for you. In .NET this is ServiceBusProcessor[15], which "provides an abstraction around a set of ServiceBusReceiver that allows using an event based model": you attach a message handler to ProcessMessageAsync and an error handler to ProcessErrorAsync, both of which the reference marks "Implementation is mandatory", then call StartProcessingAsync. Its AutoCompleteMessages property decides settlement for you, and it is carefully scoped: it makes the processor "automatically complete messages after the message handler has completed processing", but "if the message handler triggers an exception, the message will not be automatically completed". A .NET processor also defaults to PeekLock and exposes MaxConcurrentCalls and MaxAutoLockRenewalDuration.

Python has no equivalent class. You own the loop, which means you also own every settlement: complete_message is what "removes the message from the queue", and there is no auto-complete switch to fall back on. Practically, for message in receiver: is the Python spelling of the continuous pump, and your try/except around the body is the error handler. The mental model to carry into the exam is that auto-completion is a processor feature and not a Service Bus feature, so the broker's rules from the settlement section apply unchanged either way.

If you would rather not own the loop at all, the Azure Functions Service Bus trigger wraps this machinery and is covered on that sibling page. The service-side behaviour it wraps, locks, delivery counts, dead-lettering, is exactly what this page describes.

One client, two object familiesServiceBusClientnamespace + credentialServiceBusSenderget_queue_sender(queue_name)get_topic_sender(topic_name)ServiceBusReceiverget_queue_receiver(queue_name)get_subscription_receiver(...)sends ServiceBusMessageyields ServiceBusReceivedMessageThere is no topic receiver.Nothing reads a topic, only its subscriptions.
The azure-servicebus object graph: one client, a sender branch and a receiver branch, and the message type each deals in.

What the sender controls before a message enters the queue

Most of this page has been about the receive side. Two send-side features change what a message is before any receiver ever sees it, and both come up constantly because they solve problems that look like application bugs. They are not an exhaustive list of sender options, but they are the two that alter enqueue behaviour itself.

Duplicate detection: making a retried send safe

The problem is stated precisely in the docs: an application can crash "immediately after sending a message", restart believing the send did not happen, and send again; or the acknowledgment can be lost in transit, "leaving the client in doubt about the outcome of the send operation". Duplicate detection[16] resolves both "by enabling the sender to resend the same message, and the queue or topic discards any duplicate copies".

The mechanism is a window over one field. Enabling it "helps keep track of the application-controlled MessageId of all messages sent into a queue or topic during a specified time window. If any new message is sent with MessageId that was logged during the time window, Service Bus reports the message as accepted (the send operation succeeds), but the newly sent message is instantly ignored and dropped." Two consequences follow, and both are exam-shaped. First, a suppressed duplicate looks like a success to the sender, so you cannot detect suppression from the send result. Second, "no other parts of the message other than the MessageId are considered", so two messages with different bodies and the same id collapse to one.

Is that "exactly once"? Microsoft's own pages give three different answers, and this guide keys none of them. The queues, topics, and subscriptions article[2] points the reader at duplicate detection and calls it "exactly once processing". The service comparison article[1] instead lists the Service Bus delivery guarantee as "At least once (optional ordered, exactly once with sessions)", attaching the phrase to sessions. The duplicate-detection article and the sessions article never use the term at all. What all four pages do agree on is the mechanism described just above: a bounded window, keyed on MessageId, that suppresses a repeat send. It says nothing about how many times a surviving message is delivered, which stays governed by the lock and delivery-count rules earlier on this page. Design against the mechanism, not the label.

That makes the MessageId your responsibility. "Application control of the identifier is essential because it allows the application to tie the MessageId to a business process context from which it can be predictably reconstructed when a failure occurs." Microsoft's example of a good id is a composite of a business key and the message subject, such as 12345.2017/payment. A random GUID generated fresh on each attempt defeats the feature entirely, and this is easy to do by accident: the partitioning article[13] notes that "the Microsoft client libraries automatically assign a message ID if the sending application doesn't", so leaving the field alone gives you a different id on every retry.

The numbers are small enough to remember. The detection history window "defaults to 10 minutes for queues and topics, with a minimum value of 20 seconds and a maximum value of 7 days", and the window is a throughput cost, because "all recorded message IDs must be matched against the newly submitted message identifier". Keep it just longer than your longest retry chain. The basic tier does not support duplicate detection; standard and premium do. And on a partitioned entity the uniqueness key widens: "when partitioning is enabled, MessageId+PartitionKey is used to determine uniqueness", while with partitioning disabled, which is the default, "only MessageId is used".

Scheduled messages: choosing when the message appears

A scheduled message is submitted now and enqueued later. "Scheduled messages don't materialize in the queue until the defined enqueue time. Before that time, scheduled messages can be canceled. Cancellation deletes the message." There are two documented ways to do it[11], and they differ in what you get back:

  • Set the ScheduledEnqueueTimeUtc property on the message and send it normally. Simple, and the only option for large messages, since "messages that are larger than 1 MB can only be scheduled using the regular API".
  • Call the schedule API, passing the message and the time. "The API returns the scheduled message's SequenceNumber, which you can later use to cancel the scheduled message if needed." In Python this is schedule_messages(messages, schedule_time_utc)[14], which "returns a list of the sequence numbers of the enqueued messages", paired with cancel_scheduled_messages(sequence_numbers).

Three limits keep this from being a general scheduler. The sequence number you were handed is temporary: it "is only valid while the message is in the scheduled state", because on activation "the message is appended to the queue as if it had been enqueued at the current instant, which includes assigning a new SequenceNumber". There are no repeats, since "Service Bus doesn't support recurring schedules for messages". And activation and cancellation "are independent operations without mutual locking", so cancelling at the moment of activation may not take effect and Microsoft recommends avoiding "scheduling activation and cancellation operations in close succession".

One interaction ties the two features together: "Scheduled messages are included in duplicate detection", so a scheduled message and a later non-scheduled message sharing a MessageId will suppress one another in whichever order they arrive. If you use both features, make the id encode the intent and not the timing.

Which delivery shape a back-end operation needs

RequirementQueueTopic and subscriptionsSession-enabled queue or subscription
Consumers that act on one messageExactly oneOne per subscription, each on its own copyExactly one, and always the receiver holding that session
Scale-out shapeCompeting consumers on one entityFan-out across subscriptions, competing consumers inside eachOne receiver per session, many sessions in parallel
Selective deliveryNone; every receiver draws from the same streamSubscription rules with SQL, correlation, or boolean filtersSame filtering as the underlying queue or subscription
Processing orderRetrieval order only, not processing orderRetrieval order only, per subscriptionFIFO within each session id
Can it be turned on after creationn/aSubscriptions and rules can be added at any timeNo; sessions are fixed at entity creation

Decision tree

Choosing the delivery shapeA back-end operationMust several independent consumerseach act on the same message?YesTopic, one subscription per consumerEach gets its own copy; narrow it with rulesNoMust related messages be processedin a strict order?YesSession-enabled queue or subscriptionSet at creation only; use a session receiverNoCan you afford to lose the messageif the worker crashes?YesQueue, ReceiveAndDeleteAt-most once; settled as it goes on the wireNoQueue, PeekLock and settleAt-least once; complete after the work is done

Sharp facts the exam loves — give these one last read before exam day.

Cheat sheet

Sharp facts the exam loves — scan these before test day.

A Service Bus queue delivers each message to exactly one competing consumer (point-to-point)

A Service Bus queue is a point-to-point channel: many consumers can compete for messages, but each message is delivered to and processed by only one receiver. Choose a queue (not a topic) when a back-end operation must be handled exactly once by a single worker.

Trap A topic with multiple subscriptions is fan-out — each subscription gets its own copy — so it violates single-consumer delivery.

5 questions test this
A topic with subscriptions is publish/subscribe fan-out — each subscription receives its own copy

A Service Bus topic delivers a published message to every matching subscription, and each subscription is itself an independent queue read by its own consumer(s). Use topics + subscriptions when several independent consumers must each process the same event.

13 questions test this
Subscription rules (SQL and correlation filters) select which topic messages a subscription receives

Each subscription can carry rules that filter the topic stream: a correlation filter matches system/application properties (fast, exact match), while a SQL filter evaluates a SQL-like expression over message properties. A subscription with no filter receives every message via the default TrueFilter.

Trap Assuming a subscription with no rule receives nothing until a filter is added.

7 questions test this
PeekLock is a two-stage receive: the message is locked, then explicitly Completed after processing succeeds

In PeekLock mode the broker hands the message to one receiver and locks it for the lock duration; the receiver must call complete after successful work to remove it. This makes processing at-least-once and safe: a crash before Complete releases the lock so the message is redelivered.

Trap Treating a PeekLock receive as having already removed the message from the queue.

14 questions test this
ReceiveAndDelete settles the message at delivery, so a mid-processing failure loses it

ReceiveAndDelete removes the message from the queue the instant it is delivered, before processing begins. It is faster and simpler but offers no retry: if the consumer crashes mid-work the message is gone. Use it only for high-throughput, loss-tolerant data.

Trap Reliable single-consumer processing needs PeekLock + Complete, never ReceiveAndDelete.

7 questions test this
A locked message can be Completed, Abandoned, Dead-lettered, or Deferred

Beyond complete, the SDK exposes abandon (release the lock for immediate redelivery, incrementing the delivery count), dead_letter (move to the DLQ with a reason), and defer (set aside for later retrieval by sequence number). Each is an explicit settlement action only valid on a PeekLock message.

Trap Calling abandon or dead_letter on a message received in ReceiveAndDelete mode.

12 questions test this
Locks expire after the lock duration unless renewed; the max delivery count governs redelivery

A PeekLock lock is held only for the entity's lock duration; long-running work must renew the lock (for example with an auto lock renewer) or the message unlocks and is redelivered. Repeated redeliveries eventually exceed the max delivery count.

The dead-letter queue is a secondary sub-queue of its parent entity, not an entity you provision

Dead-lettered messages land in a dead-letter queue (DLQ), a secondary sub-queue belonging to the queue or topic subscription itself rather than a separate entity. It can't be deleted or managed independently of the main entity, messages can only be submitted to it via the dead-letter operation of the parent, time-to-live isn't observed there, and there's no automatic cleanup: messages stay until you receive and complete them. What you configure is the entity's dead-lettering settings, never the sub-queue itself.

Trap Expecting dead-lettered messages to age out of the DLQ on their own.

12 questions test this
Messages dead-letter when the max delivery count is exceeded, TTL expires, or the app calls dead_letter

Service Bus moves a message to the DLQ automatically when it exceeds the max delivery count (repeated abandon/lock loss) or when its time-to-live expires with dead-lettering on expiration enabled. The application can also dead-letter a message explicitly (for example, a poison/unparseable payload).

Trap Expecting an expired message to reach the DLQ with dead-lettering on expiration switched off.

10 questions test this
Read the DLQ by opening a receiver on the entity's /$deadletterqueue sub-path

To inspect or reprocess dead-lettered messages you open a receiver against the sub-queue formatted as /$DeadLetterQueue (or /subscriptions//$DeadLetterQueue). In the Python SDK this is done via the sub_queue=ServiceBusSubQueue.DEAD_LETTER option when creating the receiver.

8 questions test this
Dead-lettered messages carry DeadLetterReason and DeadLetterErrorDescription properties

When a message is dead-lettered the broker (or app) records DeadLetterReason and DeadLetterErrorDescription in the message's application properties, letting an operator triage why delivery failed before reprocessing.

Sessions provide guaranteed FIFO ordering for all messages sharing a session id

Enabling sessions on a queue/subscription groups messages by SessionId and locks an entire session to one receiver, guaranteeing first-in-first-out processing within that session. This is how you achieve ordered, related-message processing that a plain competing-consumer queue cannot.

Trap A session must be enabled at entity creation; you cannot get per-key ordering from a non-session queue just by setting SessionId.

10 questions test this
A session receiver accepts a specific or the next available session and holds a session lock

To read a session-enabled entity you create a session receiver, which locks one session and delivers its messages in order. In Python there is no separate accept call: the session is chosen through the same receiver factory, either get_queue_receiver(queue_name=..., session_id="") for a named session or session_id=NEXT_AVAILABLE_SESSION to take whichever session is free. Session state can be persisted on the broker through the receiver's session object to checkpoint per-session progress.

Trap Hunting for a separate accept-session call in the Python SDK.

10 questions test this
Message time-to-live expires undelivered messages, optionally routing them to the DLQ

Each message has a time-to-live (defaulting to the entity's default TTL, capped by it); once it expires the message is removed, and if dead-lettering on message expiration is enabled it is moved to the DLQ instead of being silently dropped.

Trap Setting a per-message TTL longer than the entity's default and expecting it to hold.

6 questions test this
ServiceBusClient is the connection factory that creates senders and receivers

A single ServiceBusClient (built from a namespace + DefaultAzureCredential or a connection string) is the entry point; you call get_queue_sender / get_queue_receiver (or the topic/subscription variants) to obtain a ServiceBusSender for publishing and a ServiceBusReceiver for consuming. Messages are ServiceBusMessage objects.

8 questions test this
The processor model registers message/error handler callbacks and can auto-complete on success

The event-driven ServiceBusProcessor (.NET) registers a message handler and an error handler and continuously pumps messages, auto-completing them on success unless auto-complete is disabled; the Python SDK achieves the same by iterating a ServiceBusReceiver and settling each message explicitly.

Trap Expecting the Python receiver to auto-complete messages the way the .NET processor does.

6 questions test this
Duplicate detection discards messages with a repeated MessageId within a configured time window

When duplicate detection is enabled on a queue/topic, the broker ignores any incoming message whose MessageId matches one seen within the configured detection history window (default 10 minutes, minimum 20 seconds, maximum 7 days). A suppressed send still reports success to the sender, and no part of the message other than the MessageId is considered, so the sender must set a stable, reconstructible MessageId for this to work.

Trap Expecting a send suppressed by duplicate detection to surface as an error.

6 questions test this
Messages can be scheduled for future enqueue via a scheduled enqueue time

A sender can schedule a message to become available at a future instant by setting its scheduled enqueue time (or calling schedule_messages), which returns a sequence number that can be used to cancel the scheduled delivery before it fires.

References

  1. Compare Azure Event Grid, Event Hubs, and Service Bus
  2. Azure Service Bus queues, topics, and subscriptions
  3. Azure Service Bus topic filters and actions
  4. Message transfers, locks, and settlement in Azure Service Bus
  5. azure.servicebus.ServiceBusClient class (Python SDK reference)
  6. azure.servicebus.ServiceBusReceiver class (Python SDK reference)
  7. Azure Service Bus dead-letter queues
  8. Azure Service Bus message expiration and time to live
  9. Enable FIFO with Azure Service Bus message sessions
  10. azure.servicebus.ServiceBusSubQueue enum (Python SDK reference)
  11. Azure Service Bus message sequencing and timestamps
  12. Enable Azure Service Bus message sessions
  13. Create partitioned Azure Service Bus topics and queues
  14. azure.servicebus.ServiceBusSender class (Python SDK reference)
  15. ServiceBusProcessor class (Azure.Messaging.ServiceBus .NET reference)
  16. Azure Service Bus duplicate message detection