MQTT Topic Design: Best Practices for Naming and Wildcards
Every MQTT message is published to a topic, and every subscription matches one or more topics. The topic string is the routing key — the only thing the broker uses to decide where a message goes. There are no schemas, no registries, and no central type definitions. Your topic structure is the contract between publishers and subscribers. Getting it right early is far cheaper than migrating a deployed fleet. Whether you are running a local broker to test an ESP32 from your Mac or wiring a multi-site plant, the same rules apply.
Good topic design makes wildcards useful, ACLs enforceable, and retained messages meaningful. Bad design creates unbounded cardinality, defeats subscriptions, and forces you to re-flash every device when you want to change a single path. This guide covers the topic rules that matter, how the + and # wildcards really work, shared subscriptions, ACL security, and the anti-patterns that bite teams in production.
What an MQTT Topic Actually Is
A topic is a UTF-8 string, between 1 and 65535 bytes, organized as a hierarchy of levels separated by the forward slash (/). The slash is the only level separator — MQTT does not care about dots, colons, or underscores. Those are just characters inside a level. The hierarchy is conceptual: the broker treats topics as opaque strings for matching, but humans and ACL engines rely on the slash-delimited tree to make sense of the namespace.
The hard rules are short:
- Case-sensitive.
Plant/Line3andplant/line3are two different topics. Pick a convention (lowercase is common) and enforce it. - No leading or trailing slash.
/plant/line3creates an empty leading level;plant/line3/creates an empty trailing level. Both match unpredictably with wildcards. - No spaces. Technically legal UTF-8, but they break copy-paste, URLs, and shell scripts. Use
_or-instead. - 1–65535 bytes. The MQTT spec caps a topic at 65535 bytes. In practice, keep topics well under a few hundred bytes for readability and to stay friendly to constrained brokers.
- Wildcards only in SUBSCRIBE.
+and#are forbidden in PUBLISH. A broker must reject a PUBLISH whose topic contains them. $-prefix topics are reserved. Topics beginning with$(such as$SYS/...) are broker-internal. Subscribing to#does not match them; you must subscribe to$SYS/#explicitly.
The Forward Slash Is the Only Separator
Because / is the only structural character, every level you add is a real branch in the tree. plant/line3/cellA/temp_sensor/value is five levels deep. A subscriber can address any slice of that path with wildcards. If you instead pack the same information into plant.line3.cellA.temp_sensor.value, you have one flat level — wildcards become useless and the broker cannot help you route.
This single decision — slashes vs. dots — determines whether your topic namespace is a queryable tree or a flat bag of strings. Use slashes. Reserve dots, dashes, and underscores for naming within a level.
Wildcards: + and #
Subscriptions accept two wildcard characters. They look similar but behave very differently.
Single-level: +
The plus sign matches exactly one level. It can appear anywhere a level would appear, and it matches precisely one level — never zero, never two.
plant/+/cellA/valuematchesplant/line3/cellA/valueandplant/line4/cellA/value.- It does not match
plant/cellA/value(missing the middle level) orplant/line3/zone2/cellA/value(extra level). - Multiple
+signs are allowed:plant/+/cellA/+matchesplant/line3/cellA/valueandplant/line3/cellA/status.
Multi-level: #
The hash matches all remaining levels — zero or more. It must be the last character of the subscription and must occupy its own level (write plant/#, not plant#).
plant/#matchesplant,plant/line3, andplant/line3/cellA/temp_sensor/value.plant/line3/#matchesplant/line3and everything beneath it.- Because
#matches zero levels too,plant/#also matches the bare topicplant. This is a frequent source of surprise.
Quick Comparison
Combine them sparingly. +/temperature/# is legal but hard to reason about. The clearer pattern is to fix as many levels as you can and wildcard only the axis you actually want to vary.
Building a Naming Convention
A workable industrial topic scheme reads like a path from the general to the specific. Each level adds one piece of context, ordered most-stable to most-volatile:
- Site / plant:
site-houston,plant-a - Area / line:
line3,filler-1 - Device / asset:
cellA,vav12,pump-04 - Measurement point:
temp_sensor,setpoint,status - Aspect of that point:
value,units,alarm
Concrete examples:
plant/line3/cellA/temp_sensor/valuesite/hvac/floor2/vav12/setpointsite-houston/area-b/pump-04/statusfactory/filler-1/hopper/weight/alarm
With this shape, useful subscriptions fall out naturally: plant/line3/# for everything on Line 3, plant/+/cellA/temp_sensor/value to compare Cell A across lines, site/hvac/floor2/+/setpoint for all setpoints on Floor 2. ACLs can lock down whole subtrees by prefix (plant/line3/#), which is how brokers enforce least privilege.
Decide on a case convention once and stick to it. Lowercase with underscores or hyphens is the most common choice. Avoid mixed case — Line3 and line3 are different topics, and that mismatch is a classic silent-bug source.
Shared Subscriptions
Normal subscriptions are fan-out: every matching subscriber gets a copy. For load-balanced consumption — many workers pulling from the same stream — MQTT 5.0 adds shared subscriptions using the $share/group/... prefix:
$share/workers/jobs/#— all subscribers in theworkersgroup share delivery of messages published tojobs/.... Each message goes to exactly one worker.$share/ingest/site/+/telemetry— distribute telemetry across an ingest pool so no single consumer becomes the bottleneck.
Shared subscriptions solve the problem that request/response and queue-oriented systems solve natively. Without them, you would need an external queue (RabbitMQ, Kafka, SQS) in front of MQTT just to get load balancing. With them, the broker round-robins messages across the group. Note that ordering is only guaranteed within a single consumer, not across the group, so design your handlers to be idempotent.
Topic Security via ACL
Most production brokers (Mosquitto, EMQX, HiveMQ, VerneMQ) authorize clients by topic prefix. When a client connects, it presents a username, certificate, or token, and the broker applies an ACL that says what topic patterns that client may publish or subscribe to.
read plant/line3/#— the client may subscribe to anything under Line 3.write plant/line3/cellA/+/setpoint— the client may publish setpoints to Cell A on Line 3.read $SYS/broker/uptime— the client may read one specific broker metric.
This only works if your topics are structured so that the security boundary is a clean prefix. If a tenant boundary sits in the middle of a topic (device/public-tenant-x/sensor), you cannot grant access to one tenant without exposing neighbors. Put the tenant (or site, or customer) at the top of the tree: tenant-x/device/sensor. Then the ACL is read tenant-x/# and there is no leakage.
Anti-Patterns to Avoid
Embedding the value in the topic
temperature/22.5c creates a new topic for every reading. Subscribers cannot subscribe to "current temperature" — they would have to know the value in advance. The reading belongs in the payload; the topic is routing metadata only.
Per-message unique topics
sensor/device-1837492/2026-08-11-14-02-07 makes every message its own leaf. The broker's subscription tree grows without bound, retained messages are useless, and memory consumption climbs. Use a stable topic and let the payload carry the timestamp.
Leading slash and empty levels
/plant/line3 and plant//line3 both introduce empty levels that interact unpredictably with + and #. Strip leading and trailing slashes and never leave a level blank.
Spaces and special characters
plant/line 3/cell A is legal UTF-8 but a nightmare in URLs, shells, and logs. Use plant/line_3/cell_a instead.
Deeply nested unbounded topics
If your topic has a level whose cardinality is unbounded — a device serial number plus a timestamp plus a counter — the broker tree grows forever. Cap the depth. Move high-cardinality data into the payload.
Querying by value
MQTT is not a database. There is no "give me all sensors above 30°C" operator. If you need value queries, push the data into a time-series database (InfluxDB, TimescaleDB, Prometheus) from a subscriber, and query it there.
Retained Messages and Topics
When a publisher sets the retain flag, the broker stores the last message published on that topic and delivers it to every new subscriber. Retained messages are how you represent "last known good" state — the current setpoint, the latest reading, the device's online status. Retained messages and Last Will interact with topic design in two ways:
- One retained message per topic. If you publish the value into the topic name (
setpoint/75.0), each new value creates a new retained topic, and the broker accumulates stale leaves forever. Use a stable topic (cellA/setpoint) and the broker replaces the retained value on each publish. - Wildcards are read-only. A subscriber can read retained messages across
cellA/#, but a publisher cannot publish with retain to a wildcard — only to a concrete topic.
Inspecting Topics with a Tree Browser
Topic problems are much easier to find when you can see the tree. A topic-tree browser shows every active topic in your broker as a collapsible hierarchy, with the latest payload and message count at each leaf. When an ESP32 publishes to home/sensor/temperature, home/sensor/humidity, and home/status/uptime, you see all three branches immediately and can spot a typo like home/sensor/temperture at a glance. MacTools MQTT Explorer ships this view natively — it is the fastest way to validate a naming convention against a real device before you commit it to firmware.
Frequently Asked Questions
What characters are allowed in an MQTT topic?
MQTT topics are UTF-8 strings between 1 and 65535 bytes. The forward slash (/) separates levels, the plus sign (+) matches exactly one level, and the hash (#) matches all remaining levels. Topics cannot contain the null character, should avoid spaces and control characters, and must not include wildcards when used in a PUBLISH packet. Wildcards are valid only in SUBSCRIBE.
What is the difference between the + and # wildcards in MQTT?
The plus sign (+) is a single-level wildcard that matches exactly one topic level, so sensor/+/temp matches sensor/a/temp and sensor/b/temp but not sensor/a/zone/temp. The hash (#) is a multi-level wildcard that must be the final character of the subscription and matches all remaining levels including zero, so sensor/# matches sensor, sensor/a, and sensor/a/b/c.
Should I put the device value in the MQTT topic?
No. Embedding values such as temperature/22.5c or sensor/device-1837492-20260811 creates unbounded cardinality, defeats wildcard subscriptions, and prevents retained messages from working cleanly. Put the reading in the payload and keep the topic as a stable routing key (metadata only). One device, one topic.
What is a shared subscription in MQTT?
A shared subscription uses the $share/group/topic prefix so that multiple subscribers in the same group share delivery of matching messages, providing load-balanced consumption across workers. For example, $share/workers/jobs/# distributes job messages across all workers in the group. Shared subscriptions require MQTT 5.0 or a broker that back-ports the feature to 3.1.1.
MQTT Explorer for macOS
MacTools MQTT Explorer — built-in broker, publish/subscribe, topic tree browser, message history. Validate your topic naming convention against a real device before you ship firmware. $14.99 one-time.
Get MacTools MQTT ExplorerRelated: Full SCADA System
Need continuous MQTT monitoring with dashboards, alarms, and trending? Voltrus SCADA supports MQTT, Modbus TCP/RTU, OPC-UA, Siemens S7, BACnet, DNP3, and more. Lifetime license from $249.