Logging Patterns and Frameworks in Lua
Lua has no built-in logging module. print() goes to stdout and disappears. For anything serious — timestamps, log levels, file rotation, structured output — you need to build it yourself or reach for a library. This guide covers both approaches.
The print() Baseline
print() is the simplest logging tool. It calls tostring() on values, making it flexible but noisy:
print("user logged in", userId, os.date())
-- user logged in 42 2026-04-22 10:03:25
For quick debugging, print() is fine. For anything that outlasts a single session, you need more.
Writing to a Log File
The standard approach: open a file handle and write to it. This works in plain Lua with no dependencies:
local function log(msg)
local file = io.open("app.log", "a")
if file then
file:write(os.date("%Y-%m-%d %H:%M:%S") .. " " .. msg .. "\n")
file:close()
end
end
log("user logged in")
Opening and closing the file on every call is slow. A better pattern keeps the handle open:
local logfile
local function init_log(path)
logfile = io.open(path, "a")
end
local function log(msg)
if logfile then
logfile:write(os.date("%Y-%m-%d %H:%M:%S") .. " " .. msg .. "\n")
logfile:flush()
end
end
init_log("app.log")
log("startup complete")
flush() ensures the line is written immediately, not buffered. Remove it for better performance when you don’t need immediate writes.
Log Levels
A minimal log level system lets you filter output by severity:
local LEVELS = { DEBUG = 1, INFO = 2, WARN = 3, ERROR = 4 }
local current_level = LEVELS.INFO
local function log(level_name, level_value, msg)
if level_value >= current_level then
local file = io.open("app.log", "a")
if file then
file:write(string.format("%s [%s] %s\n",
os.date("%Y-%m-%d %H:%M:%S"), level_name, msg))
file:close()
end
end
end
log("DEBUG", LEVELS.DEBUG, "entering function")
log("INFO", LEVELS.INFO, "request received")
log("WARN", LEVELS.WARN, "rate limit approaching")
log("ERROR", LEVELS.ERROR, "connection failed")
Change current_level at runtime to control verbosity without restarting the application.
A Simple Structured Logger
Combine timestamp, level, and message into a consistent format:
local Logger = {}
Logger.__index = Logger
function Logger.new(options)
local file = io.open(options.path, "a")
if not file then return nil, "cannot open log file" end
return setmetatable({
file = file,
level = options.level or 2, -- default INFO
}, Logger)
end
function Logger.log(self, level_name, msg)
local line = string.format("%s [%s] %s",
os.date("%Y-%m-%d %H:%M:%S"), level_name, msg)
self.file:write(line .. "\n")
self.file:flush()
end
function Logger.debug(self, msg) self:log("DEBUG", msg) end
function Logger.info(self, msg) self:log("INFO", msg) end
function Logger.warn(self, msg) self:log("WARN", msg) end
function Logger.error(self, msg) self:log("ERROR", msg) end
function Logger.close(self)
self.file:close()
end
return Logger
Usage:
local Logger = require("logger")
local log = Logger.new({ path = "app.log", level = 2 })
log:info("server listening on port 8080")
log:error("bind failed: address in use")
log:close()
This pattern is clean enough for most applications. If you need rotation, JSON output, or multiple sinks, look at a library.
LuaLogging
LuaLogging (luarocks.org/modules/tieske/lualogging) is the most widely-used logging library for Lua. It draws from log4j and supports multiple appenders:
luarocks install lualogging
local LuaLogging = require("logging")
local logger = LuaLogging.new()
-- Write to console
logger:addAppender(LuaLogging.appenders.console())
-- Write to rotating file (10MB max, 3 files kept)
logger:addAppender(LuaLogging.appenders.rolling_file("app.log", "%Y%m%d", 3))
logger:info("application started")
logger:warn("disk space low")
logger:error("database unreachable")
Available appenders include console, file, rolling_file, and socket (TCP/UDP). You can add multiple appenders to send the same message to several outputs.
Log levels are configurable per logger:
local logger = LuaLogging.new()
logger:setLevel(LuaLogging.level.WARN)
logger:debug("this is hidden")
logger:warn("this appears") -- WARN
logger:error("this appears") -- ERROR
Pretty-Printing Tables
Logging tables requires converting them to strings first. Penlight’s pl.pretty does this cleanly:
local pretty = require("pl.pretty")
local state = { users = { {id = 1, name = "Alice"}, {id = 2, name = "Bob"} } }
print(pretty.write(state))
--[[
{
users = {
{ id = 1, name = "Alice" },
{ id = 2, name = "Bob" }
}
}
]]
For raw tables without Penlight, a recursive string builder works:
local function dump(val, indent)
indent = indent or ""
if type(val) == "string" then
return string.format("%q", val)
elseif type(val) == "table" then
local lines = {}
for k, v in pairs(val) do
table.insert(lines, string.format("%s[%s] = %s",
indent .. " ", tostring(k), dump(v, indent .. " ")))
end
return "{\n" .. table.concat(lines, "\n") .. "\n" .. indent .. "}"
else
return tostring(val)
end
end
log("state: " .. dump(state))
Log Rotation
For long-running applications, a rolling file appender prevents disk space exhaustion. With plain Lua:
local function roll_log(path, max_size)
local f = io.open(path, "r")
if f then
local size = f:seek("end")
f:close()
if size >= max_size then
os.rename(path, path .. "." .. os.date("%Y%m%d%H%M%S"))
end
end
end
roll_log("app.log", 10 * 1024 * 1024) -- 10MB threshold
Call this before each write, or check on a timer.
Log Output Formats
Text format is readable. JSON is better for machine consumption and log aggregation tools:
local function json_log(level, msg, extra)
local entry = {
ts = os.date("!%Y-%m-%dT%H:%M:%SZ"),
level = level,
msg = msg,
}
for k, v in pairs(extra or {}) do
entry[k] = v
end
-- quick and dirty JSON
local parts = {}
for k, v in pairs(entry) do
table.insert(parts, string.format("%q=%q", k,
type(v) == "string" and v or tostring(v)))
end
return "{" .. table.concat(parts, ",") .. "}"
end
print(json_log("INFO", "login", {user_id = 42, ip = "192.168.1.1"}))
-- {"ts":"2026-04-22T10:03:00Z","level":"INFO","msg":"login","user_id":"42","ip":"192.168.1.1"}
For production JSON, use a proper JSON encoder rather than the quick-and-dirty approach above.
Filtering by Module
Tag log entries with the module that produced them:
local function module_logger(module_name)
return {
info = function(msg) log("[" .. module_name .. "] " .. msg) end,
error = function(msg) log("[" .. module_name .. "] " .. msg) end,
}
end
local db_log = module_logger("database")
db_log:info("query executed in 12ms")
-- 2026-04-22 10:03:25 [database] query executed in 12ms
This makes it easy to find which part of the codebase produced a given log line.
Gotchas
io.open in append mode does not create directories. If your log path includes a directory that doesn’t exist, io.open returns nil. Create directories first with os.execute("mkdir -p dir") or LFS.
Long-running processes need log rotation. Without it, log files grow until disk is full. Set a size or date threshold and archive old logs.
print() goes to stdout, log files may be buffered. Use file:flush() for immediate writes, or set io.stdout:setvbuf("no") for line buffering on stdout.
Levels beyond WARN and ERROR often go unused. DEBUG is tempting but clutters production logs. Set it at DEBUG level during development, switch to INFO or WARN in production.
See Also
- /guides/lua-config-files/ — load configuration at startup before logging begins
- /guides/lua-serialization/ — serialize Lua tables to JSON or other formats for machine-readable logs
- /guides/lua-error-handling-patterns/ — combine error handling with logging for failure reporting that includes stack traces