Intermediate

I/O Operations in Elixir

Learn console output, reading input, and file handling in Elixir using pipes, streams, and tagged tuples with Docker-ready examples

Input and output are where a program meets the outside world—the terminal, files on disk, and other processes. Elixir treats I/O the way it treats everything else: as functions that transform data. There is no special syntax, just modules like IO and File whose functions you compose with the pipe operator (|>).

Because Elixir is a functional language, its I/O design leans on two idioms you will see throughout this tutorial. The first is tagged tuples: fallible operations return {:ok, value} or {:error, reason}, and you branch on them with pattern matching instead of catching exceptions. The second is a bang convention: a function ending in ! (like File.read!) skips the tuple and raises on failure, which is convenient for scripts where you’d rather crash than continue with bad data.

This tutorial covers writing to the console with more control than IO.puts, reading lines from standard input, writing and reading files, streaming large files line by line, and handling I/O errors the functional way. Every example is a runnable .exs script.

Console Output Beyond puts

You already used IO.puts for Hello World. Elixir gives you several output functions, each suited to a different job—IO.write for output without a trailing newline, IO.inspect for viewing data structures, and Erlang’s :io.format for printf-style formatting.

Create a file named console_output.exs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# IO.puts adds a trailing newline
IO.puts("Line one")
IO.puts("Line two")

# IO.write does NOT add a newline — you control it
IO.write("No newline here... ")
IO.write("still same line\n")

# String interpolation builds formatted messages
name = "Elixir"
version = 1.17
IO.puts("Running #{name} #{version}")

# IO.inspect prints the internal representation of ANY data structure
# and returns its argument, so it drops cleanly into a pipeline
[1, 2, 3]
|> Enum.map(fn x -> x * 2 end)
|> IO.inspect(label: "doubled")

# :io.format is Erlang's printf-style formatter
# ~s = string, ~.2f = float with 2 decimals, ~n = newline
:io.format("~s scored ~.2f~n", ["Elixir", 9.5])

IO.inspect is the workhorse of Elixir debugging: because it returns its input unchanged, you can splice it into the middle of a |> chain to peek at a value without breaking the flow. The label: option prefixes the output so you can tell multiple inspects apart.

Reading Input from the Console

IO.gets/1 prints a prompt and reads one line from standard input. The returned string keeps its trailing newline, so you almost always pipe it through String.trim/1. Since input arrives as text, converting to a number is an explicit step.

Create a file named read_input.exs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# IO.gets prints the prompt and returns the line (with a trailing newline)
name =
  IO.gets("Enter your name: ")
  |> String.trim()

IO.puts("Hello, #{name}!")

# Input is always text — convert it to work with numbers
age =
  IO.gets("Enter your age: ")
  |> String.trim()
  |> String.to_integer()

IO.puts("Next year you will be #{age + 1}")

Because this script waits for input, you feed it data through a pipe when running non-interactively (shown in the Docker section below). Notice how the pipe operator reads top to bottom: get a line, trim it, convert it—each function transforming the value from the one above.

Writing and Reading Files

The File module handles the filesystem. The bang variants (File.write!, File.read!) raise on failure, which keeps script code short. File.write! truncates by default; pass [:append] to add to an existing file instead.

Create a file named file_io.exs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Write a file — File.write! raises on error (the ! convention)
File.write!("poem.txt", "Roses are red\nViolets are blue\n")

# Append instead of overwriting
File.write!("poem.txt", "Elixir is fun\n", [:append])

# Read the whole file into a single string
contents = File.read!("poem.txt")
IO.puts("=== File contents ===")
IO.write(contents)

# Stream the file line by line — memory-efficient for huge files,
# since only one line is held at a time
IO.puts("=== Numbered lines ===")
"poem.txt"
|> File.stream!()
|> Enum.with_index(1)
|> Enum.each(fn {line, i} ->
  IO.write("#{i}: #{line}")
end)

# Clean up the file we created
File.rm!("poem.txt")

