MQTT does not know what JSON is: a payload is just bytes. To send JSON over MQTT in Python, serialize your object with json.dumps() before publish(), and call json.loads(msg.payload) in on_message. Everything else in this guide is about doing that safely: validating what arrives, surviving bad messages, handling dates and keeping the payload small.
All examples use paho-mqtt 2.x and the test broker broker.connectmq.com.
The minimal round trip
import json
import time
import paho.mqtt.client as mqtt
TOPIC = "testmqtt/json-demo/sensor-42/telemetry"
def on_connect(client, userdata, flags, reason_code, properties):
client.subscribe(TOPIC, qos=1)
def on_message(client, userdata, msg):
data = json.loads(msg.payload) # bytes in, dict out
print(msg.topic, data["temperature"], type(data["temperature"]))
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883)
client.loop_start()
time.sleep(1)
reading = {"device_id": "sensor-42", "temperature": 21.5, "humidity": 48.2}
info = client.publish(TOPIC, json.dumps(reading), qos=1)
info.wait_for_publish()
time.sleep(1)
client.loop_stop()
client.disconnect()Three facts explain most beginner bugs:
json.dumps()returns astr; paho encodes strings as UTF-8 before sending. Passing the dict itself fails withTypeError: payload must be a string, bytearray, int, float or None.msg.payloadis alwaysbytes.json.loads()accepts UTF-8 bytes, so you do not need.decode()first.- JSON has no tuples, sets,
datetimeorDecimal. Tuples come back as lists; the others raiseTypeError: Object of type datetime is not JSON serializable.
You can watch the messages with the online MQTT client by subscribing to testmqtt/json-demo/#. It shows the payload as text, so you can see exactly what your code put on the wire.
Never let a bad payload kill your subscriber
Anyone with publish rights on a topic can send you anything: a plain string, a truncated message, Latin-1 text from an old device. If json.loads() raises inside on_message, the exception propagates and your network loop stops. Catch the two errors that matter:
import json
import logging
log = logging.getLogger("mqtt")
def on_message(client, userdata, msg):
try:
data = json.loads(msg.payload)
except UnicodeDecodeError:
log.warning("payload on %s is not UTF-8: %r", msg.topic, msg.payload[:50])
return
except json.JSONDecodeError as exc:
log.warning("invalid JSON on %s: %s (%r)", msg.topic, exc, msg.payload[:50])
return
if not isinstance(data, dict):
log.warning("expected an object on %s, got %s", msg.topic, type(data).__name__)
return
handle_reading(msg.topic, data)The isinstance check matters more than it looks: 42, "ok" and null are all valid JSON, and code that does data["temperature"] will crash on them with a TypeError. The typical messages you will see in logs, such as Expecting value: line 1 column 1 (char 0) for an empty or non-JSON payload, are explained in MQTT JSON parse errors.
Validate the structure with pydantic
Catching syntax errors is not enough: {"temperature": "hot"} parses fine. A schema makes the contract explicit, and pydantic v2 can parse and validate bytes in one step:
from datetime import datetime
from pydantic import BaseModel, Field, ValidationError
class Reading(BaseModel):
device_id: str = Field(min_length=1, max_length=64)
ts: datetime
temperature: float = Field(ge=-50, le=150)
humidity: float | None = Field(default=None, ge=0, le=100)
def on_message(client, userdata, msg):
try:
reading = Reading.model_validate_json(msg.payload)
except ValidationError as exc:
log.warning("rejected message on %s: %s", msg.topic, exc.errors(include_url=False))
return
store(reading) # reading.ts is a real datetime, reading.temperature a floatOn the publishing side, the same model serializes itself, including the datetime:
from datetime import datetime, timezone
reading = Reading(device_id="sensor-42", ts=datetime.now(timezone.utc), temperature=21.5)
client.publish("testmqtt/json-demo/sensor-42/telemetry", reading.model_dump_json(), qos=1)Sharing one model module between publisher and subscriber is the cheapest form of contract testing: if someone renames a field, the test suite fails before the devices do.
Dates, decimals and other awkward types
If you are not using pydantic, tell json.dumps() how to handle types it does not know:
| Python type | Problem | Common fix |
|---|---|---|
datetime | Not serializable | dt.isoformat() or a Unix timestamp (int(dt.timestamp())) |
Decimal | Not serializable | str(value) to keep precision, or float(value) |
set | Not serializable | sorted(value) |
bytes | Not serializable | Base64 string, or send binary as a separate message |
float("nan") | Serialized as NaN, which is not valid JSON | Replace with None; use allow_nan=False to catch it |
import json
from datetime import datetime
from decimal import Decimal
def default(obj):
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, Decimal):
return str(obj)
if isinstance(obj, set):
return sorted(obj)
raise TypeError(f"Cannot serialize {type(obj).__name__}")
payload = json.dumps(data, default=default, allow_nan=False)The NaN row is a real trap: Python happily produces {"t": NaN}, but JavaScript's JSON.parse, Go and most other strict parsers reject it. allow_nan=False turns that into a ValueError on the publisher, where it is easy to fix.
Compact encoding
Every byte counts on cellular links and battery devices, and MQTT charges you for the payload on every hop. Here is the same reading encoded four ways (sizes measured with Python 3):
| Encoding | Bytes |
|---|---|
json.dumps(d, indent=2) | 166 |
json.dumps(d) (default separators) | 152 |
json.dumps(d, separators=(",", ":")) | 141 |
| Short keys, Unix timestamp, rounded floats | 62 |
The first three use the full keys device_id, timestamp (an ISO string with microseconds and offset), temperature, humidity, battery and status. The last one is {"id":"sensor-42","ts":1792229400,"t":21.46,"h":48.2,"b":0.87}. The changes that make the difference:
- Compact separators remove the spaces after
,and:. - Short keys are the biggest win for small messages; document them in your schema.
- Unix timestamps instead of ISO strings save around 20 bytes each.
- Round floats to the precision your sensor actually has.
21.456789carries noise, not information. ensure_ascii=Falsekeeps non-ASCII text as UTF-8 ("Zürich") instead ofüescapes, which use 6 bytes instead of 2.
For speed and convenience, orjson is a drop-in option: it produces compact output by default, returns bytes (which paho accepts directly) and serializes datetime natively.
import orjson
client.publish(TOPIC, orjson.dumps(reading_dict), qos=1)
def on_message(client, userdata, msg):
data = orjson.loads(msg.payload) # raises orjson.JSONDecodeError (a ValueError subclass)Check the full packet size, including topic and headers, with the MQTT packet size calculator. If compact JSON is still too large, binary formats are the next step; see CBOR and MessagePack over MQTT and Protobuf payloads.
Tell subscribers it is JSON (MQTT 5)
MQTT 5 lets the publisher label the payload, so generic tools and other services do not have to guess:
import json
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
client.connect("broker.connectmq.com", 1883)
client.loop_start()
props = Properties(PacketTypes.PUBLISH)
props.ContentType = "application/json"
props.PayloadFormatIndicator = 1 # 1 = UTF-8 text
info = client.publish("testmqtt/json-demo/sensor-42/telemetry",
json.dumps({"temperature": 21.5}), qos=1, properties=props)
info.wait_for_publish()On the receiving side, read msg.properties.ContentType (check with hasattr first, because it is only present when the publisher set it). With the payload format indicator set to 1, an MQTT 5 broker may reject payloads that are not valid UTF-8; more on that in MQTT UTF-8 encoding errors.
Test your JSON handling
Before you trust a subscriber, send it the messages that break things. With mosquitto_pub (or the online client):
T=testmqtt/json-demo/sensor-42/telemetry
H=broker.connectmq.com
mosquitto_pub -h $H -t $T -m '{"device_id":"sensor-42","ts":"2026-10-17T09:30:00Z","temperature":21.5}'
mosquitto_pub -h $H -t $T -m 'hello' # not JSON
mosquitto_pub -h $H -t $T -n # empty payload
mosquitto_pub -h $H -t $T -m '[1,2,3]' # valid JSON, wrong shape
mosquitto_pub -h $H -t $T -m '{"temperature":"hot"}' # wrong type
printf '{"city":"Z\xfcrich"}' | mosquitto_pub -h $H -t $T -s # Latin-1, not UTF-8The subscriber should store the first message and log a clear warning for each of the others, while staying connected. Put the same cases into a unit test that calls your on_message function directly with a fake message object, and you will catch regressions without a broker at all.
Inspect the JSON payloads in the online client
JSON over MQTT is simple once you treat the payload as untrusted bytes: serialize explicitly, parse defensively, validate against a schema and keep it compact. For the bigger design questions such as envelopes, timestamps and topic layout, continue with MQTT payload design best practices.
Frequently asked questions
Can I pass a Python dict directly to paho-mqtt publish()?
No. paho accepts str, bytes, bytearray, int, float or None and raises TypeError: payload must be a string, bytearray, int, float or None. Serialize first with json.dumps(data) or orjson.dumps(data).
Do I need to decode msg.payload before json.loads()?
No. msg.payload is bytes and json.loads() accepts UTF-8 bytes directly. Decoding first with .decode() only helps if you want to log the text or handle UnicodeDecodeError separately.
How do I make JSON MQTT messages smaller?
Use compact separators (separators=(',', ':')), short keys, rounded floats and Unix timestamps instead of ISO strings. If that is still too large, switch to a binary format such as CBOR, MessagePack or Protobuf.
