Modbus to MQTT: How the Gateway Bridge Works

By | July 19, 2026

Modbus and MQTT solve the same problem from opposite ends. Modbus reads and writes numbers in a device. MQTT moves messages between systems. Put them together and you can take a register sitting inside a serial meter and land its value in a cloud database a continent away. But the two don’t connect on their own — they don’t even share the same idea of how communication works. Something has to sit in the middle and translate. That something is a Modbus-to-MQTT gateway, and this is what it actually does.

The mismatch you’re bridging

Before the how, be clear on why it isn’t trivial. Modbus and MQTT disagree on four basic things.

ModbusMQTT
ModelRequest/response (polling)Publish/subscribe (event-driven)
RolesClient polls, server answersPublisher sends, subscriber receives
Data unit16-bit register at a numeric addressPayload on a named topic
Reach & securityBuilt for a local wired bus, no securityBuilt for WAN, TLS and auth standard

Modbus is a pull model: nothing moves until a client asks. Register 40001 doesn’t announce itself — someone has to read it. MQTT is a push model: a publisher sends when it has something, and whoever subscribed gets it. The gateway’s whole job is to turn one into the other.

What the gateway is, in MQTT terms

Strip away the marketing and a Modbus-to-MQTT gateway is just an MQTT client with a Modbus interface bolted on. It has two faces:

  • Southbound (Modbus side): it speaks Modbus to the field.
  • Northbound (MQTT side): it acts as an MQTT publisher for readings, and an MQTT subscriber for commands, against a broker.

From the broker’s point of view there’s nothing special here. The gateway is one more client publishing and subscribing. All the intelligence is in the middle: the mapping.

Which end holds the Modbus master?

The southbound side has a fork that decides how the whole bridge behaves, and it’s easy to get wrong. Modbus is strictly master/slave — only the master initiates, slaves only answer. So the question is which role the gateway plays.

  • Gateway as master (polling). The gateway is the Modbus master and the field devices are slaves. It polls registers on a timer — read holding registers (FC03), read input registers (FC04), read coils (FC01) — and writes back with FC06 or FC16. This is how most standalone gateway products work, and it fits when the gateway is the only thing driving the bus.
  • Gateway as slave (agent). The field already has its own master — a PLC running the process. That PLC stays the master, and the gateway joins the bus as a slave. Instead of polling, the gateway waits for the PLC’s ladder logic to write event data into the gateway’s registers (FC06/FC16) when something actually happens, then publishes it. Reads flow the same way in reverse: an MQTT command lands, the PLC reads it out of the gateway on its next scan.

The two aren’t interchangeable. Polling is simple and self-contained but constant, even when nothing changes. The agent model is event-driven at the source — the PLC only writes on a real transition, so it’s the leanest option — but it depends on someone owning the ladder logic. Pick based on who already controls the bus: if the gateway is alone with the devices, make it master; if it’s joining a live PLC, make it a slave.

The heart of it: mapping registers to topics

This is the part that actually takes thought. Modbus addresses data by number; MQTT addresses it by name. The gateway holds a map that says “this register on this device becomes this topic.”

A slice of a typical map:

DeviceFunctionRegisterData typeMQTT topic
Meter 1 (unit 1)FC030–1uint32plant/meter1/voltage
Meter 1 (unit 1)FC032–3uint32plant/meter1/current
Meter 1 (unit 1)FC034–5float32plant/meter1/power
Pump 2 (unit 5)FC02100boolplant/pump2/running

Two design choices decide whether this map is pleasant or painful.

Topic structure. Use a hierarchy — general on the left, specific on the right — so wildcards work later: plant/meter1/voltage, plant/meter1/current. Don’t flatten everything into voltage, current, data. Design the tree once, up front; renaming topics across a live fleet is miserable.

Which registers to expose. You rarely want every register. Map the ones that carry real signals and skip the padding. A tighter map means less traffic and a cleaner topic space.

Building the payload

A raw Modbus register is a 16-bit integer. That’s almost never what you want to publish. The gateway shapes it into something the northbound side can use, and there are three common levels.

Raw value. Publish the integer as-is: "14732". Simplest, but the consumer has to know the scaling and reassemble multi-register values itself.

Scaled engineering value. Apply the device’s scale factor at the gateway, so 14732 becomes "147.32" volts. The consumer gets a real number. This is usually the right call.

JSON. Wrap the value with context — units, a timestamp, quality:

json

{"voltage": 147.32, "unit": "V", "ts": 1721400000}

JSON is readable and self-describing, which is why most integrations use it. If bandwidth is tight, publish binary with a fixed layout instead — smaller, but the consumer needs the format spec.

One trap lives here: byte and word order. A 32-bit value spans two Modbus registers, and devices disagree on which register holds the high word. Get it backwards and your 147.32 V reads as something absurd. The gateway map has to pin down the word order per device — this is the single most common cause of “the numbers are wrong” after a bridge goes live. (See our Modbus byte order / word order guide for the full picture.)

The read path: field in, MQTT out

The flow, each time a fresh reading appears:

  1. Get the value. In the master model that means polling the register on a timer; in the agent model it means the PLC has just written new data into the gateway.
  2. Check for a Modbus exception — a wrong unit id or a dead device comes back as an error, not a value. Don’t publish garbage.
  3. Convert: reassemble multi-register values, apply scaling, build the payload.
  4. Publish to the mapped topic.

