FileUtils #

Ruby has File and Dir for filesystem operations, but both often need lots of boilerplate code for common tasks like recursively copying directories, creating nested folder structures, or deleting directory trees. FileUtils fills this gap — it provides high-level filesystem operations inspired by Unix commands (cp, mv, rm, mkdir, chmod). A single line of FileUtils.cp_r(source, destination) replaces dozens of lines of manual recursive-copy code. This article covers the entire FileUtils API, the verbose and noop modes useful for debugging, and safe patterns for filesystem manipulation in Ruby scripts.

Getting Started with FileUtils #

FileUtils is available after require "fileutils". All methods are available as module methods — you can call them directly as FileUtils.method_name or include the module into a class.

require "fileutils"

# Direct usage as module methods
FileUtils.mkdir_p("/tmp/test/sub/dir")
FileUtils.touch("/tmp/test/file.txt")

# Or include into a class
class DeployScript
  include FileUtils

  def run
    mkdir_p("releases/current")
    cp_r("dist/.", "releases/current")
    chmod_R(0755, "releases/current/bin")
  end
end

# Verbose mode — print every executed command (like a shell with -v)
FileUtils.mkdir_p("/tmp/test", verbose: true)
# => mkdir -p /tmp/test

FileUtils.cp("a.txt", "b.txt", verbose: true)
# => cp a.txt b.txt

# Noop mode — simulate without real execution (dry run)
FileUtils.rm_rf("/tmp/important", noop: true, verbose: true)
# => rm -rf /tmp/important
# Nothing is actually deleted!
flowchart TD
    A[FileUtils] --> B[Directory Operations]
    A --> C[File Operations]
    A --> D[Permission & Attributes]
    A --> E[Special Modes]

    B --> B1[mkdir / mkdir_p]
    B --> B2[rmdir / rm_rf]
    B --> B3[cd / pwd]

    C --> C1[cp / cp_r]
    C --> C2[mv]
    C --> C3[rm / rm_f / rm_rf]
    C --> C4[touch / install]
    C --> C5[ln / ln_s / ln_sf]

    D --> D1[chmod / chmod_R]
    D --> D2[chown / chown_R]

    E --> E1[verbose: true]
    E --> E2[noop: true]
    E --> E3[FileUtils::Verbose]
    E --> E4[FileUtils::NoWrite]
    E --> E5[FileUtils::DryRun]

Directory Operations #

Creating Directories #

require "fileutils"

# mkdir — create a single directory (parent must exist)
FileUtils.mkdir("/tmp/one_level")

# mkdir_p — create a directory with all missing parents
# This is the most frequently used one
FileUtils.mkdir_p("/tmp/level1/level2/level3")
# Creates /tmp/level1, /tmp/level1/level2, and /tmp/level1/level2/level3

# Create several directories at once
FileUtils.mkdir_p(["log", "tmp/pids", "tmp/sockets", "public/uploads"])

# mkdir_p doesn't error if the directory already exists — safe to call repeatedly
FileUtils.mkdir_p("/tmp/level1/level2/level3")   # no error!

# rmdir — delete an empty directory
FileUtils.rmdir("/tmp/empty")   # errors if not empty

# Recursively removing empty directories from the leaves up
# (rarely used, usually rm_rf is enough)
FileUtils.remove_dir("/tmp/level1")

Changing Directories #

# cd — change the working directory for the duration of a block
FileUtils.cd("/tmp") do
  # the working directory is /tmp during this block
  FileUtils.touch("file_in_tmp.txt")
  puts Dir.pwd   # => /tmp
end
# After the block finishes, back to the original working directory

# cd without a block — permanently change the working directory
# Careful: this changes the process's global state
FileUtils.cd("/tmp")
puts Dir.pwd   # => /tmp

Copying Files and Directories #

cp — Copying Files #

require "fileutils"

# cp — copy a single file
FileUtils.cp("source.txt", "destination.txt")
FileUtils.cp("source.txt", "/tmp/")   # copy to a directory, same name

# Copy several files to a destination directory
FileUtils.cp(["a.txt", "b.txt", "c.txt"], "/tmp/backup/")

# cp with preserve — keep timestamps and permissions
FileUtils.cp("source.txt", "destination.txt", preserve: true)

# cp_lr — copy with hard links (faster, saves disk space)
# The source and destination files share the same inode
FileUtils.cp_lr("source.txt", "link.txt")

cp_r — Recursive Copy #

cp_r is the most frequently used method for copying a directory with all its contents — files, subdirectories, and sub-subdirectories.

# cp_r — recursively copy a directory
FileUtils.cp_r("source_directory", "destination_directory")

# If the destination doesn't exist, it's created with that name
# If it already exists, the source is copied into it

# Copy a directory's contents (with a trailing slash or /.)
FileUtils.cp_r("dist/.", "public/")   # copy dist's contents into public

# Deploy example: copy a build output to a release directory
def create_release(version)
  release_dir = "releases/#{version}"
  FileUtils.mkdir_p(release_dir)
  FileUtils.cp_r("dist/.", release_dir)
  FileUtils.cp("config/production.yml", "#{release_dir}/config/")
  release_dir
end

# cp_r with an exclusion list — no built-in option, needs manual work
def cp_r_except(source, destination, excludes: [])
  FileUtils.mkdir_p(destination)
  Pathname.new(source).glob("**/*").each do |src_path|
    next if excludes.any? { |pattern| src_path.to_s.match?(pattern) }
    next if src_path.directory?

    relative = src_path.relative_path_from(source)
    dst_path = Pathname.new(destination) / relative
    FileUtils.mkdir_p(dst_path.dirname)
    FileUtils.cp(src_path.to_s, dst_path.to_s)
  end
end

cp_r_except("project", "backup",
  excludes: [/\.git/, /node_modules/, /\.DS_Store/])

Moving and Renaming Files #

require "fileutils"

# mv — move or rename files/directories
FileUtils.mv("old.txt", "new.txt")         # rename in place
FileUtils.mv("file.txt", "/tmp/")            # move to another directory
FileUtils.mv("file.txt", "/tmp/new_name.txt")  # move and rename at once

# mv several files to a destination directory
FileUtils.mv(["a.txt", "b.txt"], "/tmp/archive/")

# Pattern: backup before replace
def replace_with_backup(original_file, new_file)
  backup = "#{original_file}.bak"
  FileUtils.cp(original_file, backup) if File.exist?(original_file)
  FileUtils.mv(new_file, original_file)
  backup
end

# Safe mv with an existence check
def move_if_exists(source, destination)
  if File.exist?(source)
    FileUtils.mkdir_p(File.dirname(destination))
    FileUtils.mv(source, destination)
    true
  else
    false
  end
end

Deleting Files and Directories #

Deletion is the operation requiring the most care — there’s no recycle bin in the filesystem!

require "fileutils"

# rm — delete a file (errors if missing)
FileUtils.rm("file.txt")

# rm_f — delete a file, ignore if missing (force)
FileUtils.rm_f("maybe_missing.txt")

# rm with several files at once
FileUtils.rm(["a.txt", "b.txt", "c.txt"])
FileUtils.rm_f(["a.tmp", "b.tmp"])

# rm_r — recursively delete a directory (errors if missing)
FileUtils.rm_r("old_directory")

# rm_rf — recursively delete a directory, ignore if missing (most used)
FileUtils.rm_rf("old_directory")
FileUtils.rm_rf("/tmp/build_output")

rm_rf can’t be undone. Files deleted with rm_rf don’t go to a recycle bin — they’re gone from the disk immediately. Always validate the path before running rm_rf, especially if the path comes from a variable or user input.

# ANTI-PATTERN: deleting directly without validation
def clean_build(build_dir)
  FileUtils.rm_rf(build_dir)   # dangerous if build_dir is "/" or ""
end

# CORRECT: validate the path before deleting
def clean_build(build_dir)
  path = Pathname.new(build_dir).expand_path

  # Make sure we're not deleting root or home
  raise "Path too short, probably wrong!" if path.to_s.split("/").length < 3
  raise "Must not delete the home directory!" if path.to_s.start_with?(Dir.home)

  FileUtils.rm_rf(path.to_s) if path.directory?
end
# Safe pattern: preview with noop, execute after confirmation
def delete_with_confirmation(path)
  puts "Will delete:"
  FileUtils.rm_rf(path, noop: true, verbose: true)

  print "Continue? (y/n): "
  return unless gets.chomp.downcase == "y"

  FileUtils.rm_rf(path)
  puts "Done."
end

require "fileutils"

# ln — hard link (files only, not directories)
FileUtils.ln("target.txt", "link.txt")

# ln_s — symbolic link (symlink)
FileUtils.ln_s("target.txt", "link.txt")
FileUtils.ln_s("/absolute/path/target", "link")

