Working with IO and files in Ruby
Ruby’s standard library gives you everything you need to work with IO and files in Ruby. 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.
Key takeaways
Fileis the most common entry point for reading and writing paths on disk.IOis the common interface behind files, sockets, pipes, and standard streams.StringIOis a safe in-memory stand-in when you need file-like behaviour for tests.DirandFileUtilscover path inspection, directory creation, and common filesystem tasks.- Block-based file handling is the easiest way to avoid leaking file descriptors.
If you are unsure where to start, begin with File.open in block form and File.read for one-off lookups. That pairing covers a surprising amount of day-to-day Ruby code without making the call sites hard to follow.
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
That simple example shows the big idea: Ruby treats file access, stream access, and in-memory buffers through the same general interface. Once you understand that shared shape, moving from File to StringIO or from file reads to socket reads feels much less like a new API and more like a variation on the same pattern.
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
These one-liners are perfect when the whole file fits comfortably in memory. They keep scripts short and readable, and they avoid the extra ceremony of a manual open-and-close cycle.
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. File descriptors are a finite resource on every operating system, and a long-running process that leaks them will eventually hit the per-process limit and crash with “Too many open files.” With the two-argument form, you must call close yourself, and that call must go in an ensure block to be safe:
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”.
If you are reviewing code later, the block form also signals intent. The reader can see that the file is temporary and that the method is responsible for cleaning up after itself.
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 argument to File.read, File.foreach, or any other class-level method that opens a file. The encoding is applied as Ruby reads the bytes from disk, so the string you get back is already in the encoding you asked for. This is the simplest way to handle non-UTF-8 files without opening and closing a separate file handle:
File.read("story.txt", encoding: "UTF-8")
Line-by-line reads are ideal when a file is too large to load at once or when the processing logic works better as a stream. The tradeoff is that you usually give up random access, so choose the style that matches the task instead of defaulting to the bigger API.
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.
For real applications, the mode you choose is often a signal about how much risk you are willing to accept. Truncating modes are simple but destructive. Append modes are safer for logs. Read-write modes are powerful, but they require you to think about offsets and partial updates.
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.
Binary mode is one of those details that can be invisible in development and painful in production. If you are moving data that is not meant to be human-readable, use binary mode first and think about text conversion only when you are sure it belongs.
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 with set_encoding, which is useful when the file creation and the encoding decision happen in different parts of your code. This approach keeps the file mode string simple and lets you adjust the encoding after the file handle is already in hand:
f = File.open("data.txt", "r")
f.set_encoding("UTF-8")
Encoding matters any time data crosses an application boundary. A file may look fine in one environment and then break in another because the bytes are legal but the encoding assumptions are different. Being explicit here saves a lot of debugging later.
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, which gives you methods for splitting, joining, and inspecting path strings without touching the filesystem. These helpers are stateless and fast because they operate on the string representation alone. File.basename strips the directory portion, File.dirname keeps only the directory, File.extname isolates the extension, and File.exist? checks whether a path actually resolves to something on disk:
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
The Dir methods are a good fit when you are exploring a filesystem, while the File helpers are better when you already know the path and only need metadata. That split keeps the API easier to remember.
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 is where convenience starts to matter more than strict low-level control. It is usually the right choice for scripts, build tools, and maintenance tasks because the intent is obvious even when the filesystem operation itself is a little destructive.
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")
Those helpers let you describe filesystem work in the same order you would explain it to another person. That readability matters in maintenance scripts, especially when the task needs to be rerun safely.
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.
The reason this trap shows up so often is that directory changes feel local when they are not. A method can look self-contained while still changing global process state. If you need temporary navigation, keep the scope narrow and restore the original directory explicitly.
StringIO for 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.
That last point is the real superpower. If your code only needs a readable or writable object, StringIO can stand in for a disk-backed file without changing the method API. That makes it especially handy for unit tests, examples, and quick experiments.
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))
JSON is a common companion to file I/O because it turns raw bytes into something structured right away. CSV, JSON, and Tempfile all fit neatly into the same mental model: open, process, close, and clean up.
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
Temporary files are a good reminder that file handling is not only about permanent storage. Sometimes you need a scratch pad for intermediate work, and Ruby gives you a straightforward API for that too.
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.
Ignoring the cleanup story. Scripts often start small, but file handling tends to stick around. Use the block form, close resources explicitly when necessary, and think about what should happen if the process dies halfway through.
Frequently asked questions
When should I use File.read instead of File.open?
Use File.read when you want the whole file in memory and do not need incremental processing. Use File.open when you want to iterate, stream, or keep control over the file handle.
Is StringIO only useful in tests?
No. Tests are the most common use, but it also helps when you want file-like behaviour in memory, such as buffering generated text before you write it to disk.
Do I need FileUtils for every filesystem task?
Not every task, but it is very handy when you need copy, move, remove, or create nested directories. It keeps common scripts readable and saves you from reimplementing simple filesystem logic.
Conclusion
Working with IO and files in Ruby is mostly about choosing the right level of abstraction. File covers most everyday reads and writes, IO explains the shared interface behind streams, StringIO helps with in-memory work, and FileUtils keeps maintenance scripts concise.
If you keep the cleanup story in mind, the rest of the API becomes easier to reason about. Open resources in a block when you can, use binary mode for non-text data, and be explicit about encodings when text crosses system boundaries. That is usually enough to keep file code predictable and easy to maintain.
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