Lua idioms, style and common pitfalls

Write idiomatic Lua, avoid global leaks and silent coercions, and review someone else's Lua quickly and confidently.

Idioms worth adopting

-- 1. declare everything local; the global table is a shared namespace
local function process(items)
  local results = {}                  -- not the global 'results'
  for i = 1, #items do
    results[i] = items[i] * 2
  end
  return results
end

-- 2. options tables instead of long positional argument lists
local function connect(opts)
  opts = opts or {}
  local host = opts.host or "127.0.0.1"
  local port = opts.port or 8080
  local retry = opts.retry
  if retry == nil then retry = 3 end   -- explicit, because false is a valid value
  return host, port, retry
end
connect({ host = "db", retry = false })

-- 3. return nil plus a message for an expected failure
local function parsePort(s)
  local n = tonumber(s)
  if not n then return nil, "not a number: " .. tostring(s) end
  if n < 1 or n > 65535 then return nil, "out of range: " .. n end
  return n
end

local port, err = parsePort("abc")

-- 4. early return over deep nesting
local function classify(n)
  if type(n) ~= "number" then return "invalid" end
  if n < 0 then return "negative" end
  if n == 0 then return "zero" end
  return "positive"
end

-- 5. metatables for defaults, not for everything
local config = setmetatable({}, { __index = function(_, k) return DEFAULTS[k] end })

-- 6. a module is a local table plus a single return
-- 7. use '#' only on a true sequence, and ipairs only when you need the index
  • Locals are free of the global-table write and read cost, and they avoid colliding with another module's name.
  • An options table is self-documenting at the call site and can grow without breaking existing callers.
  • Returning nil, message is idiomatic for a recoverable condition; raising is for a broken precondition.
  • Guard clauses keep the body at one indentation level, which matters in a language with no braces to mark the end of a block.

The classic mistakes

MistakeWhat happensFix
A typo'd globalSilently nil, then a confusing error laterPrefer locals; use a linter that flags globals
t[#t + 1] = nilThe length does not changeAssign only real values, or track the size
a = b on tablesBoth names refer to the same tableCopy the fields with a loop
0 as an array indexInvisible to ipairs and #Start at 1
"10" + 1Coerces to 11 silentlyConvert explicitly with tonumber
Comparing a table to itself== is identity, not valueCompare fields, or write __eq
Missing local in a blockThe variable lives on as a globalRun a linter; enable a strict mode
-- 1. the "unexpectedly global" trap: no error, just a new global
local function register(name)
  id = name                 -- WRONG: creates a global 'id'
  return id
end
-- a strict mode turns this into an error
local strict = function()
  setmetatable(_G, {
    __newindex = function(_, k, v) error("global write: " .. tostring(k), 2) end,
    __index    = function(_, k) error("global read: " .. tostring(k), 2) end,
  })
end

-- 2. table.copy vs assignment
local original = { 1, 2, 3 }
local alias = original                    -- same table
local copy = {}
for i = 1, #original do copy[i] = original[i] end
copy[1] = 99
print(original[1], copy[1])               --> 1   99

-- 3. numeric coercion hides bugs
print("10" + 1)                           --> 11
print("10" == 10)                         --> false   (different types!)
print(tonumber("10") == 10)               --> true

-- 4. string comparison is byte-wise, not locale-aware
print("Z" < "a")                          --> true, because 90 < 97

-- 5. nil in a table constructor truncates
print(#{ 1, nil, 3 })                     -- 3 or 1, undefined: do not do this

-- 6. the colon versus dot mistake
local obj = { n = 5 }
function obj.get(self) return self.n end
print(obj:get())                          --> 5
print(obj.get(obj))                       --> 5
-- print(obj.get())  -> attempt to index a nil value
⚠️
Lua's silent behaviours are a design choice for embedding, not a safety net for applications. Run a linter such as luacheck in CI, with the unused-variable and global checks on, and it will catch the typo'd local that would otherwise cost an afternoon.

Reviewing Lua quickly

  • Scan for missing local. Every assignment to a bare name in a function body is either a deliberate global or a bug, and the latter is far more common.
  • Check every # and ipairs against a table that may have nil holes. Ask whether the data is guaranteed to be a sequence.
  • Look for .. inside a loop. It is the single most common performance problem in Lua, and the fix is always the same table-plus-concat.
  • Confirm that every io.open result is checked and every opened file is closed, including on the error path.
  • Check that pcall results are inspected. A pcall whose first return value is ignored is an error swallow.
  • Verify that any pairs loop whose order reaches the output sorts its keys first.
  • Look for a module that returns nothing and relies on globals, or that mutates _G.
-- the shape of a file that is easy to review
local M = {}

local DEFAULTS = { timeout = 5, retries = 3 }
local unpack = table.unpack or unpack

local function check(opts)                 -- private helper at the top
  assert(type(opts) == "table", "opts must be a table")
  return opts
end

function M.configure(opts)
  check(opts)
  local merged = setmetatable({}, { __index = DEFAULTS })
  for k, v in pairs(opts) do merged[k] = v end
  M.opts = merged
  return merged
end

function M.describe()
  if not M.opts then return nil, "not configured" end
  return ("timeout=%d retries=%d"):format(M.opts.timeout, M.opts.retries)
end

return M

FAQ

Should I use a strict-mode library?
Yes in development and CI. It converts the silent creation of a global into a clear error, which is the single most valuable bug check available in Lua. Turn it off, or keep it to reads, in production if the cost matters.
Is goto acceptable?
goto exists from Lua 5.2 and is fine for a continue-style jump at the end of a loop body or for a single cleanup label. Deep use of it makes control flow hard to follow, which is exactly what Lua's small syntax was meant to avoid.

The standard library in daily use Performance, garbage collection and LuaJIT

Last refreshed 2026-09-18.