# ln_sf — symbolic link with force (overwrite if it exists)
FileUtils.ln_sf("new_target.txt", "link.txt")   # update an existing symlink

# Deploy pattern: a current symlink to the latest release
def update_current_symlink(release_path)
  current = "releases/current"
  FileUtils.rm(current) if File.symlink?(current)
  FileUtils.ln_s(File.expand_path(release_path), current)
end

Permissions and Ownership #

require "fileutils"

# chmod — change permissions of one file/directory
FileUtils.chmod(0644, "file.txt")     # rw-r--r--
FileUtils.chmod(0755, "script.sh")    # rwxr-xr-x
FileUtils.chmod(0600, "private.key")  # rw-------

# chmod several files at once
FileUtils.chmod(0644, ["a.txt", "b.txt", "c.txt"])

# chmod_R — recursively change permissions
FileUtils.chmod_R(0755, "bin/")         # everything in bin/ becomes executable
FileUtils.chmod_R(0644, "public/")      # all public files world-readable

# chown — change ownership (requires privileges)
FileUtils.chown("www-data", "www-data", "public/")

# chown_R — recursively change ownership
FileUtils.chown_R("deploy", "deploy", "releases/")

# Common post-deploy pattern
def set_production_permissions(app_dir)
  # Directories: 755, files: 644
  Find.find(app_dir) do |path|
    if File.directory?(path)
      FileUtils.chmod(0755, path)
    else
      FileUtils.chmod(0644, path)
    end
  end
  # Special scripts: 755
  FileUtils.chmod_R(0755, File.join(app_dir, "bin"))
end

touch and install #

require "fileutils"

# touch — create an empty file or update the timestamp (like Unix touch)
FileUtils.touch("new_file.txt")          # create an empty file
FileUtils.touch("existing.txt")          # update mtime to now
FileUtils.touch(["a.txt", "b.txt"])       # several files at once

# install — copy while setting permissions
# Very useful for deployment scripts
FileUtils.install("build/app", "/usr/local/bin/app", mode: 0755)
FileUtils.install("config/app.conf", "/etc/app/app.conf", mode: 0644)

Verbose and Dry-Run Modes #

One of FileUtils’ often-overlooked features is built-in support for debugging and dry runs — you can see what will be done without actually doing anything.

require "fileutils"

# verbose: true — print every operation to STDOUT
FileUtils.cp_r("src/", "dst/", verbose: true)
# => cp -r src/ dst/

FileUtils.rm_rf("old_build/", verbose: true)
# => rm -rf old_build/

# noop: true — don't execute, just simulate
FileUtils.rm_rf("/tmp/important", noop: true, verbose: true)
# => rm -rf /tmp/important
# (nothing is actually deleted)

# Combining both = an informative dry run
def deploy(env)
  dry_run = env != "production"
  opts = { verbose: true, noop: dry_run }

  FileUtils.mkdir_p("releases/#{version}", **opts)
  FileUtils.cp_r("dist/.", "releases/#{version}", **opts)
  FileUtils.ln_sf("releases/#{version}", "current", **opts)

  if dry_run
    puts "[DRY RUN] No real changes"
  else
    puts "Deploy done!"
  end
end

# Special modules for different modes
# FileUtils::Verbose — all operations verbose by default
# FileUtils::NoWrite — all operations noop by default
# FileUtils::DryRun  — verbose + noop

class DeployDryRun
  include FileUtils::DryRun   # all operations are dry run + verbose

  def run
    mkdir_p("releases/latest")
    cp_r("dist/.", "releases/latest")
    # All of this is only printed, not executed
  end
end

Real-World Usage Patterns #

A Simple Deploy Script #

require "fileutils"
require "pathname"

