Intermediate

I/O Operations in Lua

Learn console output, reading input, file reading and writing, formatted output, and I/O error handling in Lua with Docker-ready examples

Input and output are how a program reaches the outside world—the terminal, files on disk, and pipes between processes. In the Hello World tutorial you met print, but that function is only the friendly front door. Lua keeps its I/O deliberately small: the io library gives you a handful of functions built around file handles, and the same handle-based API works for the console, real files, and sub-processes alike.

Two design choices shape everything below. First, Lua distinguishes print—a convenience that adds tabs between arguments and a trailing newline—from io.write, which writes exactly the bytes you give it and nothing more. Second, and true to Lua’s minimalist, scripting-oriented character, most I/O functions that can fail do not raise an error. Instead they return nil plus a message, leaving you to decide whether to handle it or promote it to an error with assert.

In this tutorial you will learn to control console output precisely, read input from the user, write and read files through handles, produce formatted output with string.format, and handle the failures that come with touching the filesystem. Because Lua’s standard library is small, these few functions cover the vast majority of everyday I/O.

Console Output

print is built for quick output: it converts every argument with tostring, joins them with tab characters, and ends the line. io.write is the lower-level tool—it accepts only strings and numbers, adds no separators or newline, and returns the file handle so calls can be chained. For structured text, string.format provides C-style printf specifiers.

Create a file named console_output.lua:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- print separates its arguments with tabs and adds a trailing newline
print("print separates with tabs:", "a", "b", 42)

-- io.write writes exactly what you pass: no separators, no newline
io.write("io.write ")
io.write("stays on ")
io.write("one line\n")

-- Numbers are coerced to text automatically by io.write
io.write("Sum: ", 2 + 3, "\n")

-- string.format gives printf-style control over each value
print(string.format("Name: %s, Age: %d", "Ada", 36))
print(string.format("Pi to 2 places: %.2f", 3.14159))
print(string.format("Hex: %x, Padded: |%5d|", 255, 42))

Running this produces:

print separates with tabs:	a	b	42
io.write stays on one line
Sum: 5
Name: Ada, Age: 36
Pi to 2 places: 3.14
Hex: ff, Padded: |   42|

Notice the tab characters between a, b, and 42 in the first line—that spacing is print’s doing. The three chained io.write calls, by contrast, land on a single line exactly as written.

Reading Console Input

io.read pulls from standard input. Its argument selects a format: "l" reads one line and discards the newline, "n" reads a number and returns it as a Lua number, and "a" reads everything until end of input. With no argument it defaults to "l".

Create a file named greet_input.lua:

1
2
3
4
5
6
7
8
io.write("Enter your name: ")
local name = io.read("l")      -- read a line, newline stripped

io.write("Enter a number: ")
local n = io.read("n")         -- read a number (returns a Lua number)

print("Hello, " .. name .. "!")
print("Its square is " .. (n * n))

Because the prompts use io.write (no newline) and piped input is not echoed back, feeding Ada and 7 into the program produces:

Enter your name: Enter a number: Hello, Ada!
Its square is 49

When you run this interactively in a terminal, your typed input appears between each prompt and the response. Note that io.read("n") returns an actual number, so n * n is arithmetic—not string concatenation.

Writing and Reading Files

io.open(filename, mode) returns a file handle you call methods on: file:write(...), file:read(format), and file:close(). Common modes are "w" (write, truncating any existing file), "a" (append), and "r" (read, the default). The read formats mirror io.read: "a" slurps the whole file, "l" reads one line, "n" reads a number.

For line-by-line iteration, io.lines(filename) is the idiomatic choice—it opens the file, yields one line per iteration, and closes the file automatically when the loop ends.

Create a file named io_operations.lua:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- Open for writing ("w" creates the file or truncates an existing one)
local file = io.open("notes.txt", "w")
file:write("Lua I/O Demo\n")
file:write("Line 2 of 3\n")
file:write(string.format("Line %d of 3\n", 3))
file:close()
print("File written.")

-- Read the whole file at once with the "a" (all) format
local whole = io.open("notes.txt", "r")
local content = whole:read("a")
whole:close()
print("\nFull contents:")
io.write(content)

-- Iterate line by line; io.lines opens and closes the file for you
print("\nNumbered lines:")
local n = 0
for line in io.lines("notes.txt") do
    n = n + 1
    print(string.format("%d: %s", n, line))
end

-- Append mode ("a") adds to the end without overwriting
local append = io.open("notes.txt", "a")
append:write("Appended line\n")
append:close()

-- Count the lines now that we have appended one
local count = 0
for _ in io.lines("notes.txt") do
    count = count + 1
end
print("\nTotal lines now: " .. count)

The -v $(pwd):/app Docker mount (shown below) lets the container write notes.txt back into your current directory, so you can open the file yourself after the run.

I/O Error Handling

Here Lua’s convention shows its character: io.open does not raise when a file is missing. It returns nil, a human-readable message, and a numeric error code. You test the first return value and react accordingly. When you would rather fail loudly, wrap the call in assert, which turns the nil-plus-message pair into a raised error—catchable with pcall.

Create a file named safe_read.lua:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
local filename = "missing.txt"

-- io.open returns nil + message on failure instead of raising
local file, err = io.open(filename, "r")
if file then
    local data = file:read("a")
    file:close()
    print(data)
else
    print("Error: could not open '" .. filename .. "'")
    print("Reason from OS: " .. err)
end

-- assert() promotes the nil+message convention into a raised error,
-- which pcall then catches so the script keeps running
local ok = pcall(function()
    local f = assert(io.open(filename, "r"))
    f:close()
end)
print("pcall succeeded: " .. tostring(ok))

Running this yields:

Error: could not open 'missing.txt'
Reason from OS: missing.txt: No such file or directory
pcall succeeded: false

The exact wording of the OS message comes from the operating system, so it may differ slightly on other platforms, but the structure—filename: reason—is consistent.

Running with Docker

You can run every example without installing Lua locally.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the community Lua Alpine image
docker pull nickblah/lua:5.4-alpine

# Run the console output example
docker run --rm -v $(pwd):/app -w /app nickblah/lua:5.4-alpine lua console_output.lua

# Run the file read/write example
docker run --rm -v $(pwd):/app -w /app nickblah/lua:5.4-alpine lua io_operations.lua

# Run the error-handling example
docker run --rm -v $(pwd):/app -w /app nickblah/lua:5.4-alpine lua safe_read.lua

# For the input example, add -i and pipe input into the container
printf "Ada\n7\n" | docker run --rm -i -v $(pwd):/app -w /app nickblah/lua:5.4-alpine lua greet_input.lua

Expected Output

Running io_operations.lua produces:

File written.

Full contents:
Lua I/O Demo
Line 2 of 3
Line 3 of 3

Numbered lines:
1: Lua I/O Demo
2: Line 2 of 3
3: Line 3 of 3

Total lines now: 4

Key Concepts

  • print vs io.writeprint is a convenience that tostrings every argument, joins them with tabs, and adds a newline; io.write writes exactly the strings and numbers you pass, with no separators or newline, making it the tool for precise output.
  • File handles drive file I/Oio.open returns a handle, and you call handle:write, handle:read, and handle:close on it; always close what you open (or let io.lines do it for you).
  • Read formats select what you get"a" reads the entire file, "l" reads a line (newline stripped), and "n" reads a number; the same formats work for io.read on standard input.
  • io.lines is the idiomatic iterator — it opens the file, yields one line per loop step, and closes the file automatically when the loop finishes, so it needs no explicit cleanup.
  • Failure returns nil, it does not raiseio.open and friends return nil plus a message on error rather than throwing, so check the return value before using a handle.
  • assert and pcall promote and catch errors — wrap a fallible call in assert(io.open(...)) to raise on failure, and use pcall to catch that error and keep the script running.
  • Input arrives as text unless you ask otherwiseio.read("l") gives you a string; use io.read("n"), tonumber, or string.format when numbers are involved.

Running Today

All examples can be run using Docker:

docker pull nickblah/lua:5.4-alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining