IO and Files in Ruby
Ruby’s standard library gives you everything you need to work with files, directories, and streams. The APIs are clean, the block forms prevent resource leaks, and most operations you need are a single method call away. This guide covers the classes you’ll reach for most often, the patterns that actually work in production, and the gotchas that trip people up.
The IO Class Hierarchy
Ruby groups all stream operations under three main classes:
IO— the base class. STDIN, STDOUT, STDERR, network sockets, and pipes all share this interface.File— a subclass ofIOthat adds class methods for path manipulation (File.read,File.exist?,File.basename, etc.).StringIO— an in-memory drop-in forIO. It behaves like a file but stores data in a string instead of on disk. Useful for testing, buffering, or when you want a file-like API without touching the filesystem.
# File reads from disk
File.write("/tmp/demo.txt", "hello")
puts File.read("/tmp/demo.txt")
# => hello
# StringIO keeps everything in memory
require "stringio"
buf = StringIO.new
buf.write("hello")
buf.rewind
puts buf.read
# => hello
Reading and Writing Files
The File class has two groups of methods: class methods that operate on a path in one go, and instance methods you get after opening a file object.
One-liners with File class methods
For quick reads and writes, the class methods are hard to beat:
content = File.read("config.json") # entire file as String
lines = File.readlines("data.txt") # Array of lines (with \n)
File.write("out.txt", "result: 42\n") # returns byte count written
Using File.open with a block
When you need more control, open the file and work with it directly. Always prefer the block form:
File.open("data.txt", "r") do |f|
f.each_line do |line|
puts line.chomp
end
end
# file auto-closed when block exits — no close call needed
The block form is preferred because Ruby closes the file automatically, even if an exception is raised inside the block. With the two-argument form, you must call close yourself:
f = File.open("data.txt", "r")
begin
# work with f
ensure
f.close # easy to forget, and the file stays open if this line is skipped
end
This matters more than it might seem. File descriptors are a finite resource — most systems cap them at 1024 per process. A long-running server that forgets to close files will eventually crash with “Too many open files”.
Iterating line by line
For simple iteration, File.foreach is the cleanest option:
File.foreach("/etc/hostname") { |line| puts line.inspect }
# => "server01\n"
You can also pass an encoding:
File.read("story.txt", encoding: "UTF-8")
File Modes
When you open a file, the mode string determines what you can do with it:
| Mode | What it does |
|---|---|
"r" | Read-only, from the start |
"w" | Write-only, truncate existing content or create new |
"a" | Write-only, append to the end |
"r+" | Read-write, start of file |
"w+" | Read-write, truncate |
"a+" | Read-write, append |
# Overwrite
File.write("log.txt", "new content\n")
# Append
File.open("log.txt", "a") do |f|
f.puts "another line"
end
Append mode is useful for logging. Read-write mode ("r+") lets you modify existing content in place, though you have to manage the cursor position yourself.
Binary vs Text Mode
Append "b" to the mode for binary access:
File.binread("image.png") # raw bytes, no newline translation
File.binwrite("output.bin", data)
In text mode (the default), Ruby translates newline characters on read and write. On Windows, \r\n becomes \n when reading. This translation corrupts binary data like images, PDFs, or compressed files. On Unix, newlines are handled the same way in both modes, but using "b" still signals your intent and prevents bugs if the code runs cross-platform.
Encoding
Ruby handles encoding through the mode string or the encoding: keyword argument:
File.read("data.txt", encoding: "UTF-8")
File.open("out.txt", "w:UTF-8") { |f| f.write("hello") }
# Nested encoding: read UTF-16, convert to UTF-8 internally
File.read("utf16_file.txt", encoding: "UTF-16:UTF-8")
You can also set encoding on an open file object:
f = File.open("data.txt", "r")
f.set_encoding("UTF-8")
Working with Directories
The Dir class handles directory listing and path utilities:
Dir.entries("/tmp") # => ["file1.txt", "file2.txt", ".", ".."]
Dir.foreach("/tmp") { |name| puts name }
Path utilities come from File:
File.basename("/a/b/c.rb") # => "c.rb"
File.dirname("/a/b/c.rb") # => "/a/b"
File.extname("c.rb") # => ".rb"
File.exist?("path") # true or false
Creating and removing directories:
Dir.mkdir("new_dir")
Dir.rmdir("empty_dir") # fails if directory is not empty
require "fileutils"
FileUtils.mkdir_p("a/b/c") # creates nested directories
FileUtils.rm_rf("dir") # removes directory and all contents
FileUtils for Common Tasks
FileUtils covers copy, move, delete, and navigation:
require "fileutils"
FileUtils.cp("src.txt", "dest.txt")
FileUtils.mv("old.txt", "new.txt")
FileUtils.rm("file.txt")
FileUtils.touch("timestamp.txt")
The Dir.chdir trap
One gotcha worth knowing: Dir.chdir changes the process’s working directory globally. Unlike File.open with a block, there is no scoped form:
original = Dir.pwd
Dir.chdir("/tmp")
begin
# working directory is /tmp here
ensure
Dir.chdir(original) # must restore, or the change persists
end
FileUtils.cd has the same global behavior. Save and restore Dir.pwd if you need temporary navigation.
StringIO: In-Memory Files
When you want a file-like object without touching disk, StringIO is the answer:
require "stringio"
buf = StringIO.new
buf.puts("first line")
buf.puts("second line")
buf.rewind
buf.each_line { |l| puts "GOT: #{l.inspect}" }
# => GOT: "first line\n"
# GOT: "second line\n"
It supports the same modes as File ("r", "w", "a"), and responds to all the standard IO methods. This makes it great for testing code that reads or writes files — you can pass a StringIO instead of a File without changing your method signatures.
Working with Structured Data
For common formats, Ruby’s stdlib has you covered:
require "csv"
CSV.foreach("data.csv") { |row| puts row.inspect }
rows = CSV.read("data.csv")
require "json"
data = JSON.parse(File.read("config.json"))
File.write("config.json", JSON.pretty_generate(data))
For temporary files that clean themselves up:
require "tempfile"
file = Tempfile.new(["prefix", ".txt"])
file.write("temporary content")
file.rewind
puts file.read
file.close
file.unlink
Common Mistakes
Forgetting to close files. Use the block form of File.open. It is not optional discipline — it is the only reliable way to ensure files close when exceptions occur.
Using text mode for binary data. If you read a JPEG in text mode on Windows, the \r\n translation corrupts the bytes. Always use "rb" for binary files.
Assuming Dir.chdir is scoped. It is not. The working directory change lasts until you change it back, even after a method returns.
Mixing encodings. When you read a file with one encoding and write it with another, you can get garbled output. Always specify encoding explicitly when it matters.
See Also
- Working with Files and Directories in Ruby — more on FileUtils, glob patterns, and file metadata
- Ruby String Class — string methods you’ll use constantly when processing file content
- Ruby Error Handling — how to handle IO errors like
ENOENTandEACCESproperly