class Deployer
  RELEASE_DIR = Pathname.new("releases")
  CURRENT_LINK = Pathname.new("current")
  MAX_RELEASES = 5

  def initialize(build_dir, verbose: false)
    @build = Pathname.new(build_dir)
    @verbose = verbose
    @opts = { verbose: @verbose }
  end

  def deploy
    version = Time.now.strftime("%Y%m%d%H%M%S")
    release_path = RELEASE_DIR / version

    puts "Deploying version #{version}..."

    create_release(release_path)
    update_symlink(release_path)
    clean_old_releases

    puts "Deploy successful: #{release_path}"
    release_path
  end

  private

  def create_release(path)
    FileUtils.mkdir_p(path.to_s, **@opts)
    FileUtils.cp_r("#{@build}/.", path.to_s, **@opts)
    FileUtils.chmod_R(0755, (path / "bin").to_s, **@opts) if (path / "bin").directory?
  end

  def update_symlink(release_path)
    FileUtils.rm(CURRENT_LINK.to_s, **@opts) if CURRENT_LINK.symlink?
    FileUtils.ln_s(release_path.expand_path.to_s, CURRENT_LINK.to_s, **@opts)
  end

  def clean_old_releases
    all_releases = RELEASE_DIR.children.select(&:directory?).sort
    old_releases = all_releases.first([all_releases.length - MAX_RELEASES, 0].max)
    old_releases.each do |r|
      puts "Deleting old release: #{r}"
      FileUtils.rm_rf(r.to_s, **@opts)
    end
  end
end

# Usage
deployer = Deployer.new("build/", verbose: true)
deployer.deploy

Directory Synchronization #

require "fileutils"
require "pathname"
require "digest"

# Simple sync: copy changed files, delete ones that no longer exist
def synchronize(source_dir, destination_dir, verbose: false)
  source = Pathname.new(source_dir)
  destination = Pathname.new(destination_dir)

  FileUtils.mkdir_p(destination.to_s)

  added = 0
  updated = 0
  deleted = 0

  # Copy new or changed files
  source.glob("**/*").select(&:file?).each do |src|
    rel = src.relative_path_from(source)
    dst = destination / rel

    if !dst.exist?
      FileUtils.mkdir_p(dst.dirname.to_s)
      FileUtils.cp(src.to_s, dst.to_s, verbose: verbose)
      added += 1
    elsif Digest::MD5.file(src) != Digest::MD5.file(dst)
      FileUtils.cp(src.to_s, dst.to_s, verbose: verbose)
      updated += 1
    end
  end

  # Delete files in the destination that don't exist in the source
  destination.glob("**/*").select(&:file?).each do |dst|
    rel = dst.relative_path_from(destination)
    src = source / rel

    unless src.exist?
      FileUtils.rm(dst.to_s, verbose: verbose)
      deleted += 1
    end
  end

  { added: added, updated: updated, deleted: deleted }
end

result = synchronize("src/", "backup/", verbose: true)
puts "Sync complete: +#{result[:added]} ~#{result[:updated]} -#{result[:deleted]}"

FileUtils vs Alternatives #

NeedFileUtilsPathnameShell (backtick)
Recursive copycp_rmanual needed`cp -r src dst`
Create dir + parentsmkdir_pmkpath`mkdir -p dir`
Recursive deleterm_rfrmtree`rm -rf dir`
Move/renamemvrename (one fs)`mv src dst`
Recursive permissionschmod_Rnone`chmod -R 755 dir`
Dry runnoop: truenonenone
Cross-platform✗ Windows
Error handlingExceptionExceptionReturn code
# ANTI-PATTERN: using shell commands from Ruby
system("cp -r src/ dst/")          # ✗ not cross-platform, no error handling
`rm -rf #{user_input}`             # ✗ shell injection vulnerability!
system("mkdir -p #{dir}")          # ✗ can be shell-injected

# CORRECT: use FileUtils
FileUtils.cp_r("src/", "dst/")    # ✓ cross-platform, raises exceptions on errors
FileUtils.rm_rf(sanitized_dir)    # ✓ injection-safe
FileUtils.mkdir_p(dir)            # ✓ proper error handling

Summary #

  • mkdir_p for creating directories — always use mkdir_p not mkdir; it creates all missing parents and doesn’t error if the directory already exists.
  • cp_r for copying directories — copies a directory with all its contents recursively; use "src/." as the source to copy the contents without creating a new subdirectory.
  • rm_rf must be validated — always make sure the path to be deleted is correct before calling rm_rf; a wrong delete can’t be undone.
  • verbose: true and noop: true — use for debugging and dry runs; combine both to see what will happen before actually executing.
  • Don’t use shell commands — backticks (`) and system() for file operations are vulnerable to shell injection and aren’t cross-platform; FileUtils is always safer.
  • FileUtils::DryRun for testing scripts — include this module into your deploy/script classes during development so all operations are dry runs by default.
  • Combine with Pathname — use Pathname to build and manipulate paths, then pass .to_s to FileUtils for the actual operations.
  • install for deploymentsFileUtils.install is more expressive than separate cp + chmod when you need to copy a file while setting its permissions.

← Previous: Pathname   Next: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact