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 your own logging patterns or reach for a library. This guide covers both approaches, starting with the simplest possible logger and working up to production-grade logging patterns that handle rotation, structured formats, and multi-module filtering.
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 during development, print() is fine—it requires no setup and works everywhere Lua runs. But the moment you need to keep logs across sessions, filter by severity, or ship logs to a file that survives a crash, print() stops being adequate. The output goes to stdout and vanishes when the terminal closes. There is no timestamp, no level, no structured format. To go beyond print(), you need to write to a file.
Writing to a log file
The standard approach: open a file handle and write to it. This works in plain Lua with no dependencies. The key function is io.open with mode "a" (append), which creates the file if it does not exist and writes to the end if it does. Each call to log opens the file, writes a timestamped line, then closes it—simple but not efficient for high-volume logging.
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 because each io.open call involves a system call to the filesystem, and each io.close flushes the buffer. For a low-traffic application this overhead is negligible, but in a hot path—say, logging every HTTP request in a web server—the constant open/close cycle adds measurable latency. A better pattern keeps the file handle open for the lifetime of the application and reuses it across log calls. The trade-off is that you need to manage the handle’s lifecycle: you must call close explicitly when the application shuts down, otherwise buffered data may be lost.
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 to disk immediately rather than sitting in the stdio buffer. In the event of a crash, buffered data may be lost before it reaches the file, so flush() gives you durability at the cost of a small performance hit. Remove it for better throughput when you do not need immediate writes—batch processing scripts and data pipelines can safely buffer hundreds of lines before flushing.
Log Levels
A minimal log level system lets you filter output by severity at runtime. The idea is simple: assign a numeric threshold to each severity label, and skip any log call whose level falls below the current threshold. This means you can run at DEBUG verbosity during development and switch to WARN in production without touching a single log statement in your code. The level table acts as both an enum for readability and a comparison target for the filtering logic.
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. You could expose this through an environment variable, a configuration file, or even a signal handler that toggles the level for live debugging. The important thing is that the filtering logic is centralised: every log call goes through the same gate, and changing one variable changes the behaviour globally.
A simple structured logger
Combine timestamp, level, and message into a consistent format using a reusable Logger object. Rather than passing level values manually on every call, this approach wraps the formatting logic inside convenience methods (debug, info, warn, error) that do the right thing automatically. The metatable pattern gives you method-call syntax (log:info("message")) without duplicating the formatting code. Under the hood, each convenience method delegates to a single log method that handles the timestamp formatting and file I/O, keeping the code DRY and the output format consistent across all log levels.
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 is straightforward: require the module, create a logger instance with a file path and minimum level, then call the convenience methods. Each method delegates to the internal log function that handles timestamp formatting, level labelling, and file I/O. The logger must be explicitly closed when your application shuts down—the close method flushes and closes the underlying file handle, preventing data loss from buffered writes that have not yet reached disk.
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, but at some point you will want features that are tedious to build from scratch: automatic log rotation, multiple output sinks, configurable formatting, and asynchronous writing. When you hit that point, LuaLogging is the standard choice in the Lua ecosystem.
LuaLogging
LuaLogging (luarocks.org/modules/tieske/lualogging) is the most widely-used logging library for Lua. It draws from log4j’s architecture and supports multiple appenders that you can mix and match. Install it through LuaRocks, then create a logger and attach the appenders you need:
luarocks install lualogging
Once installed, you create a logger instance by calling LuaLogging.new() and then attach one or more appenders to it. Each appender represents a single output destination—console, file, rolling file, or network socket—and you can add as many as you need. A single log message is dispatched to every attached appender, so you can simultaneously print to the terminal for development, write to a file for archival, and send to a socket for real-time monitoring. The console appender is ideal during development and for Docker-based deployments where container stdout is captured by the orchestration layer. The rolling file appender handles disk management automatically: you specify a file path, a date format string for rotated file names, and the maximum number of old files to keep.
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 simultaneously—useful when you want terminal feedback during development alongside persistent file storage. The combination of appenders is additive: each call to logger:info() triggers every registered appender in the order they were added.
Log levels are configurable per logger instance via setLevel. The library defines standard levels (DEBUG, INFO, WARN, ERROR, FATAL) that mirror log4j conventions. Messages below the configured level are silently dropped by the logger before they reach any appender, which means the filtering happens early and cheaply—no string formatting or I/O is wasted on suppressed messages.
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—Lua’s default tostring() on a table just returns something like table: 0x7f8b1c0009a0, which is useless for debugging. Penlight’s pl.pretty module handles this cleanly, formatting nested tables with proper indentation and showing both keys and values in a human-readable layout. The pretty.write() function returns a formatted string that you can pass directly to your logger, and it handles deeply nested structures, mixed key types, and circular references gracefully.
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 well enough for simple structures. This approach walks the table recursively, handling strings, numbers, and nested tables with consistent indentation. The recursion uses pairs to iterate, so it handles both array-style and dictionary-style keys. The output is not as polished as Penlight’s, but it requires no external dependencies and works in any Lua environment, including embedded contexts where installing LuaRocks packages is impractical. For production use, prefer a dedicated serialiser to handle edge cases like cycles and metatables.
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. The core idea is simple: check the current log file’s size before each write, and if it exceeds a threshold, rename the current file to a timestamped archive name and start a fresh log. With plain Lua, the seek("end") call on the file handle returns the current size in bytes without needing platform-specific APIs. This approach works across all operating systems where Lua runs.
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 if your application has an event loop. For high-throughput systems, checking the file size on every write adds unnecessary syscall overhead; in those cases, check every N writes or every N seconds instead. The os.rename operation is atomic on most filesystems, so you will not lose log lines during the rotation window—the old file is renamed, and the next io.open call creates a fresh file automatically.
Log output formats
Text format is readable by humans and easy to grep. But structured formats enable much more powerful analysis. JSON is better for machine consumption and log aggregation tools like Elasticsearch, Loki, or CloudWatch. When every field has a known key, you can query logs with precision: find all ERROR-level events from a specific IP address within a time window, or count login attempts grouped by user agent. The trade-off is that JSON logs are harder to scan with the naked eye, so many teams use both: text during development, JSON in production.
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. The hand-rolled version shown here handles strings and numbers but misses edge cases like nested objects, arrays, booleans, nil values, and proper string escaping. Libraries like dkjson or lua-cjson handle all of these correctly and are well-tested. The quick encoder is fine for prototyping, but structured logging in production demands correct JSON—a single malformed log line can break downstream parsing pipelines and cause log loss in aggregators.
Filtering by Module
Tag log entries with the module that produced them so that a single log file can serve an entire application without losing traceability. The module name becomes a prefix in square brackets, making it trivial to filter with grep or a log viewer’s search bar. Each module gets its own logger instance created by module_logger, and each instance captures the module name as an upvalue—no global state, no string concatenation at every call site.
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
- /guides/lua-luarocks-guide/: install and manage Lua modules including LuaLogging and JSON libraries