Lua for MQTT and IoT Devices
MQTT sits at the intersection of simple and capable. It’s a lightweight messaging protocol that works well when bandwidth is limited, network is unreliable, and devices need to exchange data with minimal overhead. If you’re building IoT projects in Lua, MQTT is probably the right tool for the job.
This guide walks through the core MQTT concepts, then gets you publishing sensor data, subscribing to commands, and handling the gotchas that come with production deployments.
How MQTT works
MQTT follows a publish/subscribe model. Devices send messages to a broker, which fans those messages out to any subscribers listening on matching topics.
Three actors make this work:
- Publisher — sends a message to a topic
- Subscriber — receives messages from a topic
- Broker — routes messages between publishers and subscribers
Topics are hierarchical strings separated by slashes. A temperature reading from your office might go to home/office/temperature. A motion sensor in the hallway might publish to home/hallway/motion.
You can use wildcards when subscribing. The plus sign (+) matches a single level: home/+/temperature catches home/office/temperature and home/kitchen/temperature. The hash sign (#) matches everything below a level and must appear at the end of a subscription: home/# catches everything under home/.
Quality of service levels
MQTT defines three QoS levels that control delivery guarantees:
QoS 0 — At most once. The publisher sends the message once and doesn’t wait for acknowledgment. If it’s lost in transit, it’s gone. This works for sensor readings where a lost reading every now and then doesn’t matter. You get the lowest overhead.
QoS 1 — At least once. The publisher retries until it gets confirmation that the broker received the message. The message will arrive, but it might arrive more than once. Use this for commands that must get through: turning on a light, triggering a motor.
QoS 2 — Exactly once. A four-way handshake ensures the message arrives exactly once and neither party is left unsure. This adds the most overhead but is necessary for critical operations like configuration changes or billing events. Most IoT devices don’t need this level.
Here’s the part that trips people up: QoS is negotiated in two directions independently. When your publisher sends to the broker, that’s one QoS. When the broker forwards to your subscriber, that’s another. Your subscriber might only support QoS 0, in which case the broker downgrades the delivery.
The mqtt-lua library only supports QoS 0. This is a hard limitation — the library was designed for constrained devices where minimal overhead matters more than delivery guarantees. If you need QoS 1 or QoS 2, you’ll need a different MQTT library or a different language. For most sensor data and simple commands, QoS 0 works fine. You just accept that occasional messages may be lost.
Connecting with mqtt-lua
The mqtt-lua library handles the client-side MQTT implementation. Install it via luarocks:
luarocks install mqtt-lua
It depends on LuaSocket for TCP and LuaSec if you need TLS. Most examples below work with just LuaSocket.
Create a client by pointing it at a broker hostname and port:
local MQTT = require("paho.mqtt")
local client_id = "sensor-" .. string.format('%04d', math.random(1000, 9999))
local client = MQTT.client.create("test.mosquitto.org", 1883, function(topic, payload)
print("Received: " .. topic .. " = " .. payload)
end)
local error_message = client:connect(client_id)
if error_message then
print("Connection failed: " .. error_message)
return
end
print("Connected to broker")
client_id must be unique across all clients connected to the same broker. If two clients use the same ID, the broker kicks out the older one. Use something like sensor- followed by a random value. The identifier has a maximum length of 23 characters, so keep it short.
The callback function you pass to MQTT.client.create fires whenever a subscribed message arrives. It receives two arguments: the topic and the payload, both as strings.
Publishing sensor data
The classic IoT pattern: read a sensor and publish the value. Here’s a script that reads simulated temperature data and publishes it every two seconds:
local MQTT = require("paho.mqtt")
local socket = require("socket")
local client_id = "temp-" .. string.format('%04d', math.random(1000, 9999))
local client = MQTT.client.create("test.mosquitto.org", 1883, function(topic, payload)
-- Handler required but not used in this publisher-only script
end)
local err = client:connect(client_id)
if err then
print("Connect error: " .. err)
return
end
for i = 1, 5 do
-- Simulate reading a temperature sensor
local temp = 21.5 + (math.random() - 0.5) * 6
local payload = string.format("%.1f", temp)
-- publish(topic, payload, retain)
client:publish("home/office/temperature", payload, true)
print("Published: home/office/temperature = " .. payload)
socket.sleep(2)
end
client:disconnect()
client:destroy()
print("Done.")
Running this produces output like:
Published: home/office/temperature = 19.3
Published: home/office/temperature = 22.1
Published: home/office/temperature = 20.8
Published: home/office/temperature = 23.4
Published: home/office/temperature = 21.0
Done.
The true argument to publish sets the retain flag. This tells the broker to hold the message. Any new subscriber to home/office/temperature immediately receives the last retained value. This is useful when a device connects and needs to know the current state without waiting for the next reading.
Receiving commands
The other direction: subscribing to topics and acting on messages. A device that controls an LED might listen for on/off commands:
local MQTT = require("paho.mqtt")
local client_id = "led-" .. string.format('%04d', math.random(1000, 9999))
local client = MQTT.client.create("test.mosquitto.org", 1883, function(topic, payload)
print("Message on " .. topic .. ": " .. payload)
if topic == "home/led/01/command" then
if payload == "on" then
print("LED activated")
elseif payload == "off" then
print("LED deactivated")
else
print("Unknown command: " .. payload)
end
elseif topic == "home/led/01/brightness" then
local level = tonumber(payload)
if level then
print("Brightness set to " .. level .. "%")
end
end
end)
local err = client:connect(client_id)
if err then
print("Connect error: " .. err)
return
end
-- Announce that we're online
client:publish("device/led-01/status", "online", true)
print("Status published")
-- subscribe takes a table of topics
client:subscribe({ "home/led/01/command", "home/led/01/brightness" })
print("Listening for commands...")
-- handler() processes messages and sends keepalive pings
while true do
local err = client:handler()
if err then
print("Error: " .. err)
break
end
socket.sleep(0.1)
end
client:disconnect()
client:destroy()
The handler() call is central to this library. It processes incoming messages, invokes your callback when messages arrive, and sends keepalive pings when needed. You must call it regularly — at least once per minute, though calling it in a tight loop with small sleeps works fine for reactive devices.
This is a blocking pattern. The while loop runs forever, processing messages as they arrive. If you need to do other work concurrently, look at using Lua coroutines or structuring your code differently.
Retained messages and will messages
Two features worth understanding: retained messages and will messages.
Retained messages persist on the broker. When you publish with retain = true, the broker stores it. New subscribers get it immediately. This is how you propagate state to devices that join late.
To clear a retained message, publish an empty payload with retain = true:
client:publish("home/lamp/mode", "", true)
Publishing with retain = false does not clear the retained message. You have to overwrite it with an empty payload.
Will messages let you announce your death. During connect, you specify a will topic, payload, QoS, and retain flag. If the client disconnects unexpectedly — the broker detects a broken connection — the broker publishes your will message.
local client_id = "sensor-" .. string.format('%04d', math.random(1000, 9999))
local client = MQTT.client.create("test.mosquitto.org", 1883, function(topic, payload)
-- callback
end)
-- connect(identifier, will_topic, will_qos, will_retain, will_message)
local err = client:connect(client_id, "device/status", 0, true, "offline")
if err then
print("Connect error: " .. err)
return
end
Since mqtt-lua only supports QoS 0, the will QoS is always 0. The connect call takes the will parameters as additional arguments after the client identifier.
The will fires only on unexpected disconnects. If you call client:disconnect() cleanly, the will does not fire. When testing will messages, kill the process abruptly or yank the network cable.
Common pitfalls
Blocking the loop. The handler() call is synchronous. If your callback does heavy work, message processing stalls. For simple devices this doesn’t matter. For anything complex, consider a timer-based approach or offload work to a coroutine.
Keepalive defaults. The default keepalive is 60 seconds. If your network is flaky, you might need shorter intervals. Check the broker logs if clients appear to disconnect frequently.
QoS limitation. Remember that this library only supports QoS 0. If you need delivery guarantees, this library won’t provide them. You need a different MQTT implementation.
Client ID collisions. Two devices with the same ID cause one to get kicked. Always generate unique IDs, especially in development. Keep client IDs under 23 characters.
Will not firing on clean disconnect. This catches everyone. Wills only publish on unexpected disconnects. A clean disconnect() call suppresses the will.
Handler frequency. You must call handler() regularly or the broker will think you’re dead. A sleep of 0.1 seconds between calls is reasonable for responsive message handling.
Production checklist
If you’re deploying to real devices, a few things matter:
Use TLS. Unencrypted MQTT on port 1883 is fine for experimentation. For anything real, port 8883 with TLS is the minimum. You’ll need LuaSec installed and a valid CA bundle on the device.
Validate incoming data. Messages from the network are untrusted. Check that payload values are the type and range you expect before acting on them. A command payload of on or off is fine; a payload of DROP TABLE users should be rejected.
Handle connection failures. Network goes down, broker restarts, TLS certificate rotates. Your code should retry with backoff rather than crashing or hanging.
Log meaningfully. When something goes wrong, you need to know which device, what topic, what payload, and when. Structured logs beat print statements.
Clean up resources. Call disconnect() and destroy() when done. On long-running devices, watch for resource leaks if you recreate clients repeatedly.
See also
- Lua Socket Networking — TCP/UDP networking in Lua, useful for understanding what happens under the hood with MQTT
- Lua Error Handling Patterns — Patterns for handling connection failures and retries
- Coroutine Basics — Concurrent patterns in Lua, useful if you need to handle MQTT messages alongside other tasks