Intermediate

I/O Operations in Crystal

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

Input and output are how a program talks to the outside world - the terminal, the keyboard, and the filesystem. Crystal inherits Ruby’s friendly I/O vocabulary (puts, print, gets) but layers its static type system and nil safety on top. The result is I/O code that reads like a script yet compiles to a native binary.

Crystal models everything as an IO object. STDOUT, STDERR, STDIN, and open files all share the same IO interface, so the methods you learn here work uniformly across every stream. Because Crystal is nil-safe, reading input returns a String? (a String or Nil), and the compiler forces you to handle the “no more input” case - a class of bug that silently slips through in many dynamic languages.

In this tutorial you will move beyond the single puts from Hello World: writing to standard output and standard error, reading typed input from the keyboard, reading and writing files, formatting output with printf, and handling I/O errors gracefully.

Console Output

Crystal offers several ways to write to the terminal, each suited to a different purpose. puts appends a newline, print does not, and p/pp show the inspected form of a value - invaluable for debugging.

Create a file named console_output.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# puts appends a newline automatically
puts "puts appends a newline automatically"

# print does not append a newline
print "print does not append a newline"
print " - so this continues the same line\n"

# String interpolation works inside any string
name = "Crystal"
version = 1.14
puts "Language: #{name}, version: #{version}"

# p and pp print the *inspected* form (quotes, escapes, structure)
p "a string with \"quotes\""
p [1, 2, 3]
pp({"a" => 1, "b" => 2})

Use puts/print when you want human-readable text, and p/pp when you want to see exactly what a value contains, including its type structure.

Reading Input from the Keyboard

gets reads one line from standard input. Because the stream can end at any time, gets returns String? - the compiler will not let you use the result until you have handled the possible nil.

Create a file named read_input.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Read a line of text from standard input
print "What is your name? "
name = gets

# gets returns String? (String or Nil) - handle the nil case
if name
  name = name.chomp # strip the trailing newline
  puts "Hello, #{name}!"
else
  puts "No input received."
end

# Read a line and convert it to a number
print "Enter your age: "
age_input = gets
if age_input
  age = age_input.chomp.to_i
  puts "Next year you will be #{age + 1}."
end

The if name check narrows the type from String? to String inside the block, so calling .chomp is safe. This compile-time narrowing is one of Crystal’s signature features.

Writing Files

File.write is the quickest way to create a file. For more control - such as appending - open the file with a block, which guarantees the file is closed automatically even if an error occurs.

Create a file named file_write.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# File.write creates (or overwrites) a file in one call
File.write("greeting.txt", "Hello from Crystal!\n")

# Open with mode "a" (append) and a block that auto-closes the file
File.open("greeting.txt", "a") do |file|
  file.puts "This line was appended."
  file.puts "Numbers work too: #{6 * 7}"
end

puts "Finished writing greeting.txt"

After running this, greeting.txt contains three lines. The block form of File.open is idiomatic: you never have to remember to call close.

Reading Files

Crystal gives you several reading strategies: slurp the whole file into a String, stream it line by line, or collect every line into an array.

Create a file named file_read.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# First create a file so this example is self-contained
File.write("notes.txt", "line 1\nline 2\nline 3\n")

# Read the entire file into a single String
contents = File.read("notes.txt")
puts "Whole file:"
puts contents

# Stream the file one line at a time (newlines are chomped)
puts "Line by line:"
File.each_line("notes.txt") do |line|
  puts "-> #{line}"
end

# Collect all lines into an Array(String)
lines = File.read_lines("notes.txt")
puts "The file has #{lines.size} lines."

File.each_line is memory-efficient for large files because it never holds the entire contents in memory at once, while File.read and File.read_lines are convenient when the file is small.

Formatted Output and I/O Errors

For aligned columns, fixed decimal places, or number bases, use printf (prints directly) or sprintf (returns a string). File operations can fail - a missing file raises File::NotFoundError, which you catch with begin/rescue.

Create a file named formatted_output.cr:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# printf uses C-style format specifiers
printf("Name: %-10s Age: %3d\n", "Ada", 30)
printf("Pi is approximately %.4f\n", 3.14159265)
printf("Hex: %x, Octal: %o, Binary: %b\n", 255, 255, 255)

# sprintf returns the formatted string instead of printing it
label = sprintf("Item #%03d", 7)
puts label

# The % operator on a String is shorthand for sprintf
puts "Progress: %d%%" % 75

# Handle I/O errors with begin/rescue
begin
  File.read("does_not_exist.txt")
rescue File::NotFoundError
  puts "Could not read file: it does not exist."
end

The specifiers mirror C’s printf: %-10s left-justifies a string in a 10-character field, %3d right-justifies an integer in a 3-character field, %.4f fixes four decimals, and %% prints a literal percent sign.

Running with Docker

Run the examples with the official Crystal image - no local install required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Pull the official Crystal image
docker pull crystallang/crystal:1.14.0

# Console output
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run console_output.cr

# Writing and reading files
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run file_write.cr
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run file_read.cr

# Formatted output and error handling
docker run --rm -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run formatted_output.cr

The read_input.cr example needs data on standard input. Add -i and pipe the answers in:

1
printf 'Ada\n30\n' | docker run --rm -i -v $(pwd):/app -w /app crystallang/crystal:1.14.0 crystal run read_input.cr

Expected Output

Running console_output.cr:

puts appends a newline automatically
print does not append a newline - so this continues the same line
Language: Crystal, version: 1.14
"a string with \"quotes\""
[1, 2, 3]
{"a" => 1, "b" => 2}

Running read_input.cr with the piped input above (prompts and answers share a line because piped input is not echoed):

What is your name? Hello, Ada!
Enter your age: Next year you will be 31.

Running file_write.cr:

Finished writing greeting.txt

Running file_read.cr:

Whole file:
line 1
line 2
line 3
Line by line:
-> line 1
-> line 2
-> line 3
The file has 3 lines.

Running formatted_output.cr:

Name: Ada        Age:  30
Pi is approximately 3.1416
Hex: ff, Octal: 377, Binary: 11111111
Item #007
Progress: 75%
Could not read file: it does not exist.

Key Concepts

  • Everything is an IO - STDOUT, STDERR, STDIN, and open files all share the same interface, so methods like puts and gets work uniformly across streams.
  • gets returns String? - Crystal’s nil safety forces you to handle the end-of-input case, and an if check narrows the type from String? to String for safe use.
  • puts avoids double newlines - when the string already ends in a newline, puts does not add a second one, which is why reading and re-printing a file preserves its exact line breaks.
  • Block form of File.open auto-closes - passing a block guarantees the file handle is released even if an exception is raised mid-write.
  • Choose your read strategy - File.read for small files, File.each_line for streaming large files without loading them fully into memory, and File.read_lines for an array of lines.
  • printf/sprintf for formatting - C-style specifiers handle alignment, decimal precision, and number bases; the % operator on a String is a compact shorthand for sprintf.
  • I/O errors are exceptions - missing files raise File::NotFoundError, caught with begin/rescue, keeping error handling explicit and typed.

Running Today

All examples can be run using Docker:

docker pull crystallang/crystal:1.14.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining