Part of Learn Lua with NeoVim

Lua with Neowin: File I/O — io.open, Read Modes, Write Modes & io.lines | Episode 26

Sandy LaneSandy Lane

Video: Lua with Neowin: File I/O — io.open, Read Modes, Write Modes & io.lines | Episode 26 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua File I/O: io.open, Read Modes, Write Modes, and io.lines

io.open(path, mode) to open. "r" read, "w" write, "a" append. file:lines() for line-by-line iteration. io.lines(path) for the convenient one-shot. Build a small text-processing example.

Reading and writing files is foundational for any non-trivial script. Lua's I/O API is small but covers the cases.

Reading a file line by line

local file = io.open("data.txt", "r")
if file then
  for line in file:lines() do
    print("  " .. line)
  end
  file:close()
end

io.open(path, mode) returns a file handle (or nil on failure — always guard).

file:lines() returns an iterator that yields one line per call, without the trailing newline.

file:close() releases the OS file descriptor. Skip it and Lua's GC eventually closes the file — but for predictable behaviour (especially during writes), close explicitly.

Open modes

Mode Effect
"r" read (default)
"w" write — truncates if file exists
"a" append — preserves existing content
"r+" read and write
"w+" read and write — truncates first
"a+" read and append
"rb" etc. binary mode (Windows; on Unix, b is a no-op)

Writing a file

local out = io.open("output.txt", "w")
if out then
  out:write("Line one\n")
  out:write("Line two\n")
  out:write("Line three\n")
  out:close()
end

file:write(...) writes raw bytes. No automatic newline — add "\n" yourself.

write accepts multiple arguments:

out:write("Name: ", name, "\n")

Each argument is converted to string and concatenated, no separator.

Appending

local log = io.open("output.txt", "a")
if log then
  log:write("Appended line\n")
  log:close()
end

Open with "a" (append) to add to the end without erasing existing content.

io.lines: line iterator without explicit open/close

for line in io.lines("output.txt") do
  print("  " .. line)
end

io.lines(path) opens the file, returns a line iterator, and automatically closes the file when iteration finishes. Convenient for the common case "process each line."

If the file can't be opened, io.lines raises an error — wrap in pcall (next episode) for error-tolerant code.

Reading whole file at once

local file = io.open("data.txt", "r")
local content = file:read("a")   -- "all"
file:close()
print(#content .. " bytes")

file:read("a") (or "*a" in Lua 5.1) reads the entire remaining file.

Other read modes:

  • file:read("l") — one line, newline stripped (default).
  • file:read("L") — one line, newline included.
  • file:read("n") — one number.
  • file:read(N) — N characters/bytes.

You can chain modes: local first, second = file:read("l", "l") reads two lines.

Processing structured data

local total = 0
local count = 0
for line in io.lines("data.txt") do
  local name, score = string.match(line, "(%a+)%s+(%d+)")
  if name and score then
    print(name .. ": " .. score)
    total = total + tonumber(score)
    count = count + 1
  end
end
print("Average: " .. string.format("%.1f", total / count))

If data.txt looks like:

Alice 90
Bob 85
Charlie 92

The loop:

  1. Reads each line.
  2. Matches name score with the pattern from episode 25.
  3. If both captured, prints and accumulates.

Output:

Alice: 90
Bob: 85
Charlie: 92
Average: 89.0

Pattern + line iteration is the core of most text processing.

Default streams: stdin, stdout, stderr

io.write("Hello\n")              -- writes to stdout
io.read()                         -- reads from stdin
io.stderr:write("Warning\n")      -- writes to stderr

io.write is io.stdout:write shorthand. io.read is io.stdin:read shorthand.

Useful for command-line tools: separate diagnostic output (stderr) from data (stdout) so users can pipe.

Error handling

local file, err = io.open("missing.txt", "r")
if not file then
  print("Open failed: " .. err)
  return
end

io.open returns nil, errmsg on failure. The local x, err = ... pattern is the canonical "try and check" form in Lua. We dive into errors next episode.

Binary files

local f = io.open("image.png", "rb")
local bytes = f:read("a")
f:close()
print(#bytes)

"rb" for binary read; "wb" for binary write. On Unix, b is a no-op; on Windows, it disables CRLF translation. Always use the b variant for non-text content to be portable.

Common stumbles

Forgetting to close the file. Especially after writing — the OS may not flush until close. Always file:close() at the end.

Writing without newlines. out:write("a", "b") produces "ab", not two lines. Add "\n" between or after.

Reading after EOF. file:read() returns nil at end of file. Loops should test for nil to know when to stop. (io.lines and file:lines handle this for you.)

Forgetting to handle io.open failure. Calling methods on nil crashes. Always check.

Mixing read and write modes carelessly. "r+" requires careful seeking between reads and writes. For most cases, open once for read or write, not both.

What's next

Episode 27: error handling. error() to throw, pcall() to catch, assert() for invariants, xpcall() with custom handlers. The result, err return convention.

Recap

io.open(path, mode) → file handle or nil, err. Modes: "r" read, "w" write (truncates), "a" append; add b for binary. file:lines() iterates lines (no newlines). file:read("a") for whole file, ("l") for one line, (N) for N bytes. io.lines(path) is the convenient open-iter-close shortcut. Always file:close() after writing. io.stdout, io.stdin, io.stderr for the standard streams.

Next episode: error handling.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.