rubyguides

Working with Ruby file I/O and paths

Working with Ruby file I/O is a fundamental skill for any Ruby developer. Whether you are processing log files, reading configuration data, or generating reports, Ruby provides a clear API for working with files. In this tutorial, you will learn how to read from and write to files, work with paths safely, and follow practices that make your code predictable under real-world conditions.

Intro context

File work is one of the first places Ruby feels practical. You can open a file, inspect its content, and write the result back out without a large amount of ceremony. That makes Ruby a good fit for scripts, admin tools, and small automation tasks where you want the code to be readable the next time you open it.

The other reason file I/O matters is that it exposes a few habits that show up everywhere else in Ruby. Block-based APIs teach you to release resources cleanly. Error handling teaches you to expect missing paths and permission problems. Path handling teaches you to be explicit about where files live instead of relying on assumptions that only work on your own machine.

If you are still getting comfortable with the language, this topic also connects nicely with getting started with Ruby, Ruby error handling, and Ruby strings. Those pages help explain the surrounding patterns that make file code easier to write and easier to debug.

A quick preview shows how little code it takes to open a file and inspect its content:

File.open("hello.txt", "w") { |f| f.puts "Hello from Ruby" }
puts File.read("hello.txt")   # => "Hello from Ruby\n"

Reading files with Ruby file I/O

Ruby makes reading files straightforward with several approaches depending on your needs. The simplest way to read an entire file into memory is with File.read:

Choosing the reading strategy depends on file size and on how much control you need. For a small config file, a one-shot read is fine. For a log file or a data export, line-by-line processing is often the better choice because it keeps memory usage steady.

# Read entire file contents as a string
content = File.read("config.txt")
puts content

# Read file into an array of lines
lines = File.readlines("config.txt")
puts lines.first

For large files, reading line-by-line is memory-efficient and prevents loading the entire file into RAM. The IO.foreach method processes one line at a time:

This pattern is especially helpful in scripts that scan logs or search through generated output. It lets Ruby stream the file instead of materializing the entire content at once, which keeps the script responsive when the input grows.

# Process file line by line - memory efficient
IO.foreach("large_log.txt") do |line|
  puts line if line.include?("ERROR")
end

If you need more control over the reading process, open the file manually with a block:

The block form is worth learning early because it shows up in most safe file code. When Ruby opens the file for you and closes it automatically, you reduce the chance of leaking file handles and you make the control flow easier to follow.

# Open file and read with control
File.open("input.txt", "r") do |file|
  puts file.gets      # Read one line
  puts file.read(10) # Read 10 bytes
  puts file.pos      # Current position in file
end

The block form of File.open automatically closes the file when done, even if an error occurs.

That automatic cleanup is one of the reasons block style is the default recommendation. It is not just shorter, it is also harder to misuse. Once you get used to it, the block form becomes the natural way to handle any file that should not stay open longer than the work itself.

How do you write files in Ruby?

Writing files follows similar patterns. Use File.write for quick one-liners:

Writing is the mirror image of reading, but the decisions are slightly different. You need to think about overwrite versus append, whether the file should be created if it does not exist, and whether you want to write the whole payload at once or build it piece by piece.

# Write string to file (overwrites existing content)
File.write("output.txt", "Hello, World!")

# Append to existing file
File.write("log.txt", "New log entry\n", mode: "a")

# Write array of lines
lines = ["line one", "line two", "line three"]
File.write("data.txt", lines.join("\n"))

For more complex writing scenarios, open the file with a block:

The block form gives you a place to build the output step by step. That matters when the file is large, when you want to format several lines differently, or when the output is assembled from multiple data sources.

# Write using block form - automatically closes file
File.open("output.txt", "w") do |file|
  file.puts "First line"
  file.puts "Second line"
  file.write "Raw string without newline"
end

The file mode determines how the file is opened: “r” for read, “w” for write (truncates), “a” for append.

File modes are worth memorizing because they explain a lot of Ruby’s file behavior in a single line. If the mode is wrong, the rest of the code often looks fine but still produces the wrong result, so it is worth checking the mode first when a file behaves unexpectedly.

Working with file paths

Working with paths correctly ensures your code works across different operating systems. Ruby provides helpful methods:

Path helpers are a small but important part of portable code. A path that works on your laptop can fail on another machine if it depends on a separator or a directory layout that only exists locally. Using Ruby’s helpers reduces that risk and makes the code easier to read.

# Join path components - handles OS differences automatically
path = File.join("data", "config", "settings.txt")

# Expand relative path to absolute path
absolute_path = File.expand_path("config.txt")

# Get file information
puts File.size("large_file.txt")
puts File.dirname("/home/user/project/file.txt")
puts File.extname("photo.jpg")
puts File.basename("/path/to/file.txt")

# Check file properties
File.exist?("config.txt")
File.directory?("folder")
File.file?("config.txt")
File.readable?("config.txt")
File.writable?("config.txt")