A decision worth making early: publish everything, or only changes? Two styles:

  • Periodic: publish every value every cycle. Simple, predictable load, but noisy — you send the same voltage a thousand times while nothing moves.
  • Report by exception (change of value): publish only when a value changes beyond a deadband. Far less traffic, and it’s how efficient gateways run. The cost is you must keep the last value per point to compare against.

Either way, keep the poll rate and the publish rate separate in your head. Polling a meter ten times a second doesn’t mean you should publish ten times a second. (The agent model gets report-by-exception for free: the PLC only writes on a real transition, so there’s nothing to publish until something changes.)

The write path: commands coming back down

Control runs the other direction. The gateway subscribes to command topics. When a message lands, it writes the value into a Modbus register with FC06 (single register) or FC16 (multiple).

MQTT publish  ->  plant/pump2/cmd/speed  {"value": 1500}
        gateway (subscribed) receives it
        gateway writes 1500 to register via FC06 to unit 5

Keep command topics separate from data topics — a cmd/ branch is the usual convention — so a device never confuses a reading with an order. And think about QoS here: a command has to arrive, so publish it at QoS 1 (or 2), not 0. A dropped setpoint is a real problem in a way a dropped telemetry sample isn’t.

MQTT features that matter for a gateway

Three MQTT mechanics do specific jobs in a bridge:

Retained messages. Publish each value with retain=True and the broker keeps the last one. Any dashboard that connects later immediately sees the current voltage instead of waiting for the next poll. Retained topics turn MQTT into a live snapshot of the Modbus data.

Last will and testament. The gateway registers a will like offline on plant/meter1/status. If the gateway crashes or loses its link, the broker publishes it automatically. Now consumers can tell “the value is 0” from “the gateway is gone” — a distinction Modbus polling can’t give you cleanly.

QoS. QoS 0 for high-rate telemetry that’s replaced a second later; QoS 1 or 2 for commands and events that must land. Match the guarantee to the cost of losing the message.

A minimal bridge in Python

Concepts are easier to trust with code. This is a stripped-down read-and-write bridge using pymodbus on the Modbus side and paho-mqtt on the MQTT side. It’s illustrative, not production — no reconnection, one device — but every call is real. (The Python MQTT pillar covers the MQTT half in depth.)

python

import json, time
from pymodbus.client import ModbusTcpClient
import paho.mqtt.client as mqtt

# --- Modbus side (southbound) ---
modbus = ModbusTcpClient("192.168.1.50", port=502)
modbus.connect()

# --- MQTT side (northbound) ---
mq = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="meter-gw-01")
mq.will_set("plant/meter1/status", "offline", qos=1, retain=True)
mq.connect("broker.example.com", 1883, keepalive=60)
mq.loop_start()
mq.publish("plant/meter1/status", "online", qos=1, retain=True)

def read_u32(regs):                       # reassemble two 16-bit regs, high word first
    return (regs[0] << 16) | regs[1]

# --- Read path: Modbus -> MQTT ---
rr = modbus.read_holding_registers(0, count=2, device_id=1)   # pymodbus 3.x: device_id, not unit
if not rr.isError():
    voltage = read_u32(rr.registers) / 100.0                  # apply scale factor
    payload = json.dumps({"voltage": voltage, "ts": int(time.time())})
    mq.publish("plant/meter1/voltage", payload, qos=1, retain=True)

# --- Write path: MQTT -> Modbus ---
def on_message(client, userdata, msg):
    setpoint = int(json.loads(msg.payload)["value"])
    modbus.write_register(10, setpoint, device_id=1)          # FC06

mq.on_message = on_message
mq.subscribe("plant/meter1/cmd/setpoint", qos=1)

Note the version-sensitive bits: pymodbus 3.x uses device_id= (older code used unit= or slave=), and paho-mqtt 2.x requires the CallbackAPIVersion argument. Copying an older tutorial straight in is how most bridges break on the first run.

Standardizing the mapping: Sparkplug B

Roll your own topic tree and payload format and it works — until you add a second gateway from a different vendor that names things differently, and now the consumer parses two schemes. Sparkplug B is an MQTT application specification that fixes this: it defines a standard topic namespace (spBv1.0/...), a compact payload format, and birth/death messages so the broker always knows what’s online. For a fleet of Modbus-to-MQTT gateways feeding one system, it’s worth knowing before you invent your own convention. See our dedicated Sparkplug B article for the detail.

Quick reference

ConcernModbus sideMQTT side
RoleMaster (polls slaves) or slave (PLC writes to it)Publisher (data) / subscriber (commands)
Read dataFC01/02/03/04Publish to .../value topic
Write dataFC06 / FC16Subscribe to .../cmd/... topic
AddressNumeric register + unit idNamed hierarchical topic
Multi-register valueReassemble 2+ regs, mind word orderScaled number or JSON payload
“Last known value”Not nativeRetained message
“Device offline”Poll timeout / exceptionLast will and testament
Delivery guaranteeImplicit (request/response)QoS 0 telemetry, QoS 1–2 commands
EfficiencyPoll rateReport by exception (change of value)
Standard mappingSparkplug B
Example libraries (2026)pymodbus 3.14paho-mqtt 2.1
Author: Zakaria El Intissar

I've spent 13 years in power system automation, electrical protection, and SCADA communication, as an automation and industrial computing engineer. ScadaProtocols.com is where I turn what I've learned on site into plain guides and working tools — so other engineers can decode, analyze, and troubleshoot industrial communication protocols without the guesswork.