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
| Situation | JSON | Protobuf |
|---|---|---|
| Quick prototypes, human-readable debugging | Better | Needs tooling |
| Constrained links (cellular, LoRa backhaul, satellite) | Larger | Much smaller |
| Many teams or languages sharing one format | Implicit contract | Explicit .proto contract |
| Long-lived device fleets that change slowly | Fragile without discipline | Built-in evolution rules |
| Browser dashboards | Native | Needs 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
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:
pip install "paho-mqtt>=2" protobuf grpcio-tools
python -m grpc_tools.protoc -I. --python_out=. telemetry.proto # creates telemetry_pb2.pyKeep the protobuf runtime at least as new as the compiler that generated the code; generated modules refuse to load on an older runtime.
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:
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:
npm install mqtt protobufjsimport 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:
| Change | Safe? | Why |
|---|---|---|
| Add a new field with a new number | Yes | Old readers skip unknown fields; new readers see the default for old messages |
| Remove a field | Yes, if you reserved its number and name | Prevents a future field from reusing the number with a different meaning |
| Rename a field | Yes on the wire | Only the number is encoded — but JSON mappings and code break |
| Change a field's type | Usually no | int32 ↔ int64 is compatible; float → string is not |
| Reuse an old field number | Never | Old devices will write data the new reader misinterprets |
| Add an enum value | Yes | Old readers keep unknown values as integers in proto3 |
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:
mosquitto_sub -h broker.connectmq.com -t 'testmqtt/proto/v1/+/telemetry' -C 1 -N \
| protoc --decode=testmqtt.Telemetry telemetry.protomosquitto_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.

