luaguides

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