Loading
All articles
Programming 18 min read

Basic File I/O in Ruby: A Visual, Hands-On Guide

Learn file input and output in Ruby visually: three ways to read a file (and how much memory each uses), handling missing files, file modes, writing and appending, JSON save files, folders, line endings, plus exercises with answers.

nbkhope
On this page

Variables are forgetful: the moment your program ends, everything in them is gone. To keep a high score, a to-do list, a diary or a settings file around for next time, your program has to write it to a file. And to use data that came from somewhere else, such as a spreadsheet export, a log or a text document, it has to read one.

That's file I/O, short for input/output, and Ruby makes it pleasantly easy. In this guide you'll read a file three different ways (and see how much memory each one really uses), handle files that aren't there, write and append, save game data as JSON, organize folders, and learn a Windows surprise about line endings. Exercises with answers wrap it up.

The big picture

File I/O: your program talks to a file on disk. Your Ruby program's variables live in memory and vanish when the program ends; a file such as somefile.txt lives on disk and stays after the program ends. Reading, or input, brings data from the file into the program; writing, or output, sends data from the program to the file. Every file job has three steps: open, with File.open or File.foreach; read or write, line by line or all at once; and close, which a block does for you. I/O means input/output

Every file job follows the same three steps: open the file, read or write, then close it. As you'll see, Ruby can do the opening and closing for you.

All the examples below use this little file, somefile.txt, sitting in the same folder as the Ruby script:

This is a sample file
With some contents within
This is the last line :)

The sample runs come from Ruby 3.3.

Reading a file: three ways

Three ways to read the same file. File.read(name) returns one big String, with the whole file in memory at once. File.readlines(name) returns an Array of lines, with the whole file in memory, split into lines. File.foreach(name) with a block gives you one line at a time, with only the current line in memory, like items on a conveyor belt

1. File.read: everything at once

contents = File.read("somefile.txt")
puts contents
puts "That was #{contents.length} characters"
1This is a sample file
2With some contents within
3This is the last line :)
4That was 73 characters

Simple and handy for small files like settings or templates.

2. File.readlines: an array of lines

lines = File.readlines("somefile.txt", chomp: true)
p lines
puts "The file has #{lines.size} lines; the last one is: #{lines.last}"
["This is a sample file", "With some contents within", "This is the last line :)"]
The file has 3 lines; the last one is: This is the last line :)

Great when you need the lines as a list: to count them, sort them or pick one at random. chomp: true removes the newline from the end of each line.

3. File.foreach: one line at a time

File.foreach("somefile.txt").with_index(1) do |line, line_number|
  puts "#{line_number}: #{line.chomp}"
end
1: This is a sample file
2: With some contents within
3: This is the last line :)

foreach hands your block one line, waits for you to finish with it, then reads the next. .with_index(1) numbers the lines starting from 1.

About that newline: chomp

Each line comes with its newline character, \n, still attached. p shows it:

1File.foreach("somefile.txt") do |line|
2  p line
3end
4p "With some contents within\n".chomp
1"This is a sample file\n"
2"With some contents within\n"
3"This is the last line :)\n"
4"With some contents within"

chomp snips it off. puts doesn't mind either way, because it only adds a newline when the text doesn't already end with one. But when you compare a line with something (line == "hello") or glue lines together, a forgotten \n causes confusing bugs.

Which one should you use? Let's measure

All three read the same file, so does it matter which one you pick? We tested it on a big log file: 146 MB, 2 million lines, counting the lines that contain ERROR:

Reading a 146 MB log file: how much memory? Counting the ERROR lines among 2 million lines, measured for this article with Ruby 3.3.2 on Windows, peak memory of a fresh process, median of 3 runs. Ruby doing nothing uses 20.8 MB. File.foreach, one line at a time: 21.8 MB and 0.61 seconds. File.read, one big string: 299.8 MB and 0.47 seconds. File.readlines, an array of 2 million strings: 414.4 MB and 1.26 seconds. foreach used barely more memory than an empty Ruby program, no matter how big the file. File.read was a bit faster here, but a file bigger than your memory would simply not fit

File.foreach used just 1 MB more than a Ruby program that does nothing at all. File.readlines needed over 390 MB extra, because it builds 2 million separate strings at once. Here's how the measurement program counted the errors each way:

File.read(file_name).each_line.count { |line| line.include?("ERROR") }
File.readlines(file_name).count { |line| line.include?("ERROR") }
File.foreach(file_name) { |line| error_count += 1 if line.include?("ERROR") }

When the file isn't there

What if the file doesn't exist? Ruby stops with an error:

File.foreach("doesnt_exist.txt") do |line|
  puts line
end
missing.rb:1:in `foreach': No such file or directory @ rb_sysopen - doesnt_exist.txt (Errno::ENOENT)
	from missing.rb:1:in `<main>'

Errno::ENOENT is the error's name: "error number: Error, NO such ENTry". There are two good ways to cope:

The file might not be there: two ways to cope. Look before you leap: if File.exist?(name), read it with File.foreach, else print No such file. Simple and readable for everyday scripts. Try it and rescue: begin, File.foreach, rescue Errno::ENOENT => error, puts error.message, end. Also handles the file vanishing between the check and the read. Rescue the specific error, Errno::ENOENT, meaning no such file, not Exception, so real bugs still show up

Option 1: check first with File.exist?

1file_name = "sample.txt"
2
3if File.exist?(file_name)
4  File.foreach(file_name) do |line|
5    puts line.chomp
6  end
7else
8  puts "Sorry, #{file_name} does not exist."
9end
Sorry, sample.txt does not exist.

Option 2: try it, and rescue the error

1begin
2  File.foreach("doesnt_exist.txt") do |line|
3    puts line
4  end
5rescue Errno::ENOENT => error
6  puts "Oh no, that file isn't there!"
7  puts error.message
8  puts "Let's carry on without it."
9end
Oh no, that file isn't there!
No such file or directory @ rb_sysopen - doesnt_exist.txt
Let's carry on without it.

The code you try goes between begin and rescue. If that specific error happens, Ruby jumps to the rescue section instead of crashing, and error.message describes what went wrong.

Writing a file

To write, open the file in write mode, "w", and puts into it, just like printing to the screen:

1File.open("newfile.txt", "w") do |file|
2  file.puts "Hello everyone!"
3  file.puts "This will be a brand new file!"
4  file.puts "Just one more last line"
5end
6
7puts File.read("newfile.txt")
Hello everyone!
This will be a brand new file!
Just one more last line

Inside the block, file is your connection to the file. When the block ends, Ruby closes the file for you.

puts, print, write and <<

1File.open("output.txt", "w") do |file|
2  file.puts "puts adds a newline"
3  file.print "print does not"
4  file.print " (see?)\n"
5  file.write "write works like print, "
6  file << "and << can be chained" << "!\n"
7end
8puts File.read("output.txt")
puts adds a newline
print does not (see?)
write works like print, and << can be chained!

One-line shortcuts

For quick jobs, File.write opens, writes and closes in one step. Add mode: "a" to append instead:

File.write("shopping.txt", "milk\neggs\n")
File.write("shopping.txt", "bread\n", mode: "a")
puts File.read("shopping.txt")
milk
eggs
bread

File modes: careful with "w"

The second argument to File.open is the mode. It decides whether you can read, write or both, and, crucially, what happens to what's already in the file:

File modes: what does each one do? "r" reads only; a missing file is an error; existing contents are kept. "w" writes only; a missing file is created; existing contents are ERASED; writes go from the start. "a" writes only; a missing file is created; existing contents are kept; writes go at the end. "r+" reads and writes; a missing file is an error; contents are kept; writes go from the start. "w+" reads and writes; a missing file is created; contents are ERASED. "a+" reads and writes; a missing file is created; contents are kept; writes go at the end. "r" is the default. Add "b", like "rb" or "wb", for binary files such as images, so Ruby doesn't touch line endings

The two you'll use most are "w" (start a fresh file) and "a" (add to the end). Mixing them up is the classic way to lose data:

1File.write("diary.txt", "Monday: learned Ruby\n")
2
3File.open("diary.txt", "a") { |file| file.puts "Tuesday: learned file I/O" }
4puts "After appending with \"a\":"
5puts File.read("diary.txt")
6
7File.open("diary.txt", "w") { |file| file.puts "Wednesday: oops" }
8puts "After opening with \"w\":"
9puts File.read("diary.txt")
1After appending with "a":
2Monday: learned Ruby
3Tuesday: learned file I/O
4After opening with "w":
5Wednesday: oops
The difference between "a" and "w". diary.txt starts with one line, Monday: learned Ruby. Opening it with "a" and writing adds Tuesday: learned file I/O at the end, keeping Monday. Opening it with "w" leaves only Wednesday: oops: everything before is gone

Let a block close the file for you

An open file uses resources, and what you write may wait in a buffer until the file is closed. With a block, Ruby closes the file automatically when the block ends, even if an error happens inside it:

Let a block close the file for you. With a block, File.open with do |file|, file.puts, and end: the file is closed at end, even on error. Without a block, file = File.open, file.puts, and file.close, which is easy to forget: forget close, or crash before it, and the file stays open, and writes may not reach the disk yet

Without a block, you're responsible for closing it yourself:

1file = File.open("manual.txt", "w")
2file.puts "Remember to close me!"
3file.close
4puts "Closed? #{file.closed?}"
5puts File.read("manual.txt")
Closed? true
Remember to close me!

It works, but if something goes wrong between open and close, the file stays open. Prefer the block form.

A real task: read data, write a report

Here's the kind of small script file I/O is perfect for. scores.txt holds one student per line:

1Ana,90
2Bruno,85
3Carla,77
4Diego,92
5Eva,68

The script reads it, works out the average and the top student, and writes a report:

1total = 0
2best_name = nil
3best_score = -1
4
5File.foreach("scores.txt") do |line|
6  name, score_text = line.chomp.split(",")
7  score = score_text.to_i
8  total += score
9  if score > best_score
10    best_score = score
11    best_name = name
12  end
13end
14
15student_count = File.readlines("scores.txt").size
16average = total.to_f / student_count
17
18File.open("report.txt", "w") do |report|
19  report.puts "Students: #{student_count}"
20  report.puts "Average:  #{average.round(1)}"
21  report.puts "Top:      #{best_name} (#{best_score})"
22end
23
24puts File.read("report.txt")
Students: 5
Average:  82.4
Top:      Diego (92)

Saving data with JSON

Plain text is great for lines of words. For structured data, such as a hash of high scores, the settings of an app or a game's save file, use JSON, a text format nearly every programming language can read:

Saving and loading data with JSON. A Ruby Hash like { "Ana" => 1200, "Bruno" => 950 } becomes JSON text with JSON.pretty_generate, saved to high_scores.json on disk with File.write. File.read plus JSON.parse turns it back into a Ruby Hash. JSON turns Ruby data into plain text that survives after your program ends, and that other languages can read too
1require "json"
2
3high_scores = { "Ana" => 1200, "Bruno" => 950, "Carla" => 1430 }
4File.write("high_scores.json", JSON.pretty_generate(high_scores))
5puts File.read("high_scores.json")
6
7loaded = JSON.parse(File.read("high_scores.json"))
8champion, points = loaded.max_by { |name, score| score }
9puts "Champion: #{champion} with #{points} points"
1{
2  "Ana": 1200,
3  "Bruno": 950,
4  "Carla": 1430
5}
6Champion: Carla with 1430 points

Folders and paths

Ruby's Dir and File classes, plus the fileutils library, handle folders and file names:

1require "fileutils"
2
3FileUtils.mkdir_p("notes/2026")
4File.write(File.join("notes", "2026", "january.txt"), "Happy new year!\n")
5File.write(File.join("notes", "2026", "february.txt"), "Short month.\n")
6
7Dir.glob("notes/**/*.txt").sort.each do |path|
8  puts "#{path}  (name: #{File.basename(path, '.txt')}, extension: #{File.extname(path)})"
9end
notes/2026/february.txt  (name: february, extension: .txt)
notes/2026/january.txt  (name: january, extension: .txt)
  • FileUtils.mkdir_p creates a folder, including any missing parent folders.
  • File.join builds a path with the right separator.
  • Dir.glob("notes/**/*.txt") finds every .txt file under notes, in any subfolder.
  • File.basename and File.extname pull a path apart.

Two things that surprise people

Accents and emoji: characters vs. bytes

Ruby reads and writes text as UTF-8, so accents and emoji just work. But a character isn't always one byte:

1File.write("greetings.txt", "Olá, café com pão!\n")
2text = File.read("greetings.txt")
3puts text
4puts "Characters: #{text.chomp.length}, bytes: #{text.chomp.bytesize}"
5puts "Encoding: #{text.encoding}"
Olá, café com pão!
Characters: 18, bytes: 21
Encoding: UTF-8

á, é and ã each take 2 bytes in UTF-8, which is why 18 characters fill 21 bytes.

Line endings on Windows

Write the same two lines in text mode and in binary mode on Windows, and the files come out different sizes:

1File.open("text_mode.txt", "w") { |file| file.puts "one"; file.puts "two" }
2File.open("binary_mode.txt", "wb") { |file| file.puts "one"; file.puts "two" }
3puts "Text mode:   #{File.size('text_mode.txt')} bytes"
4puts "Binary mode: #{File.size('binary_mode.txt')} bytes"
5p File.binread("text_mode.txt")
6p File.read("text_mode.txt")

Sample run on Windows:

1Text mode:   10 bytes
2Binary mode: 8 bytes
3"one\r\ntwo\r\n"
4"one\ntwo\n"
Same two lines, different sizes: line endings on Windows. In text mode, "w", on Windows the bytes are o, n, e, backslash r, backslash n, t, w, o, backslash r, backslash n: 10 bytes. In binary mode, "wb", or on macOS and Linux, they are o, n, e, backslash n, t, w, o, backslash n: 8 bytes. Windows ends lines with two characters, backslash r backslash n. In text mode Ruby adds the backslash r when writing and removes it when reading, so your code sees the same backslash n everywhere

Windows traditionally ends lines with two characters, \r\n. In text mode, Ruby quietly translates, so your code always sees plain \n. On macOS and Linux both files are 8 bytes. Use binary mode ("rb", "wb") for images, PDFs and other non-text files, so Ruby doesn't "translate" bytes that aren't line endings at all.

Practice time

Create somefile.txt with the three lines from the top of this article, then try these before peeking at the answers.

Exercise 1: Predict the contents (warm-up). After running this, what's in numbers.txt?

File.write("numbers.txt", "1\n")
File.write("numbers.txt", "2\n")
File.write("numbers.txt", "3\n", mode: "a")

Exercise 2: Word counter. Count the lines, words and characters in somefile.txt, like the wc command does.

Exercise 3: Search. Print every line of somefile.txt that contains the word line, with its line number.

Exercise 4: Journal. Write a method add_entry(date, text) that adds a line like [2026-10-24] Started learning file I/O to journal.txt without erasing earlier entries.

Exercise 5: Shout it. Read somefile.txt and write an ALL-CAPS copy to shouting.txt, line by line.

Exercise 6: High score table (challenge). Keep the top 3 scores in high_scores.json. Write save_score(name, points), which loads the list (or starts empty if there's no file yet), adds the new score, keeps the best 3 and saves. Then add Ana 1200, Bruno 950, Carla 1430 and Diego 800, and print the table.

Quick quiz

  1. What's the difference between modes "w" and "a"?
  2. Which reading method keeps memory use flat for a huge file?
  3. Why use rescue Errno::ENOENT instead of rescue Exception?
  4. What does chomp remove?
  5. Why is File.open with a block safer than calling close yourself?

Answers

Exercise 1: The second File.write replaced the 1 (write mode starts over), and the third appended:

1File.write("numbers.txt", "1\n")
2File.write("numbers.txt", "2\n")
3File.write("numbers.txt", "3\n", mode: "a")
4print File.read("numbers.txt")
2
3

Exercise 2: split with no arguments splits on any whitespace, which gives the words:

1line_count = 0
2word_count = 0
3character_count = 0
4
5File.foreach("somefile.txt") do |line|
6  line_count += 1
7  word_count += line.split.size
8  character_count += line.length
9end
10
11puts "Lines: #{line_count}, words: #{word_count}, characters: #{character_count}"
Lines: 3, words: 15, characters: 73

Exercise 3:

1search_word = "line"
2
3File.foreach("somefile.txt").with_index(1) do |line, line_number|
4  puts "#{line_number}: #{line}" if line.include?(search_word)
5end
3: This is the last line :)

Only line 3 matches: include? is case-sensitive, and none of the other lines contain "line".

Exercise 4: Append mode, "a", keeps what's already there:

1def add_entry(date, text)
2  File.open("journal.txt", "a") do |journal|
3    journal.puts "[#{date}] #{text}"
4  end
5end
6
7add_entry("2026-10-24", "Started learning file I/O")
8add_entry("2026-10-25", "Wrote my first log file")
9puts File.read("journal.txt")
[2026-10-24] Started learning file I/O
[2026-10-25] Wrote my first log file

Exercise 5: Open the output once, and stream the input through it:

1File.open("shouting.txt", "w") do |output|
2  File.foreach("somefile.txt") do |line|
3    output.puts line.chomp.upcase
4  end
5end
6
7puts File.read("shouting.txt")
THIS IS A SAMPLE FILE
WITH SOME CONTENTS WITHIN
THIS IS THE LAST LINE :)

Exercise 6: A guard clause handles the first run, when there's no file yet:

1require "json"
2
3SCORES_FILE = "high_scores.json"
4
5def load_scores
6  return [] unless File.exist?(SCORES_FILE)
7
8  JSON.parse(File.read(SCORES_FILE))
9end
10
11def save_score(name, points)
12  scores = load_scores
13  scores << { "name" => name, "points" => points }
14  top_three = scores.sort_by { |entry| -entry["points"] }.first(3)
15  File.write(SCORES_FILE, JSON.pretty_generate(top_three))
16end
17
18save_score("Ana", 1200)
19save_score("Bruno", 950)
20save_score("Carla", 1430)
21save_score("Diego", 800)
22
23load_scores.each_with_index do |entry, index|
24  puts "#{index + 1}. #{entry['name']}: #{entry['points']}"
25end
1. Carla: 1430
2. Ana: 1200
3. Bruno: 950

Diego's 800 didn't make the top 3. Run it again, and the scores are still there: that's the whole point of saving to a file.

Quiz: (1) "w" erases the file and starts fresh; "a" keeps it and adds to the end. (2) File.foreach, which holds one line at a time. (3) Exception also catches your own bugs and even Ctrl+C; rescue only the error you expect. (4) The newline (\n, or \r\n) at the end of a string. (5) Ruby closes the file when the block ends, even if an error happens inside it.

Cheat sheet

I want to…Write
Read the whole fileFile.read("notes.txt")
Get an array of linesFile.readlines("notes.txt", chomp: true)
Go line by line (big files)File.foreach("notes.txt") { |line| … }
Number the linesFile.foreach(name).with_index(1) { |line, number| … }
Check that a file existsFile.exist?("notes.txt")
Handle a missing filebegin … rescue Errno::ENOENT => error … end
Write a new fileFile.write("notes.txt", text) or File.open(name, "w") { |file| file.puts … }
Add to the endFile.write(name, text, mode: "a") or File.open(name, "a") { … }
Save / load dataFile.write(name, JSON.pretty_generate(data)) / JSON.parse(File.read(name))
Make foldersFileUtils.mkdir_p("a/b/c")
Build a pathFile.join("notes", "2026", "january.txt")
Find filesDir.glob("notes/**/*.txt")
Read or write binary datamodes "rb" / "wb", or File.binread

Wrapping up

Files are how programs remember things. In Ruby, working with them is short and sweet:

  • Three ways to read: File.read (one string), File.readlines (an array), File.foreach (one line at a time, and the only one whose memory stayed flat on our 146 MB test file).
  • Missing files raise Errno::ENOENT: check with File.exist? or rescue that specific error.
  • Modes matter: "w" erases, "a" appends, "r" reads. Add b for binary files.
  • Blocks close files for you, even when something goes wrong.
  • JSON saves structured data that survives between runs.
  • Text is UTF-8, and on Windows, text mode quietly translates \r\n line endings.

Want more Ruby? Read What Is the Ruby Programming Language?, then Ruby Arrays, Ruby Hashes and Ruby Basic String Methods.

Looking for a reference text for Ruby? Get the Well-Grounded Rubyist.

Thanks for reading!

Know someone who would enjoy this? Send it their way.

More articles

Read next

Programming 4 min read

Ruby Blocks and Iterators: Write Your Own

Learn how Ruby methods receive blocks, pass values with yield, handle a missing block, and return an Enumerator. Build and run your own iterator.

nbkhope
Programming 6 min read

C++ std::vector: Size, Capacity, and Safe Growth

Learn how std::vector grows in C++: the difference between size and capacity, when reserve helps, why resize is different, and how growth can invalidate references.

nbkhope
Programming 5 min read

How to Handle Errors in Go: A Hands-On Guide

Learn Go's everyday error pattern with runnable examples: return an error, check it, add context with %w, and recognize a missing file with errors.Is.

nbkhope

Comments

Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.