File.read! loads the entire file into memory—fine for small files. For large files, File.stream! gives you a lazy Stream that yields one line at a time, so a multi-gigabyte log file never overwhelms memory. Here we pair it with Enum.with_index(1) to number the lines starting from 1.

Handling I/O Errors Functionally

Files may be missing, unreadable, or on a full disk. The non-bang functions (File.read, File.write) return tagged tuples so you can handle every outcome explicitly with case and pattern matching—no exceptions required.

Create a file named file_errors.exs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# File.read returns {:ok, contents} or {:error, reason}
case File.read("config.txt") do
  {:ok, contents} -> IO.puts("Read #{byte_size(contents)} bytes")
  {:error, reason} -> IO.puts("Could not read config.txt: #{reason}")
end

# File.write returns :ok or {:error, reason}
case File.write("greeting.txt", "Hi from Elixir\n") do
  :ok -> IO.puts("Write succeeded")
  {:error, reason} -> IO.puts("Write failed: #{reason}")
end

# Now read back the file we just wrote
case File.read("greeting.txt") do
  {:ok, contents} -> IO.write(contents)
  {:error, reason} -> IO.puts("Error: #{reason}")
end

File.rm!("greeting.txt")

The reason in an error tuple is an atom like :enoent (“no such file or directory”) that comes straight from the operating system. Matching on {:ok, _} versus {:error, _} forces you to consider the failure path at compile time—the compiler warns if a case doesn’t cover the shapes it sees. This is Elixir’s alternative to try/catch: make success and failure ordinary values and let pattern matching route them.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official image
docker pull elixir:1.17-alpine

# Console output example
docker run --rm -v $(pwd):/app -w /app elixir:1.17-alpine elixir console_output.exs

# Input example — pipe data in and add -i to keep stdin open
printf 'Ada\n30\n' | docker run --rm -i -v $(pwd):/app -w /app elixir:1.17-alpine elixir read_input.exs

# File read/write example
docker run --rm -v $(pwd):/app -w /app elixir:1.17-alpine elixir file_io.exs

# Error-handling example
docker run --rm -v $(pwd):/app -w /app elixir:1.17-alpine elixir file_errors.exs

The -v $(pwd):/app mount lets the file examples create poem.txt and greeting.txt in your current directory (both scripts clean up after themselves). The input example adds -i so the container keeps stdin open to receive the piped text.

Expected Output

Running console_output.exs:

Line one
Line two
No newline here... still same line
Running Elixir 1.17
doubled: [2, 4, 6]
Elixir scored 9.50

Running read_input.exs with the piped input Ada and 30 (the prompts and answers share a line because piped input isn’t echoed):

Enter your name: Hello, Ada!
Enter your age: Next year you will be 31

Running file_io.exs:

=== File contents ===
Roses are red
Violets are blue
Elixir is fun
=== Numbered lines ===
1: Roses are red
2: Violets are blue
3: Elixir is fun

Running file_errors.exs (config.txt doesn’t exist, so the first read fails):

Could not read config.txt: enoent
Write succeeded
Hi from Elixir

Key Concepts

  • Tagged tuples over exceptions — Fallible I/O returns {:ok, value} or {:error, reason}, and you branch on them with case and pattern matching rather than catching errors.
  • The bang (!) conventionFile.read!/File.write! raise on failure and return the value directly, ideal for scripts where crashing beats continuing on bad data.
  • IO.inspect returns its argument — Drop it anywhere in a |> pipeline to inspect a value without altering the data flow; use label: to tag the output.
  • Output functions differ by newlineIO.puts appends a newline, IO.write does not, giving you precise control over formatting.
  • :io.format for printf-style output — Elixir borrows Erlang’s format strings (~s, ~.2f, ~n) when you need aligned or precise numeric formatting.
  • Streams for large filesFile.stream! yields one line at a time lazily, so file size never dictates memory usage; File.read! loads everything at once and suits small files.
  • Input is always textIO.gets returns a string with a trailing newline; trim it and convert explicitly (e.g. String.to_integer/1) before treating it as a number.

Running Today

All examples can be run using Docker:

docker pull elixir:1.17-alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining