luaguides

Filesystem Operations with LuaFileSystem

Lua’s standard io library gives you file reading and writing, but it doesn’t let you create directories, walk through folder contents, or read file metadata. That’s the gap LuaFileSystem fills. Usually installed as lfs, this library wraps POSIX and Windows filesystem calls into a clean Lua API.

It’s the go-to library for any Lua program that needs to work with the filesystem beyond opening files — build scripts, config loaders, project scaffolding tools, and server-side file handlers all tend to depend on it.

Installation

LuaFileSystem is distributed via LuaRocks:

luarocks install luafilesystem

This single command fetches and builds the C extension, linking it against the Lua interpreter registered with your LuaRocks installation. If you’re managing multiple Lua versions — say, Lua 5.1 for an OpenResty project and LuaJIT for a game server — make sure the correct lua binary is on your PATH before running the install, because the compiled .so or .dll is version-specific.

Load it like any other library:

local lfs = require("lfs")

Directory Contents with lfs.dir

The lfs.dir() function iterates over entries in a directory, returning each filename one at a time through an iterator: no need to slurp the entire listing into a table first. This lazy approach keeps memory use low even when scanning directories with thousands of files:

local lfs = require("lfs")

for name in lfs.dir("/etc") do
    if name ~= "." and name ~= ".." then
        print(name)
    end
end

Each call returns the next entry name, or nil when the directory is exhausted. Entries . and .. are included; you filter them out yourself if needed.

The iterator returns nil automatically when the loop ends, so there’s no need to close the directory handle explicitly in normal usage.

Reading file attributes

lfs.attributes() returns a table of metadata for any file or directory:

local attr = lfs.attributes("/etc/passwd")

print(attr.mode)         -- "file"
print(attr.size)         -- file size in bytes
print(attr.access)       -- last access time (os.time format)
print(attr.modification)  -- last modification time
print(attr.change)       -- last status change time
print(attr.permissions)  -- e.g. "rw-r--r--"

Among these attributes, mode is the one you’ll check most often: it tells you whether a path points to a regular file, a directory, or something more exotic. This distinction matters because operations like io.open will fail on a directory, and lfs.dir will fail on a regular file:

local attr = lfs.attributes("/tmp")

if attr.mode == "directory" then
    print("it's a directory")
elseif attr.mode == "file" then
    print("it's a file")
end

Other possible mode values: link, socket, char device, block device, named pipe. On most real-world filesystems, however, you’ll only encounter file and directory, with the occasional link when symlinks are in play.

If you only need one attribute, pass it as a second argument: this avoids building the whole table:

local size = lfs.attributes("/bin/ls", "size")
print(size)  -- number of bytes

Creating and removing directories

Creating and removing directories is one of the primary reasons to use LuaFileSystem instead of the standard io library. Neither io.open nor os.execute("mkdir ...") gives you portable, programmatic directory control: lfs.mkdir and lfs.rmdir do. Both functions follow the Lua convention of returning true on success and nil, error_message on failure:

local lfs = require("lfs")

-- Create a directory
local ok, err = lfs.mkdir("/tmp/myproject")
if not ok then
    print("failed: " .. err)
end

-- Remove it
local ok2, err2 = lfs.rmdir("/tmp/myproject")
if not ok2 then
    print("failed: " .. err2)
end

Both return true on success, or nil plus an error message on failure. Common failure reasons for rmdir include the directory not being empty: LuaFileSystem won’t recursively delete, so you need to walk and remove contents yourself.

Working Directory

Every Lua process has a current working directory that affects how relative paths resolve. Changing it with lfs.chdir lets you scope file operations to a specific directory without constructing absolute paths everywhere, which is especially handy in build scripts that need to hop between project subdirectories:

Lua programs run in a current working directory. lfs.chdir() changes it:

local lfs = require("lfs")

local original = lfs.currentdir()
print("was: " .. original)

lfs.chdir("/tmp")
print("now: " .. lfs.currentdir())  -- "/tmp"

lfs.chdir(original)  -- go back

lfs.currentdir() returns the current directory as a string, or nil plus an error message if retrieval fails. A good habit is to save and restore the working directory around any code that changes it: especially in library functions that shouldn’t have side effects on the caller’s environment.

Modifying file timestamps

lfs.touch() updates the access and modification times of a file:

local lfs = require("lfs")
local now = os.time()

-- Set both access and modification to now
lfs.touch("/tmp/afile", now, now)

-- Set only modification time (access time uses mtime)
lfs.touch("/tmp/afile", nil, now)

-- Set both to current time
lfs.touch("/tmp/afile")

Times are in the same format os.time() returns: seconds since epoch. Omitting both arguments sets both times to the current moment. This function is frequently used in build systems to mark files as up-to-date, preventing unnecessary recompilation.

On systems that support them, lfs.link() creates links:

-- Hard link (default)
lfs.link("/path/to/original", "/path/to/newhard")

-- Symbolic / soft link
lfs.link("/path/to/original", "/path/to/symlink", true)

lfs.symlinkattributes() reads information about the link itself, rather than the file it points to. The distinction is critical for tools that need to know whether a path is a symlink regardless of what it resolves to: for instance, a deployment script that shouldn’t follow links into directories outside the project tree:

local attr = lfs.symlinkattributes("/path/to/symlink")
print(attr.target)  -- what the link points to
print(attr.mode)     -- "link"

On Windows, symlinkattributes() currently behaves identically to attributes(). This is a known limitation: Windows symlink support varies by filesystem and privilege level, so portable code should handle the case where link-specific metadata isn’t available.

File Locking

File locks prevent concurrent processes from stepping on each other. lfs.lock() locks an open file handle:

local lfs = require("lfs")

local file = io.open("/tmp/mylock.txt", "w")

-- Lock for exclusive write access
local ok = lfs.lock(file, "w")
if not ok then
    print("could not acquire lock")
end

-- ... do work ...

lfs.unlock(file)
file:close()

The lock is automatically released when the file is closed, but it’s good practice to explicitly free the lock before closing. File-based locking works well for coordinating access to a single resource, but when you need to ensure only one instance of your entire program runs at a time, a directory lock is more reliable.

For locking a directory rather than a file, lfs.lock_dir() creates a sentinel file:

local lfs = require("lfs")

local lock, err = lfs.lock_dir("/tmp/myproject", 30)  -- 30s stale timeout
if not lock then
    print("another instance is running: " .. err)
    return
end

-- ... do work ...

lock:free()  -- release the lock

The second argument is how long the lock file is considered stale; useful for cleaning up after a crashed process. The default is effectively forever, which means a crashed process leaves behind an orphaned lock that blocks all future runs: always set a reasonable timeout.

Iterating a directory recursively

A common pattern is walking a directory tree:

local lfs = require("lfs")

local function walk(dir, depth)
    depth = depth or 0
    for entry in lfs.dir(dir) do
        if entry ~= "." and entry ~= ".." then
            local path = dir .. "/" .. entry
            local attr = lfs.attributes(path)
            print(string.rep("  ", depth) .. entry)
            if attr.mode == "directory" then
                walk(path, depth + 1)
            end
        end
    end
end

walk(".")

This recursive walk prints every file and directory under the current folder, indented by depth. The pattern is straightforward but deliberately avoids collecting results into a list: in a deep tree with thousands of entries, eager collection would consume memory proportional to the total file count, while this iterator-based approach uses constant space.

Setting file mode

lfs.setmode() controls whether a file is opened in text or binary mode (primarily relevant on Windows):

local lfs = require("lfs")
local file = io.open("data.bin", "rb")

local prev = lfs.setmode(file, "binary")
print("previous mode: " .. tostring(prev))

-- back to text
lfs.setmode(file, "text")

On unix systems, where text and binary modes are identical, this function has no practical effect.

LuaFileSystem fills the gap between what the standard io library provides and what real-world filesystem operations demand. Once you need to list directories, read metadata, or coordinate file access across processes, lfs becomes essential rather than optional. The API is small enough to learn in an afternoon but covers the filesystem primitives that most Lua programs eventually need — especially build tools, deployment scripts, and server-side file processors that can’t rely on shell commands for portability.

See Also

  • lua-closures: how closures capture upvalues, relevant when writing directory walker callbacks
  • lua-metatables: metatables for building object-oriented file utilities
  • file-io: Lua’s standard io library for reading and writing file contents