skip to content

In Ruby's FileUtils, how do mkdir_p, cp, mv and rm_rf behave, and why is rm_rf riskier than rm_r?

level: middleimportance: should knowfreq 40%

answer

  1. require "fileutils" first
  2. mkdir_p: parents, no error if present
  3. cp refuses directories; cp_r copies trees
  4. mv falls back to copy plus delete
  5. rm_rf is rm_r with force: true

basics

~20 s

FileUtils.mkdir_p creates missing parents and ignores an existing directory; cp copies files but not directories (cp_r does); mv renames, copying across file systems. rm_rf is rm_r with force: true, which swallows errors, so a wrong path fails silently.

solid answer

~40 s

After `require "fileutils"`, `FileUtils.mkdir_p("a/b/c")` creates every missing ancestor and succeeds if the directory exists, unlike `Dir.mkdir`, which raises `Errno::EEXIST` or `Errno::ENOENT`. `FileUtils.cp(src, dest)` copies a file, or into `dest/src` when `dest` is a directory, and raises if `src` is a directory — that needs `cp_r`. `FileUtils.mv` tries `File.rename` and, across file systems, falls back to copy-then-remove. `FileUtils.rm_r` deletes files and trees and raises on failure; `rm_rf` is `rm_r(force: true)`, which **ignores StandardError**, so a missing path, a permissions error or a path built from an empty variable passes without a trace. Guard the path first, dry-run with `noop: true, verbose: true`, and consider `secure: true` in shared directories.

code

ruby · 14 lines
ruby
require "fileutils"

FileUtils.mkdir_p("exports/2026/09")   # creates parents; fine if present
# Dir.mkdir("exports/2026/09")          raises Errno::EEXIST now

FileUtils.cp("report.csv", "exports/2026/09")    # => exports/2026/09/report.csv
FileUtils.cp_r("templates", "exports/templates")  # cp would raise on a directory
FileUtils.mv("exports/2026/09/report.csv", "/mnt/archive/")  # copy+delete across devices

target = File.expand_path(ENV.fetch("CACHE_DIR"), "/srv/app")
raise "refusing to delete #{target}" unless target.start_with?("/srv/app/tmp/")

FileUtils.rm_rf(target, noop: true, verbose: true)  # prints rm -rf ..., deletes nothing
FileUtils.rm_r(target)                               # raises if target is missing

go deeper

for a junior

Recall require "fileutils", that mkdir_p creates parents, cp_r copies directories, and rm_rf deletes trees.

for a middle

Explain mkdir_p versus Dir.mkdir errors, cp versus cp_r, mv's copy fallback across devices, and that rm_rf is rm_r with force: true.

for a senior

Validate paths before recursive deletes, dry-run with noop: and verbose:, use secure: in shared directories, and prefer rm_r when absence is an error.

for a principal

Set guardrails for destructive file operations in scripts and jobs, such as allowed roots and mandatory dry runs, rather than trusting each call site.

## FileUtils in one line `FileUtils` is a default gem (`require "fileutils"`) that gives Ruby the shell's file commands as methods: `mkdir_p`, `cp`, `cp_r`, `mv`, `rm`, `rm_r`, `rm_rf`, `touch` and more. Each takes a path or an array of paths, and most accept `noop:` and `verbose:` keywords. ## mkdir_p versus Dir.mkdir | Call | Parent missing | Directory exists | A file is in the way | |---|---|---|---| | `Dir.mkdir("a/b")` | raises `Errno::ENOENT` | raises `Errno::EEXIST` | raises | | `FileUtils.mkdir_p("a/b")` | creates it | succeeds | raises | `mkdir_p` (aliases `mkpath`, `makedirs`) walks up to the first existing ancestor, creates each missing level, and rescues the "already exists" case only if a directory really is there — so a regular file at that path still raises. It returns its argument wrapped in an array (`["a/b"]`), or the array itself when given one. ## cp, cp_r and mv - **`FileUtils.cp(src, dest)`** copies a file. If `dest` is an existing directory, the copy lands at `dest/src`. If `src` is a directory it raises; use **`cp_r`** to copy a tree. `preserve: true` keeps timestamps. - **`FileUtils.mv(src, dest)`** first tries `File.rename`, which is atomic within one file system. When the rename fails with `Errno::EXDEV` (different devices), it copies the entry and removes the source instead, so a cross-device move is neither instant nor atomic. If `dest` is an existing directory, the source moves inside it as `dest/src`; if that inner path is itself an existing directory, `mv` raises `Errno::EEXIST`. ## rm_r versus rm_rf `FileUtils.rm_r(list)` removes files and directory trees recursively and raises if something goes wrong. `rm_rf` is defined in `lib/fileutils.rb` as: ```ruby def rm_rf(list, noop: nil, verbose: nil, secure: nil) rm_r list, force: true, noop: noop, verbose: verbose, secure: secure end ``` and `force: true` means "ignores raised exceptions of StandardError and its descendants". That is what makes it risky: 1. **A missing path is silent.** A typo deletes nothing and reports nothing. 2. **Permission errors are silent.** A cleanup that never worked looks the same as one that did. 3. **A wrong path is not questioned.** If `dir` comes from configuration and is empty or unset, `File.join(base, dir.to_s)` yields `base/` — the base directory itself — and `rm_rf` removes the whole base tree without complaint. `rm_rf` is still the right call when "already gone" is fine — clearing a cache directory, test teardown — but only for a path you have validated. ## Guarding destructive calls - Check the path before deleting: non-empty, absolute, inside the directory you expect (compare `File.expand_path` results). - Dry-run: `FileUtils.rm_rf(path, noop: true, verbose: true)` prints the equivalent `rm -rf` command and removes nothing. - In a world-writable directory such as the system temp directory, pass **`secure: true`** (or call `FileUtils.remove_entry_secure`), which the rdoc recommends to avoid a time-of-check to time-of-use attack through symlinks swapped in during the delete. - Prefer `rm_r` when the path must exist, so the failure is loud. ## Other commands you will meet - `FileUtils.touch(paths)` creates empty files or updates their timestamps. - `FileUtils.rm_f(paths)` removes files (not directories) and ignores errors, the single-file cousin of `rm_rf`. - `FileUtils.ln_s(target, link)` creates a symlink; `relative: true` makes the link relative. - `FileUtils.install(src, dest, mode: 0o755)` copies and sets permissions in one call. - `FileUtils.compare_file(a, b)` compares two files' contents. For whole-script dry runs, the library ships three modules with the same methods: **`FileUtils::Verbose`** (as if `verbose: true`), **`FileUtils::NoWrite`** (as if `noop: true`) and **`FileUtils::DryRun`** (both). Swapping `FileUtils` for `FileUtils::DryRun` in one constant turns a destructive maintenance script into a report of what it would do. ## Aliases worth recognising `FileUtils.rmtree` is an alias of `rm_rf`, `mkpath`/`makedirs` of `mkdir_p`, `move` of `mv`, `copy` of `cp`. `Pathname#rmtree` calls `FileUtils.rm_rf` under the hood, so it inherits the same silence.

  • Why is FileUtils.mv between two directories on the same disk safer than between two mounts?
    On one file system `mv` uses `File.rename`, which the operating system performs atomically: readers see the old name or the new one, never a half-written file. Across mounts the rename fails with `Errno::EXDEV`, so `mv` copies and then removes the source; a crash in between can leave a partial copy or both copies.
  • What do noop: and verbose: do on FileUtils methods?
    `verbose: true` prints the equivalent shell command, such as `rm -rf tmp/cache`, to standard output before acting. `noop: true` skips the operation entirely. Together they give a dry run that shows exactly what a cleanup would touch.

saying these in an interview costs you the question

  • FileUtils.rm_rf raises if the path does not exist.
  • FileUtils.mkdir_p raises Errno::EEXIST when the directory is already there.
  • FileUtils.cp copies a whole directory tree when given a directory.
  • FileUtils.mv is always atomic, even across different file systems.
  • FileUtils.rm_r and FileUtils.rm_rf behave identically apart from their names.