IO and Files in Ruby

· 5 min read · Updated March 30, 2026 · beginner
ruby io files file stdlib

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 of IO that adds class methods for path manipulation (File.read, File.exist?, File.basename, etc.).
  • StringIO — an in-memory drop-in for IO. 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:

ModeWhat 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