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

Azure Service Bus: Topics and Filters

Publish one message to an Azure Service Bus topic and see it fan out to two subscriptions with the Python SDK against the official emulator — then find out a SQL filter matches application properties, not whatever's inside the message body.

By SkillByExample · Published

azure service-bus messaging pub-sub

A Service Bus topic publishes once; every subscription on it gets its own full copy of each message that matches its filter, independent of what any other subscription does with its copy. This lesson continues against the same Service Bus Emulator image, with a different Config.json declaring a topic instead of a queue.

One Publish, Filtered to Two Different Subscriptions

import json
from azure.servicebus import ServiceBusClient, ServiceBusMessage

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_topic_sender("events") as sender:
        sender.send_messages(ServiceBusMessage(
            json.dumps({"id": "t50", "total": 50}),
            application_properties={"total": 50},
        ))
        sender.send_messages(ServiceBusMessage(
            json.dumps({"id": "t150", "total": 150}),
            application_properties={"total": 150},
        ))

    with client.get_subscription_receiver("events", "all", max_wait_time=5) as receiver:
        ids = []
        for msg in receiver.receive_messages(max_message_count=5, max_wait_time=5):
            ids.append(json.loads(str(msg))["id"])
            receiver.complete_message(msg)
        print("'all' subscription:", sorted(ids))

    with client.get_subscription_receiver("events", "big", max_wait_time=5) as receiver:
        ids = []
        for msg in receiver.receive_messages(max_message_count=5, max_wait_time=5):
            ids.append(json.loads(str(msg))["id"])
            receiver.complete_message(msg)
        print("'big' subscription:", sorted(ids))
{
  "UserConfig": {
    "Namespaces": [
      {
        "Name": "sbemulatorns",
        "Queues": [],
        "Topics": [
          {
            "Name": "events",
            "Properties": {
              "DefaultMessageTimeToLive": "PT1H",
              "DuplicateDetectionHistoryTimeWindow": "PT20S",
              "RequiresDuplicateDetection": false
            },
            "Subscriptions": [
              {
                "Name": "all",
                "Properties": {
                  "DeadLetteringOnMessageExpiration": false,
                  "DefaultMessageTimeToLive": "PT1H",
                  "LockDuration": "PT30S",
                  "MaxDeliveryCount": 3,
                  "ForwardDeadLetteredMessagesTo": "",
                  "ForwardTo": "",
                  "RequiresSession": false
                },
                "Rules": [
                  {
                    "Name": "all-rule",
                    "Properties": {
                      "FilterType": "Sql",
                      "SqlFilter": { "SqlExpression": "1=1" }
                    }
                  }
                ]
              },
              {
                "Name": "big",
                "Properties": {
                  "DeadLetteringOnMessageExpiration": false,
                  "DefaultMessageTimeToLive": "PT1H",
                  "LockDuration": "PT30S",
                  "MaxDeliveryCount": 3,
                  "ForwardDeadLetteredMessagesTo": "",
                  "ForwardTo": "",
                  "RequiresSession": false
                },
                "Rules": [
                  {
                    "Name": "big-rule",
                    "Properties": {
                      "FilterType": "Sql",
                      "SqlFilter": { "SqlExpression": "total > 100" }
                    }
                  }
                ]
              }
            ]
          }
        ]
      }
    ],
    "Logging": { "Type": "File" }
  }
}
$ docker network create az-sb-net-16
d1baa99865df81567e27160654296b4cea949bddfa5b1dc4e33c818be18e6bd4
$ docker run -d --name az-sb-sql-16 --network az-sb-net-16 -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/mssql/server:2025-CU10-ubuntu-24.04
feedda8080ff21f788afa97cf35e7d2371de07c5ff09a266015fad34187fec82
$ until docker logs az-sb-sql-16 2>&1 | grep -q 'SQL Server is now ready for client connections'; do sleep 1; done
$ docker run -d --name az-sb-emu-16 --network az-sb-net-16 -v "$(pwd)"/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json -e ACCEPT_EULA=Y -e SQL_SERVER=az-sb-sql-16 -e MSSQL_SA_PASSWORD='yourStrong(!)Password' mcr.microsoft.com/azure-messaging/servicebus-emulator:2.0.1
abbedaa910a044f4c383a9fb016eb179985d008d9230ae60a2744e32f8d92ce9
$ until docker logs az-sb-emu-16 2>&1 | grep -q 'Emulator Service is Successfully Up'; do sleep 1; done
$ docker run --rm --network container:az-sb-emu-16 -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_topic.py"
[notice] To update, run: pip install --upgrade pip
'all' subscription: ['t150', 't50']
'big' subscription: ['t150']

Both messages carry their total twice: once inside the JSON body, once as the application_properties value the SQL filter actually reads. The 'all' subscription’s 1=1 filter matches everything, so both land there. The 'big' subscription’s total > 100 filter only matches t150 — if total had been left out of application_properties and only embedded in the body, 'big' would have matched nothing at all, silently, with no error on either the publish or the filter evaluation side.

Practice

1. Send a message whose total lives only in the JSON body, with no application_properties, and confirm the big subscription never sees it.
import json
from azure.servicebus import ServiceBusClient, ServiceBusMessage

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_topic_sender("events") as sender:
        sender.send_messages(ServiceBusMessage(json.dumps({"id": "t999-body-only", "total": 999})))

    with client.get_subscription_receiver("events", "big", max_wait_time=5) as receiver:
        ids = [json.loads(str(m))["id"] for m in receiver.receive_messages(max_message_count=5, max_wait_time=5)]
        print("'big' subscription saw:", ids)

    with client.get_subscription_receiver("events", "all", max_wait_time=5) as receiver:
        ids = []
        for m in receiver.receive_messages(max_message_count=5, max_wait_time=5):
            ids.append(json.loads(str(m))["id"])
            receiver.complete_message(m)
        print("'all' subscription saw:", ids)
$ docker run --rm --network container:az-sb-emu-16 -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_topic_body_only.py"
[notice] To update, run: pip install --upgrade pip
'big' subscription saw: []
'all' subscription saw: ['t999-body-only']

999 > 100 is true, but the filter never sees 999 — it’s only in the JSON text of the body, not in an application property named total — so 'big' gets nothing. 'all'’s 1=1 filter doesn’t inspect any property at all, so it still receives this message regardless.

2. Send a message with application_properties that has no matching value at all for the big subscription's filter, and predict whether that's an error or a non-match.
import json
from azure.servicebus import ServiceBusClient, ServiceBusMessage

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_topic_sender("events") as sender:
        sender.send_messages(ServiceBusMessage(
            json.dumps({"id": "t-no-total-property"}),
            application_properties={"category": "misc"},
        ))

    with client.get_subscription_receiver("events", "big", max_wait_time=5) as receiver:
        ids = [json.loads(str(m))["id"] for m in receiver.receive_messages(max_message_count=5, max_wait_time=5)]
        print("'big' subscription saw:", ids)

    with client.get_subscription_receiver("events", "all", max_wait_time=5) as receiver:
        ids = []
        for m in receiver.receive_messages(max_message_count=5, max_wait_time=5):
            ids.append(json.loads(str(m))["id"])
            receiver.complete_message(m)
        print("'all' subscription saw:", ids)
$ docker run --rm --network container:az-sb-emu-16 -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_topic_missing_property.py"
[notice] To update, run: pip install --upgrade pip
'big' subscription saw: []
'all' subscription saw: ['t-no-total-property']

A total > 100 comparison against a message that has no total property at all evaluates to unmatched, the same as any other non-match — not an error, and not treated as true or false by default. The message is simply never delivered to 'big'.

3. Send a message with total exactly 100 and predict whether the strict > 100 filter matches it.
import json
from azure.servicebus import ServiceBusClient, ServiceBusMessage

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_topic_sender("events") as sender:
        sender.send_messages(ServiceBusMessage(
            json.dumps({"id": "t100-exact"}),
            application_properties={"total": 100},
        ))

    with client.get_subscription_receiver("events", "big", max_wait_time=5) as receiver:
        ids = [json.loads(str(m))["id"] for m in receiver.receive_messages(max_message_count=5, max_wait_time=5)]
        print("'big' subscription (total exactly 100):", ids)

    with client.get_subscription_receiver("events", "all", max_wait_time=5) as receiver:
        for m in receiver.receive_messages(max_message_count=5, max_wait_time=5):
            receiver.complete_message(m)
$ docker run --rm --network container:az-sb-emu-16 -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_topic_boundary.py"
[notice] To update, run: pip install --upgrade pip
'big' subscription (total exactly 100): []

total > 100 is a strict inequality — 100 itself doesn’t satisfy it, so 'big' gets nothing. The filter means exactly what its SQL text says; a value sitting right on the boundary is a non-match unless the filter was written with >=.

That’s the data-plane core of Service Bus this track covers on a local emulator. Sessions, duplicate detection, and scheduled messages all run on this same emulator too, but aren’t covered here — each is its own topic with its own gotchas, not a small addition to queues, dead-lettering, or filters.

Cleanup

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

Next: Azure Key Vault — storing secrets, their automatic versioning, and what reading a version that doesn’t exist actually raises.

Frequently Asked Questions

How is a Service Bus topic different from a queue?
A queue has one logical set of consumers competing for each message — once one receiver completes a message, it's gone for everyone else. A topic fans each published message out to every subscription that exists on it, and each subscription gets its own independent copy with its own settlement state, the same message body delivered once per matching subscription rather than once total.
What does a subscription's SQL filter actually filter on?
A message's broker and application properties (the metadata passed via application_properties on ServiceBusMessage, plus system properties like label or correlation_id) — never the message body. A filter like total > 100 only works if total was sent as an application property; the same value sitting inside a JSON-encoded body is invisible to the filter.
What happens to a message that doesn't match any subscription's filter?
It's simply not delivered to that subscription — no error, no dead-letter, nothing visible at all from the publisher's side. A topic with subscriptions whose filters don't cover every case a publisher sends can silently drop messages for anyone relying on a subscription seeing everything; a catch-all subscription with a 1=1 filter (or no filter at all, which defaults to matching everything) is the usual way to avoid that.
Can a Service Bus subscription have more than one filter rule?
Yes — a subscription can have multiple named rules, each with its own filter, and a message matches the subscription if it matches any one of them (an implicit OR across the subscription's rules). This lesson's two subscriptions each have exactly one rule, but Config.json's Rules array for a subscription accepts more.