A good smart home MQTT topic layout is boring and predictable: one branch per room, one per device, and a fixed set of leaf topics for state, commands and availability. This article proposes a concrete layout you can copy, explains why commands and state live on separate topics, which messages to retain, and how the layout maps to Home Assistant discovery and broker ACLs.
home/<room>/<device>/state current state, JSON, retained
home/<room>/<device>/set commands to the device, NOT retained
home/<room>/<device>/availability "online" / "offline", retained, Last Will
home/<room>/<device>/event one-off events (button press), NOT retainedNaming rules
Topic names are part of your home's API. Changing them later means touching every automation, so decide the rules once:
- Lowercase ASCII, words joined by
-:living-room,ceiling-light. No spaces, no umlauts, no emoji. - No leading slash.
/home/kitchencreates an empty first level and does not matchhome/#. - Never start with
$: that prefix is reserved for broker topics such as$SYS. - Nouns for places and things, fixed words for leaves:
state,set,availability,event. Do not mixstatus,cmdandcommandacross devices. - Keep the depth constant. Every device sits exactly at
home/<room>/<device>, sohome/+/+/statealways means "every device's state".
More general guidance is in MQTT topic design best practices.
Room-based or ID-based?
| Style | Example | Good for | Trade-off |
|---|---|---|---|
| Room-based | home/kitchen/ceiling-light/state | Typical homes, hand-written automations | Moving a device changes its topics |
| ID-based | home/device/shelly-a8032a/state | Large or frequently changing installations | Room must be kept elsewhere (metadata, HA areas) |
| Hybrid | home/kitchen/shelly-a8032a/state | Unique names with readable grouping | Still changes when moved |
For most homes, room-based wins on readability. If you have devices that move (a portable heater, a sensor on a cart), give them a stable ID and put them under a neutral branch such as home/mobile/.
Commands vs state
The most important rule: the device publishes state, everyone else publishes commands, and they never share a topic.
app ──PUBLISH home/kitchen/ceiling-light/set {"state":"ON"} (not retained)
light ──applies the command, then
light ──PUBLISH home/kitchen/ceiling-light/state {"state":"ON","brightness":255} (retained)
app, HA ◀─ receive the new state and update the UIWhy separate them?
- The state topic always reflects reality. If the command fails (relay stuck, device offline), the state topic still shows the truth, and the UI does not lie.
- No echo loops. A device subscribed to its own state topic would receive its own updates. With
setandstateapart, each side only subscribes to what it needs. - Safe ACLs. A dashboard user can be allowed to write
.../setand read.../statewithout being able to fake the device's state.
The device should publish the new state after applying the command, even if nothing changed. That confirmation is what UIs and automations wait for. For request/response patterns with correlation IDs, see command and control over MQTT.
What to retain
| Topic | Retain? | Why |
|---|---|---|
.../state | Yes | A dashboard that opens later shows current state immediately |
.../availability | Yes | New subscribers know which devices are offline |
.../set | No | A retained command replays on every reconnect: ghost switching after a power cut |
.../event | No | "Button pressed" is only meaningful at the moment it happens |
homeassistant/.../config | Yes | Discovery configs must survive Home Assistant restarts |
If a command was retained by mistake, clear it by publishing an empty retained message to the same topic. Details in retained messages.
Availability with Last Will
Each device sets a Last Will on its availability topic when it connects: payload offline, retained, QoS 1. Right after connecting it publishes online, retained. If the device loses power or Wi-Fi, the broker publishes offline once the keep-alive expires, about 1.5 × the keep-alive interval. See MQTT Last Will.
A device in 40 lines
This Python script (paho-mqtt 2.x) behaves like a dimmable light that follows the layout. The examples use a testmqtt/ prefix so they are safe to run on a shared test endpoint; in your house you drop it.
import json, os
import paho.mqtt.client as mqtt
BASE = "testmqtt/home/kitchen/ceiling-light"
state = {"state": "OFF", "brightness": 0}
def publish_state(client):
client.publish(f"{BASE}/state", json.dumps(state), qos=1, retain=True)
def on_connect(client, userdata, flags, reason_code, properties):
client.publish(f"{BASE}/availability", "online", qos=1, retain=True)
client.subscribe(f"{BASE}/set", qos=1)
publish_state(client)
def on_message(client, userdata, msg):
try:
cmd = json.loads(msg.payload)
except ValueError:
cmd = {"state": msg.payload.decode(errors="replace").strip().upper()}
if not isinstance(cmd, dict):
cmd = {}
if cmd.get("state") in ("ON", "OFF"):
state["state"] = cmd["state"]
if isinstance(cmd.get("brightness"), int):
state["brightness"] = max(0, min(255, cmd["brightness"]))
publish_state(client) # always confirm with the real state
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="kitchen-ceiling-light")
if os.getenv("MQTT_USER"):
client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS"))
client.will_set(f"{BASE}/availability", "offline", qos=1, retain=True)
client.on_connect = on_connect
client.on_message = on_message
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_forever()Publishing state in on_connect matters: after every reconnect the retained state is refreshed, and subscriptions are re-created even when the broker kept no session.
Test the layout
Watch every device's state and availability in one terminal:
mosquitto_sub -h broker.connectmq.com -p 1883 -v \
-t 'testmqtt/home/+/+/state' -t 'testmqtt/home/+/+/availability'Send commands from another:
mosquitto_pub -h broker.connectmq.com -p 1883 -q 1 \
-t 'testmqtt/home/kitchen/ceiling-light/set' -m '{"state":"ON","brightness":180}'
mosquitto_pub -h broker.connectmq.com -p 1883 -q 1 \
-t 'testmqtt/home/kitchen/ceiling-light/set' -m 'off'Then check the behaviours that the layout promises:
- Every command produces exactly one state message.
- Restart terminal 1: the current state and
onlineappear immediately (retained). - Kill the script with
kill -9: after the keep-alive timeout,offlineappears. - Restart the script: no command is replayed, because
setwas never retained.
You can do the same in the browser with the online MQTT client, and check any subscription pattern against your topics with the topic matcher.
Useful subscriptions
| Need | Subscription |
|---|---|
| Everything in the house | home/# |
| Everything in the kitchen | home/kitchen/# |
| State of every device | home/+/+/state |
| Which devices are offline | home/+/+/availability |
| All button presses | home/+/+/event |
Because the depth is constant, single-level + wildcards are enough and you rarely need # outside debugging. See MQTT wildcards in depth.
Home Assistant discovery
Home Assistant can create entities automatically from retained config messages under homeassistant/<component>/<object_id>/config. The layout maps directly onto the discovery fields:
{
"name": "Kitchen ceiling light",
"unique_id": "kitchen_ceiling_light",
"state_topic": "home/kitchen/ceiling-light/state",
"value_template": "{{ value_json.state }}",
"command_topic": "home/kitchen/ceiling-light/set",
"payload_on": "ON",
"payload_off": "OFF",
"availability_topic": "home/kitchen/ceiling-light/availability"
}payload_available and payload_not_available default to online and offline, which is exactly what the Last Will above publishes. For the full integration setup see Home Assistant and MQTT.
ACLs that follow the layout
A consistent tree makes permissions short:
| Client | Write | Read |
|---|---|---|
| Each device | its own state, availability, event | its own set |
| Home Assistant / automation hub | home/+/+/set, homeassistant/# | home/# |
| Wall tablet dashboard | home/+/+/set | home/+/+/state, home/+/+/availability |
| Guest or monitoring user | nothing | home/+/+/availability |
One credential per device means a compromised smart plug can only lie about itself, not switch your front-door lock.
The layout costs nothing to adopt and pays off with every new device: constant depth, state and set split, retained state and availability, commands never retained. Write the rules down, test each new device against the four checks above, and your automations will keep working when the house grows.
Frequently asked questions
Should MQTT command topics be retained?
No. A retained command is replayed to the device every time it reconnects, so a light can switch on by itself after a power cut. Retain state and availability; send commands without the retain flag.
Should the room be part of the topic?
For a typical home, yes: home/kitchen/ceiling-light reads well and lets you subscribe to a whole room with home/kitchen/#. The downside is that moving a device changes its topics. Larger installations often use stable device IDs and keep the room as metadata instead.
One JSON state topic or one topic per property?
A single JSON state message per device is easier to keep consistent and is what Home Assistant's JSON templates expect. Per-property topics are simpler for tiny sensors and some tools. Pick one style and use it for every device.


