rubyguides

Working with files and directories in Ruby

Ruby provides powerful built-in tools for working with files and the filesystem as a whole. Whether you’re writing deployment scripts, processing configuration files, or automating backups, Ruby’s file handling capabilities make these tasks straightforward. This tutorial covers the essential classes you’ll use daily as a DevOps engineer.

Intro Context

Filesystem code shows up everywhere in DevOps work. You read config files, move artifacts, rotate logs, and build directories that do not exist yet. Ruby is a good fit for those tasks because the syntax stays readable while the standard library gives you enough power to handle real operating-system work.

The main goal in this tutorial is not to memorize every filesystem method. It is to understand the shape of the problem so you can pick the right tool. Sometimes that means File.read or File.write. Sometimes it means FileUtils. Sometimes it means Pathname. The better you understand the differences, the easier it is to write scripts that are safe to run on more than one machine.

If you want to connect this topic with other Ruby fundamentals, it helps to keep Ruby error handling, file I/O in Ruby, and Ruby strings open in another tab. Those guides explain the surrounding patterns that make filesystem scripts more predictable.

By the end, you will know how to:

  • Read and write files using Ruby
  • Navigate and manipulate directories
  • Use FileUtils for common file operations
  • Work with Pathnames for cross-platform path handling

Reading files in Ruby

The simplest way to read a file in Ruby is using the File.read method. This reads the entire file contents into a string:

For small configuration files, a full read is fine. For larger files, it is often better to think about whether you need the whole file or only one line at a time. Picking the right access pattern early keeps scripts easy to maintain later.

# Read entire file contents
content = File.read('/path/to/file.txt')
puts content

For large files, reading line by line is more memory-efficient. Ruby’s File.foreach iterates through each line without loading the whole file:

That pattern is common in log processing and cleanup jobs. It lets Ruby work through the file as a stream, which keeps the memory footprint small and makes the code easier to adapt when the input grows.

# Process file line by line
File.foreach('/path/to/file.txt') do |line|
  puts line.chomp
end

You can also use a block with File.open, which automatically closes the file when the block finishes:

The block form is the safest default because it keeps the lifecycle obvious. You can see exactly when the file is opened, how it is used, and when Ruby closes it again.

# File is automatically closed after the block
File.open('/path/to/file.txt', 'r') do |file|
  while line = file.gets
    puts line.chomp
  end
end

The second argument to File.open is the mode. Common modes include:

  • r - Read (default)
  • w - Write (creates new file or truncates existing)
  • a - Append
  • r+ - Read and write
  • a+ - Read and append

Those modes do a lot of heavy lifting in a small amount of syntax. When file code fails, the mode is often the first thing to check because a single character can change whether the script reads, writes, truncates, or appends.

Writing files in Ruby

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

Simple writes are ideal when you already have the full content in memory. If the output is tiny, there is no need to build a more complicated writer. Ruby keeps the simple case simple, which is exactly what you want in a script.

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

# Append to file
File.write('/path/to/output.txt', "\nNew line", mode: 'a')

For more control, use File.open with a write mode:

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. The block also guarantees the file is closed, which prevents resource leaks in longer scripts:

# Write with explicit file handling
File.open('/path/to/output.txt', 'w') do |file|
  file.puts 'First line'
  file.puts 'Second line'
  file.write 'Third line without newline'
end

Working with directories

The Dir class provides directory navigation capabilities. You use it to discover inputs, walk through a tree, or decide where to store output. Directory code often becomes the glue around a larger task:

Directory code often becomes the glue around a larger task. You use it to discover inputs, walk through a tree, or decide where to store output. That makes it worth understanding even if the rest of your script focuses on file content.

# List all files in a directory
Dir.entries('/path/to/directory').each do |entry|
  puts entry unless entry.start_with?('.')
end

# Glob pattern matching
Dir.glob('**/*.rb').each do |file|
  puts file
end

Create and delete directories:

These operations are common in automation scripts because the filesystem rarely looks exactly the way you want it to look before the script runs. Creating parent directories and removing empty ones are small operations, but they remove a lot of manual setup.

# Create directory (and parent directories if needed)
Dir.mkdir('/path/to/new_directory')

# Create parent directories recursively
Dir.mkdir_p('/path/to/nested/directory')

# Delete directory (must be empty)
Dir.delete('/path/to/empty_directory')

Check if paths exist:

Path checks are the simplest guardrail you can add to a script. They let you fail early, choose a fallback path, or skip work that would otherwise raise an exception later.

# Path existence checks
puts File.exist?('/path/to/file.txt')    # true or false
puts File.directory?('/path/to/dir')     # true or false
puts File.file?('/path/to/file.txt')     # true or false

FileUtils: power tools for file operations

The FileUtils module provides convenient methods for common operations. Require it first:

FileUtils is the part of the standard library that makes filesystem automation feel practical. Instead of writing the same copy, move, and remove logic by hand, you can call methods that already know how to do the right thing.

require 'fileutils'

# Copy a file
FileUtils.cp('source.txt', 'destination.txt')

# Copy directory recursively
FileUtils.cp_r('source_dir/', 'dest_dir/')

# Move or rename files
FileUtils.mv('old_name.txt', 'new_name.txt')

# Delete files
FileUtils.rm('unwanted.txt')

# Delete directory recursively
FileUtils.rm_rf('unwanted_directory/')

Create backups with timestamp:

This pattern is especially common in DevOps work because backups need traceability. A timestamp in the backup name tells you when the copy was made and helps avoid accidental overwrites.

require 'fileutils'
require 'time'

timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
backup_name = "config_#{timestamp}.yaml"
FileUtils.cp('config.yaml', backup_name)
puts "Backup created: #{backup_name}"

Pathname: cross-platform path handling

The Pathname class provides an object-oriented way to handle file paths. It handles cross-platform path separators automatically:

Pathname becomes handy when you need to compose paths out of several parts or pass a path through several methods. Because it is an object, it gives you a little more structure than a raw string, which makes path manipulation easier to read and test.

require 'pathname'

# Create pathname objects
path = Pathname.new('/home/user/project/file.txt')

# Get file information
puts path.basename    # file.txt
puts path.dirname    # /home/user/project
puts path.extname    # .txt
puts path.basename   # file.txt
puts path.dirname   # /home/user/project

# Check path properties
puts path.exist?     # true
puts path.directory? # false
puts path.file?      # true

Build paths safely:

This style is useful when the same script runs in more than one environment. A path built from components is easier to move between machines than one hard-coded string with separators copied from a single operating system.

require 'pathname'

base = Pathname.new('/home/user/project')
config_dir = base + 'config' + 'environments'
puts config_dir.to_s  # /home/user/project/config/environments

Pathname also handles file extensions elegantly:

Extension handling is another small thing that saves time. If you are rotating files, renaming outputs, or converting a file from one format to another, it is much nicer to ask Pathname to do the filename surgery for you.

require 'pathname'

path = Pathname.new('script.rb')

puts path.extname      # .rb
puts path.sub_ext('.py') # script.py
puts path.basename('.rb') # script

Practical example: log file processor

Here’s a practical DevOps script that processes log files:

The log processor ties together the ideas from the earlier sections. It discovers files, reads them line by line, counts interesting events, and moves the result out of the working directory. That is a realistic shape for a lot of automation scripts.

require 'fileutils'
require 'pathname'

class LogProcessor
  def initialize(log_dir, archive_dir)
    @log_dir = Pathname.new(log_dir)
    @archive_dir = Pathname.new(archive_dir)
  end

  def process
    Dir.glob(@log_dir.join('*.log')).each do |log_file|
      process_log(log_file)
    end
  end

  private

  def process_log(log_path)
    path = Pathname.new(log_path)
    puts "Processing: #{path.basename}"

    error_count = 0
    File.foreach(log_path) do |line|
      error_count += 1 if line.include?('ERROR')
    end

    puts "  Found #{error_count} error(s)"

    # Archive processed log
    archive_path = @archive_dir.join("#{path.basename}.#{Time.now.strftime('%Y%m%d')}")
    FileUtils.mv(log_path, archive_path)
    puts "  Archived to: #{archive_path}"
  end
end

# Usage
processor = LogProcessor.new('/var/logs/app', '/var/logs/archive')
processor.process

When to use each approach

For simple file reading, File.read is perfect. When processing large files or needing line-by-line control, use File.foreach or File.open with a block.

Choose FileUtils for operations that touch more than one file or directory and when you want the intent to be obvious in a script. Choose Pathname when the path itself is part of the logic, because the object methods make path transformation easier to follow.

Use FileUtils for operations that involve multiple steps or when you need verbose output options. The cp_r and rm_rf methods are particularly useful for directory operations.

Choose Pathname when you’re building paths from multiple components or need to extract parts of a path (extension, basename, dirname). Pathname objects are also easier to pass around in methods since they’re just objects.

Conclusion

Ruby’s filesystem libraries provide everything you need for DevOps automation. Start with the simple File.read and File.write methods, then add FileUtils and Pathname as your needs grow. These tools work together, allowing you to build scripts for file processing, backups, deployments, and more.

The next tutorial moves from filesystem work into command-line tooling. That is a good next step for DevOps scripts because the same knowledge that helps you read and write files also helps you build tools that accept arguments, produce helpful output, and fit into shell pipelines.

In the next tutorial, we’ll explore building CLI tools with Thor, which builds on these file handling concepts to create user-friendly command-line applications.