Tutorials

MQTT with Protocol Buffers Payloads: Schemas, Encoding and Versioning

Use Protocol Buffers as MQTT payloads: write a .proto schema, encode and decode in Python and Node.js, tag messages with MQTT 5 properties and evolve schemas safely.

Updated · 9 min read

MQTT with Protocol Buffers Payloads: Schemas, Encoding and Versioning

Protocol Buffers (protobuf) work as MQTT payloads out of the box: MQTT transports bytes, protobuf produces bytes, and the broker never needs to know the difference. What you gain is a typed schema shared by every publisher and subscriber, payloads that are usually a third of the size of the equivalent JSON, and clear rules for evolving the format without breaking devices already in the field.

This tutorial defines a telemetry schema, encodes and decodes it in Python and Node.js, labels messages with MQTT 5 properties, shows how to version the schema, and how to read protobuf messages when you are debugging.

When protobuf is worth it

SituationJSONProtobuf
Quick prototypes, human-readable debuggingBetterNeeds tooling
Constrained links (cellular, LoRa backhaul, satellite)LargerMuch smaller
Many teams or languages sharing one formatImplicit contractExplicit .proto contract
Long-lived device fleets that change slowlyFragile without disciplineBuilt-in evolution rules
Browser dashboardsNativeNeeds a JS library

If your payloads are small and few, JSON is fine — see payload design best practices. If schema-free binary sounds better than code generation, compare CBOR and MessagePack. Protobuf shines when the format is a contract between teams. It is also what Sparkplug B uses for its payloads.

Define the schema

telemetry.proto
syntax = "proto3";

package testmqtt;

message Telemetry {
  string device_id     = 1;
  int64  ts_ms         = 2;   // Unix time in milliseconds
  float  temperature_c = 3;
  float  humidity      = 4;
  uint32 battery_mv    = 5;
  Status status        = 6;
}

enum Status {
  STATUS_UNSPECIFIED = 0;
  STATUS_OK          = 1;
  STATUS_LOW_BATTERY = 2;
}

Field numbers, not names, are what goes on the wire. That is why protobuf is compact, and why the numbers must never change once devices use them.

For a sample reading (sensor-042, a millisecond timestamp, two floats and a battery voltage) the compact JSON is 103 bytes; the protobuf encoding of the same values is about 32 bytes. The exact ratio depends on your data — long strings shrink little, numbers and repeated keys shrink a lot.

Python: encode and publish

Generate the Python module with the compiler bundled in grpcio-tools (or a standalone protoc), then install the runtime and the MQTT client:

Setup
pip install "paho-mqtt>=2" protobuf grpcio-tools
python -m grpc_tools.protoc -I. --python_out=. telemetry.proto   # creates telemetry_pb2.py

Keep the protobuf runtime at least as new as the compiler that generated the code; generated modules refuse to load on an older runtime.

publish_proto.py
import time
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties
import telemetry_pb2 as pb

TOPIC = "testmqtt/proto/v1/sensor-042/telemetry"

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
# client.username_pw_set("username", "password")
client.connect("broker.connectmq.com", 1883)
client.loop_start()

props = Properties(PacketTypes.PUBLISH)
props.ContentType = "application/x-protobuf"
props.UserProperty = [("schema", "testmqtt.Telemetry"), ("schema-version", "1")]

msg = pb.Telemetry(
    device_id="sensor-042",
    ts_ms=int(time.time() * 1000),
    temperature_c=21.5,
    humidity=48.2,
    battery_mv=3710,
    status=pb.STATUS_OK,
)
payload = msg.SerializeToString()
client.publish(TOPIC, payload, qos=1, properties=props).wait_for_publish(10)
print(f"published {len(payload)} bytes")

client.loop_stop()
client.disconnect()

The subscriber parses the bytes back into an object. Check the content type before decoding, so a stray JSON message on the topic is reported rather than misread:

subscribe_proto.py
import paho.mqtt.client as mqtt
from google.protobuf.message import DecodeError
import telemetry_pb2 as pb

def on_connect(client, userdata, flags, reason_code, properties):
    client.subscribe("testmqtt/proto/v1/+/telemetry", qos=1)

def on_message(client, userdata, msg):
    ctype = getattr(msg.properties, "ContentType", None)
    if ctype not in (None, "application/x-protobuf"):
        print(f"skip {msg.topic}: content type {ctype}")
        return
    try:
        t = pb.Telemetry.FromString(msg.payload)
    except DecodeError as e:
        print(f"bad payload on {msg.topic}: {e}")
        return
    print(t.device_id, t.ts_ms, round(t.temperature_c, 1), pb.Status.Name(t.status))

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883)
client.loop_forever()

Note round(t.temperature_c, 1): float fields are 32-bit, so 21.5 survives exactly but 48.2 comes back as 48.200000762939453. Use double, or send scaled integers (sint32 temp_c10 = 215), when exact decimals matter.

Node.js: protobufjs and MQTT.js

protobufjs can load the .proto file at runtime, so there is no code-generation step:

Setup
npm install mqtt protobufjs
proto-client.mjs
import mqtt from "mqtt";
import protobuf from "protobufjs";

const root = await protobuf.load("telemetry.proto");
const Telemetry = root.lookupType("testmqtt.Telemetry");
const TOPIC = "testmqtt/proto/v1/sensor-042/telemetry";

const client = mqtt.connect("mqtt://broker.connectmq.com:1883", { protocolVersion: 5 });

client.on("connect", async () => {
  await client.subscribeAsync("testmqtt/proto/v1/+/telemetry", { qos: 1 });

  // protobufjs uses camelCase field names by default
  const fields = { deviceId: "sensor-042", tsMs: Date.now(), temperatureC: 21.5,
                   humidity: 48.2, batteryMv: 3710, status: 1 };
  const problem = Telemetry.verify(fields);
  if (problem) throw new Error(problem);

  const bytes = Telemetry.encode(Telemetry.create(fields)).finish();
  await client.publishAsync(TOPIC, Buffer.from(bytes), {
    qos: 1,
    properties: {
      contentType: "application/x-protobuf",
      userProperties: { schema: "testmqtt.Telemetry", "schema-version": "1" },
    },
  });
});

client.on("message", (topic, payload) => {
  const msg = Telemetry.decode(payload);
  const obj = Telemetry.toObject(msg, { longs: Number, enums: String });
  console.log(topic, obj);
});

Two things trip people up here. First, protobufjs converts device_id to deviceId; passing snake_case keys to create() silently drops them. Second, int64 fields decode as Long objects unless you pass longs: Number (safe for millisecond timestamps) or longs: String.

Versioning the schema

Protobuf's evolution rules let old and new devices coexist, as long as you follow them:

ChangeSafe?Why
Add a new field with a new numberYesOld readers skip unknown fields; new readers see the default for old messages
Remove a fieldYes, if you reserved its number and namePrevents a future field from reusing the number with a different meaning
Rename a fieldYes on the wireOnly the number is encoded — but JSON mappings and code break
Change a field's typeUsually noint32 ↔ int64 is compatible; float → string is not
Reuse an old field numberNeverOld devices will write data the new reader misinterprets
Add an enum valueYesOld readers keep unknown values as integers in proto3
telemetry.proto — v1.1
message Telemetry {
  reserved 4;                 // humidity moved to the Environment message
  reserved "humidity";
  string device_id     = 1;
  int64  ts_ms         = 2;
  float  temperature_c = 3;
  uint32 battery_mv    = 5;
  Status status        = 6;
  int32  rssi_dbm      = 7;   // new in v1.1
}

For a genuinely breaking change, do not fight the rules: create a new message type, publish it under a new topic prefix (testmqtt/proto/v2/...) and let backends subscribe to both during the migration. The version in the topic makes it visible in logs, ACLs and the topic matcher, and the schema-version user property (MQTT 5 user properties) gives consumers a second signal without parsing.

Debugging protobuf messages

Binary payloads are unreadable in text tools, so keep protoc handy. mosquitto_sub -N prints the payload without a trailing newline, which keeps the bytes intact for the decoder:

Decode one message with the schema
mosquitto_sub -h broker.connectmq.com -t 'testmqtt/proto/v1/+/telemetry' -C 1 -N \
  | protoc --decode=testmqtt.Telemetry telemetry.proto
Decode without the schema
mosquitto_sub -h broker.connectmq.com -t 'testmqtt/proto/#' -C 1 -N | protoc --decode_raw

--decode_raw prints field numbers and raw values (1: "sensor-042", 5: 3710), enough to see which device sent a malformed message. To confirm messages are arriving on the topics you expect at all, subscribe to testmqtt/proto/# in the online MQTT client while your publisher runs.

A useful automated test publishes an encoded message, receives it, decodes it and compares every field — including a message produced by the previous schema version, so compatibility regressions fail in CI instead of in the field. The Python MQTT guide and JavaScript guide cover the client setup for such tests.

Watch protobuf traffic in the online client

Protobuf turns your MQTT payload into a versioned contract: define the schema once, generate or load it in every language, label messages with content type and version, and never reuse a field number. Keep a decode command in your notes and an old-version message in your tests, and schema changes stop being scary.

Frequently asked questions

Does MQTT support Protocol Buffers?

MQTT carries any bytes, so a serialized protobuf message is a valid payload with no broker configuration. The broker never decodes it; publisher and subscriber just need the same .proto schema.

How do subscribers know which protobuf message type a payload is?

Decide it by topic (one message type per topic pattern), or with MQTT 5 set the Content Type property and a user property naming the schema and version. A wrapper message with a oneof field also works.

How do I debug protobuf MQTT messages?

Pipe the raw payload from mosquitto_sub with -N into protoc --decode=Package.Message schema.proto, or use protoc --decode_raw when you do not have the schema at hand.