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.

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

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

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 charactersSimple 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}"
end1: 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".chomp1"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:

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
endmissing.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:

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."
9endSorry, 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."
9endOh 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 lineInside 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
breadFile 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:

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
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:

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,68The 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:

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 pointsFolders 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)})"
9endnotes/2026/february.txt (name: february, extension: .txt)
notes/2026/january.txt (name: january, extension: .txt)FileUtils.mkdir_pcreates a folder, including any missing parent folders.File.joinbuilds a path with the right separator.Dir.glob("notes/**/*.txt")finds every.txtfile undernotes, in any subfolder.File.basenameandFile.extnamepull 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"
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
- What's the difference between modes
"w"and"a"? - Which reading method keeps memory use flat for a huge file?
- Why use
rescue Errno::ENOENTinstead ofrescue Exception? - What does
chompremove? - Why is
File.openwith a block safer than callingcloseyourself?
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
3Exercise 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: 73Exercise 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)
5end3: 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 fileExercise 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']}"
25end1. Carla: 1430
2. Ana: 1200
3. Bruno: 950Diego'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 file | File.read("notes.txt") |
| Get an array of lines | File.readlines("notes.txt", chomp: true) |
| Go line by line (big files) | File.foreach("notes.txt") { |line| … } |
| Number the lines | File.foreach(name).with_index(1) { |line, number| … } |
| Check that a file exists | File.exist?("notes.txt") |
| Handle a missing file | begin … rescue Errno::ENOENT => error … end |
| Write a new file | File.write("notes.txt", text) or File.open(name, "w") { |file| file.puts … } |
| Add to the end | File.write(name, text, mode: "a") or File.open(name, "a") { … } |
| Save / load data | File.write(name, JSON.pretty_generate(data)) / JSON.parse(File.read(name)) |
| Make folders | FileUtils.mkdir_p("a/b/c") |
| Build a path | File.join("notes", "2026", "january.txt") |
| Find files | Dir.glob("notes/**/*.txt") |
| Read or write binary data | modes "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 withFile.exist?orrescuethat specific error. - Modes matter:
"w"erases,"a"appends,"r"reads. Addbfor 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\nline 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.
Read next

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.

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.

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.
Comments
Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.