Tutorials

Build a Unified Namespace (UNS) with MQTT

How to build a Unified Namespace with MQTT: an ISA-95 topic hierarchy, retained state, payload conventions, how Sparkplug B fits in, and scripts to test it.

Updated · 9 min read

Build a Unified Namespace (UNS) with MQTT

A Unified Namespace (UNS) is a single, event-driven place where every system in a company publishes its current state, organised by where things are in the business rather than by which software produced them. In practice it is an MQTT broker plus a topic hierarchy — usually shaped after ISA-95: enterprise / site / area / line / cell — with retained messages holding the latest value of everything.

This tutorial shows how to design that hierarchy, decide what to retain, choose payload conventions, fit Sparkplug B in, and test the namespace with a few small scripts.

Why a UNS instead of point-to-point integrations

Classic plants integrate system by system: the MES polls the SCADA, the ERP imports a CSV from the MES, a dashboard queries a historian. Every new consumer means a new integration, and nobody has one place to see the present state of the operation.

A UNS replaces this with a hub:

Point-to-pointUnified Namespace
IntegrationsOne per pair of systemsOne per system (to the broker)
Adding a consumerNew interface on the sourceSubscribe to a topic
Current stateSpread across databasesRetained on the broker
Data flowPolling, batchEvent-driven, report by exception
OwnershipEach interface owned separatelyOne namespace, governed centrally

Producers — PLC gateways, MES, quality systems, ERP adapters — publish into their branch of the tree. Consumers subscribe to whatever they need. Nobody needs to know who else is connected.

Design the ISA-95 hierarchy

ISA-95 gives you a vocabulary for the physical and organisational structure of a plant. Mapping it directly to topic levels makes the namespace readable for both OT and IT people:

LevelExampleTopic level
EnterpriseAcme Foodsacme
SiteDallas plantdallas
AreaPackagingpackaging
Line (work center)Line 1line1
Cell (work unit)Fillerfiller
DataSpeed, state, countsspeed, state, good-count

So the filler's speed lives at acme/dallas/packaging/line1/filler/speed. Below the ISA-95 levels, add a small set of namespaces by data kind so consumers can tell state from events:

One branch of the namespace
acme/dallas/packaging/line1/filler/state            "running"       retained
acme/dallas/packaging/line1/filler/speed            {"v":118,...}   retained
acme/dallas/packaging/line1/filler/good-count       {"v":48211,...} retained
acme/dallas/packaging/line1/filler/events/fault     {"code":"E14"}  not retained
acme/dallas/packaging/line1/mes/work-order          {"id":"WO-881"} retained
acme/dallas/packaging/line1/quality/last-check      {"result":"ok"} retained
acme/dallas/packaging/line1/filler/cmd/setpoint     (restricted)    not retained

Rules that keep a namespace healthy over the years:

  • Lower-case, no spaces, ASCII only, hyphens inside a level. Topics are case-sensitive, and mixed case guarantees duplicates.
  • Name by place, not by source. line1/filler/speed, never kepware3/channel2/tag17. Sources change; the line stays.
  • Keep levels fixed in depth inside a branch, so acme/+/packaging/+/filler/state works across all sites.
  • Separate commands under cmd/ and protect them with ACLs; most producers should never be able to write there.
  • Do not put values in topic names (no .../temp/21.5); the value belongs in the payload.

The general principles are covered in topic design best practices. Before committing to a layout, try your subscription patterns in the topic matcher.

Retained state is the heart of a UNS

The namespace should answer "what is happening right now?" to a client that connected one second ago. That is exactly what retained messages do: the broker stores the last retained message per topic and delivers it to every new subscriber immediately.

  • Retain state: values, statuses, current work order, last quality result, setpoints.
  • Do not retain events: faults raised, parts rejected, commands. A new subscriber should not see yesterday's fault as if it just happened.
  • Clear obsolete topics by publishing an empty retained payload when a machine is decommissioned, or the namespace slowly fills with ghosts.
  • Use MQTT 5 message expiry on state that becomes meaningless if not refreshed, so stale values disappear instead of lying.

Pair every producer with a retained status topic and a Last Will (offline), as shown in the Modbus gateway tutorial. Consumers can then tell a stable value from a dead source.

Payload conventions

Agree on one payload shape for state and stick to it. A simple, self-describing JSON works well:

State payload
{ "v": 118, "unit": "bpm", "ts": "2026-10-17T08:41:07.214Z", "q": "good" }

v is the value, unit avoids guessing, ts is the source timestamp in UTC, q the quality. Static metadata (descriptions, ranges, engineering units) can live in a retained _meta sibling so the hot path stays small.

Seed and browse a test namespace

Two small paho-mqtt 2.x scripts let you experiment before any real producer exists. The first publishes a retained snapshot of a tiny plant with a one-hour expiry:

seed_uns.py
import json, time
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties

ROOT = "testmqtt/uns/acme/dallas/packaging/line1"
STATE = {
    "filler/state":      "running",
    "filler/speed":      {"v": 118, "unit": "bpm"},
    "filler/good-count": {"v": 48211, "unit": "pcs"},
    "capper/state":      "idle",
    "mes/work-order":    {"id": "WO-881", "product": "SKU-330ml"},
}

props = Properties(PacketTypes.PUBLISH)
props.MessageExpiryInterval = 3600          # seconds

c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
c.connect("broker.connectmq.com", 1883)
c.loop_start()
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
for path, value in STATE.items():
    body = value if isinstance(value, str) else json.dumps({**value, "ts": now})
    c.publish(f"{ROOT}/{path}", body, qos=1, retain=True, properties=props).wait_for_publish()
c.loop_stop()
c.disconnect()
print("seeded", len(STATE), "topics")

The second connects as a brand-new client, collects whatever the broker hands it for a few seconds and prints the namespace as a tree — exactly what a new consumer would see:

browse_uns.py
import time
import paho.mqtt.client as mqtt

FILTER = "testmqtt/uns/acme/#"
seen = {}

def on_connect(client, userdata, flags, reason_code, properties):
    client.subscribe(FILTER, qos=1)

def on_message(client, userdata, msg):
    seen[msg.topic] = (msg.payload.decode(errors="replace"), msg.retain)

c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
c.on_connect, c.on_message = on_connect, on_message
c.connect("broker.connectmq.com", 1883)
c.loop_start()
time.sleep(3)
c.loop_stop()

for topic in sorted(seen):
    payload, retained = seen[topic]
    depth = topic.count("/") - FILTER.count("/") + 1
    print("  " * depth + topic.rsplit("/", 1)[1], "=", payload, "(retained)" if retained else "")

If the browser prints every seeded topic marked (retained), your state layer works. You can do the same check visually in the online MQTT client:

Browse the test namespace in your browser

The shared endpoint is fine for trying the layout. A real namespace holds the live state of your operation, so run it on a broker you control.

Where Sparkplug B fits

Sparkplug B is a specification on top of MQTT for industrial devices: a fixed topic structure (spBv1.0/<group>/<message type>/<edge node>/<device>), Protobuf payloads, birth/death certificates and a primary host that tracks state. It solves the edge problem very well, but its topics are shaped by message type and edge node, not by ISA-95.

ApproachHow it worksGood when
Plain MQTT UNSProducers publish JSON straight into ISA-95 topicsMixed IT/OT consumers, simple tooling, readable topics
Sparkplug at the edge, UNS aboveEdge nodes speak Sparkplug; a host or bridge republishes into ISA-95 topicsMany devices, need for standard state and compact payloads
Sparkplug onlyConsumers all understand SparkplugHomogeneous SCADA ecosystem

The hybrid model is popular for a reason: devices get Sparkplug's state management, and the rest of the company gets a readable namespace. The trade-offs are discussed in Sparkplug vs plain MQTT. Data from OPC UA servers can enter the same tree through an OPC UA to MQTT bridge.

A testing checklist for your namespace

  1. Fresh-subscriber test: a new client subscribing to acme/<site>/# receives the current state of every asset immediately (all retained).
  2. Event test: events arrive live but are not replayed to a new subscriber.
  3. Source-swap test: replace a producer (simulated gateway → real gateway); consumers notice nothing.
  4. Dead-source test: kill a producer; its status flips to offline via the Last Will.
  5. ACL test: try to publish into another line's branch with a gateway's credentials — it must be refused.
  6. Wildcard test: every dashboard filter matches exactly the intended topics in the topic matcher.

A Unified Namespace is less about technology than about agreement: one broker, one hierarchy everyone understands, retained state for the present and clear rules about who publishes where. Start with one line, seed it, browse it, break it on purpose — and grow the tree only when the first branch has proven itself.

Frequently asked questions

Is a Unified Namespace a product?

No. A UNS is an architecture pattern: one event-driven hub, usually an MQTT broker, where every system publishes its current state into a shared, business-shaped topic hierarchy. You build it from a broker, naming rules and the clients that publish and consume.

Do I need Sparkplug B for a Unified Namespace?

No. Many UNS implementations use plain MQTT with JSON payloads and ISA-95 shaped topics. Sparkplug B is useful at the edge for standard birth/death state and compact payloads; a common pattern is Sparkplug from devices to a host, which then republishes into the ISA-95 namespace.

Should every message in the UNS be retained?

Retain current state (values, statuses, setpoints) so any new consumer sees the present immediately. Do not retain events such as alarms raised, parts produced or commands; those belong to the moment they happen and to a historian afterwards.