Modules, require and package.path

Write modules that return a table, understand how require caches and searches, and avoid circular and global-leaking module patterns.

Writing and requiring a module

-- file: geometry.lua
local M = {}                       -- the conventional module table

local function square(x) return x * x end   -- private, stays local

function M.hypot(a, b)
  return math.sqrt(square(a) + square(b))
end

M.pi = math.pi
M.VERSION = "1.2.0"

return M                           -- return exactly one value: the module

-- file: main.lua
local geometry = require("geometry")        -- searches on package.path
print(geometry.hypot(3, 4))                 --> 5
print(geometry.VERSION)

-- require returns the SAME table every time: modules are singletons
local again = require("geometry")
print(again == geometry)                    --> true

-- a dotted name maps to a directory path with the remaining dots intact
local text = require("util.text")           -- loads util/text.lua
local deep = require("a.b.c")               -- loads a/b/c.lua

-- a submodule of a library looks like a field after the fact
-- require("mylib") sets package.loaded["mylib"], not nested tables
  • Return the module table, and only one value. A module that returns nothing makes every caller use a global, which reintroduces the problem modules exist to solve.
  • Keep helpers local. A module that leaks names into the global table collides with every other module in the process.
  • Do not access a module's own table through the module name from inside itself. Use the local M, which is available before require finishes.
  • require caches the result in package.loaded. A second call returns the cached value without re-running the file.

package.path, searchers and loaders

-- inspect the current search path
print(package.path)
-- ./?.lua;./?/init.lua;/usr/share/lua/5.4/?.lua;/usr/share/lua/5.4/?/init.lua

-- add a project directory at the front so local modules win
package.path = "./src/?.lua;./src/?/init.lua;" .. package.path

-- a module that replaces itself with a function or a table with state
-- file: app.lua
local M = {}
function M.configure(opts) M.opts = opts end
function M.name() return M.opts and M.opts.name or "unnamed" end
return M

-- package.loaded: preload a value to skip the search entirely
package.loaded["config"] = { env = "test" }
local config = require("config")            -- returns the preloaded table
print(config.env)                           --> test

-- force a reload in a test or a hot-reload loop
package.loaded["config"] = nil
require("config")                           -- re-runs the file

-- searchers: how require actually finds things, in order
for i, searcher in ipairs(package.searchers or package.loaders) do
  print(i, type(searcher))
end
-- 1 preload   2 Lua file on package.path   3 C library on package.cpath   4 all-in-one

-- add your own searcher, for example for a virtual filesystem
table.insert(package.searchers, function(name)
  if name == "generated" then
    return function() return { value = 42 } end   -- a loader function
  end
end)
print(require("generated").value)           --> 42
ItemMeaningWhere
? in package.pathThe module name with dots replaced by slashesSearch template
package.loadedThe cache of loaded modulesIndexed by module name
package.preloadLoaders registered by name from CChecked by the first searcher
package.searchersThe ordered list of finders5.2+; called loaders in 5.1
package.cpathWhere native libraries are foundFor modules written in C

A searcher returns either a loader function plus the path it used, or a string explaining why that searcher failed. That string is what makes the final error message list every path that was tried.

Circular dependencies and structure

-- circular requires: A needs B and B needs A
-- file: a.lua
local B = require("b")            -- b.lua runs, requires a, and gets a PARTIAL table
local M = {}
function M.run() return B.helper() end
return M

-- file: b.lua
local A = require("a")            -- receives the incomplete a table, whose run is not set yet
local M = {}
function M.helper() return A.run end   -- capture lazily: do not call at load time
return M

-- the rule: at module load time, only refer to the other module's table.
-- Call its functions later, from your own functions, never at the top level.

-- the cleaner fix: move the shared piece into a third module both can depend on
-- shared.lua  -- types and pure helpers
-- a.lua requires shared, b.lua requires shared, neither requires the other
  • A circular require is not an error in Lua; it silently hands over the partially initialised module. The failure appears later, as an attempt to call a nil field.
  • Design modules as a dependency graph with no cycles and one direction. If two modules need each other, the shared part belongs in a third one.
  • If a cycle is unavoidable, defer the reference: a local function that calls require on first use, or a setter the caller invokes after both modules finish loading.
  • Avoid module state that depends on load order. A module that reads a global configured by another module is order-dependent and hard to test.
  • Use a per-module local for require when you call it often: local insert = table.insert at the top of the file.
⚠️
Never call require with a name built from user input without validating it. A crafted name such as os or io pulls a standard library into your sandbox, and on a path-based searcher a name containing .. can escape the module directory.

FAQ

Why does require say module not found?
The name did not match package.path after the dot-to-slash substitution. Print package.path, confirm the file name and the case, and remember that a leading ./ template is relative to the current working directory, not to the script.
How do I reload a module during development?
Set package.loaded["name"] = nil and require it again. Any module that captured the old table still holds the old version, so a true hot reload needs to clear dependents as well.

Error handling: error, pcall and xpcall Environments, sandboxing and bytecode

Last refreshed 2026-09-18.