Using fileutils for common operations

For common file and directory operations, Ruby’s FileUtils module provides a rich set of methods:

FileUtils is most useful when a task involves more than one filesystem step. Copying, moving, creating, and deleting files are all easy to express here, and the code stays readable because the method names describe the action directly.

require "fileutils"

# Directory operations
FileUtils.mkdir_p("data/exports")
FileUtils.cp("source.txt", "backup.txt")
FileUtils.mv("old.txt", "new.txt")
FileUtils.rm_rf("temp_files")
FileUtils.touch("access.log")

For the official Ruby documentation on file I/O, see the Ruby IO class reference.

Best practices

Following these practices will make your file handling code more reliable:

These practices are simple, but they save time. Most file bugs come from forgetting to close a file, handling the wrong mode, or assuming the file is always there. A few disciplined habits prevent those problems before they turn into production issues.

Always use blocks with File.open when possible. The block ensures the file is closed even if an error occurs:

# Good: Block form keeps the file lifecycle obvious
File.open("data.txt") do |file|
  # work with file
end

The block form also makes it clearer where a file’s lifetime begins and ends. When you see File.open("data.txt") do |file|, you know the file handle exists only inside that block and that Ruby will close it afterward. This matters most in longer methods where the open and close might otherwise be separated by many lines of logic. The discipline of keeping file operations inside a block also trains you to think about resource ownership, which carries over to database connections, network sockets, and other resources that follow the same open-use-close pattern.

Handle errors gracefully with begin/rescue/ensure:

begin
  content = File.read("important.txt")
rescue Errno::ENOENT
  puts "File not found"
rescue Errno::EACCES
  puts "Permission denied"
end

Use proper encoding when working with text files:

Encoding is easy to overlook until a file contains a character that does not match your default locale. Being explicit about encoding helps avoid surprises when your script moves between machines or handles non-ASCII text.

File.read("data.txt", encoding: "UTF-8")
File.write("output.txt", "Content", encoding: "UTF-8")

Working with temporary files

For temporary data, Ruby’s Tempfile class is ideal:

Temporary files are useful when you need a scratch area that should disappear after the task is done. They are common in report generation, uploads, and any workflow that needs a safe place to assemble data before it is written somewhere permanent.

require "tempfile"

tempfile = Tempfile.new("my_app")
tempfile.write("Temporary data")
tempfile.close
puts tempfile.path
tempfile.unlink

Summary

You have learned the fundamentals of working with Ruby file I/O: reading files with File.read and IO.foreach, writing with File.write and File.open, path handling with File.join and File.expand_path, FileUtils for common operations, and best practices including using blocks, error handling, and proper encoding.

The next tutorial in the sequence covers how Ruby works with text and patterns in strings. That is a natural follow-up because file code usually ends by parsing content, formatting output, or validating what it read from disk.

Binary files

Ruby handles binary files the same way as text files, but you need to specify the binary mode:

Binary mode matters when the file contains raw data rather than human-readable text. Images, archives, and compiled assets should be treated as bytes, not as strings, so Ruby does not try to interpret the content.

# Read binary file
File.binread("image.png")

# Write binary data
File.binwrite("output.bin", binary_data)

# Read binary file with block
File.open("image.png", "rb") do |file|
  data = file.read
  puts data.bytesize
end

When working with binary files, always use “b” in the mode string to prevent Ruby from trying to interpret the data as text.

Reading CSV files

For structured data like CSV files, Ruby’s built-in CSV library makes parsing straightforward:

CSV sits in the middle between raw text and structured records. It is a good example of how file I/O often feeds into a higher-level parser, and it shows why a file reader alone is only the first step in a real data workflow.

require "csv"

# Read CSV file
CSV.foreach("data.csv", headers: true) do |row|
  puts row["name"]
  puts row["email"]
end

# Parse CSV from string
csv_data = "name,age\nAlice,30\nBob,25"
CSV.parse(csv_data, headers: true) do |row|
  puts row["name"]
end

Working with JSON

Reading and writing JSON files is equally straightforward with the json library:

JSON follows the same pattern. Read the file, parse it into Ruby objects, change what you need, and write it back out. The file handling part is simple, but the parsing step is where you decide what the data means in your application.

require "json"

# Read JSON file
data = JSON.parse(File.read("config.json"))

# Write JSON file
File.write("output.json", JSON.pretty_generate(data))

File locking

When multiple processes might access the same file, file locking prevents conflicts:

Locking is one of those details that is easy to skip in a script and easy to regret later in production. If more than one process can touch the same file, a lock turns a race condition into a controlled sequence of work.

File.open("shared.txt", "r+") do |file|
  file.flock(File::LOCK_EX)  # Exclusive lock
  # Read or write safely
  file.flock(File::LOCK_UN)  # Release lock
end

Ruby supports shared locks (LOCK_SH) for reading and exclusive locks (LOCK_EX) for writing.