IO, Files and Directories

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them. Every Ruby program talks to the outside world through file descriptors: standard input and output, files, directories, pipes and sockets. Ruby wraps them in a small family of classes: IO is the base class with the reading, writing and buffering behavior, File adds the file system (names, permissions, locking), Dir lists and creates directories, Pathname is an object-oriented view of a path, FileUtils is the shell-like toolbox (mkdir -p, cp -r, rm -rf), and Tempfile and StringIO give you scratch files and in-memory streams that behave like files. This page covers all of them: opening and closing files safely, modes and flags, encodings and newline conversion, reading and writing whole files, lines, bytes and chunks, paths and metadata, links, globbing, Pathname (a core class from Ruby 4.0), FileUtils, temporary files, StringIO, IO::Buffer, pipes, non-blocking reads, locking, atomic replacement, error handling and path safety.

Neighboring topics live elsewhere: how strings carry encodings is in Encodings and Unicode, spawning programs and the removal of Kernel#open("|cmd") in 4.0 are in Processes, Signals and Shell, sockets and HTTP are in Networking and HTTP, reading and writing JSON, YAML, CSV and Marshal is in Serialization: JSON, YAML, CSV and Marshal, the Errno exception classes are placed in the hierarchy by Exceptions and Error Handling, and the safety side of file names and temporary files is revisited in Security. For the same ideas in other languages see Go, Rust and Python.

Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings), apart from one deliberate exception that is marked where it occurs. The output shown is the real output. Examples that create files work inside a directory made by Dir.mktmpdir, which is removed when its block ends, so they leave nothing behind; temporary directory names are abbreviated in the output, and examples were run with a UTF-8 locale. Each example is self-contained; the ones that use the shelf scenario say so.

The I/O classes at a glance

Class or module What it is for Available

IO

Streams over file descriptors: read, write, gets, each_line, pipe, select, copy_stream, buffering and encodings.

Core.

File (a subclass of IO)

Named files: open, read, write, rename, delete, stat, flock, path functions (basename, join, expand_path).

Core.

File::Stat, File::Constants, FileTest

File metadata objects, the flag constants (File::RDONLY, File::LOCK_EX, File::FNM_DOTMATCH) and predicate functions.

Core.

Dir

Listing, globbing, creating and removing directories, the working directory.

Core. Dir.mktmpdir and Dir.tmpdir need require "tmpdir".

Pathname

A path as an object with /, join, children, read, glob and more.

Core from Ruby 4.0 (a default gem that needed require "pathname" before). Only find and rmtree still need the require.

FileUtils

Shell-like operations: mkdir_p, cp, cp_r, mv, rm_rf, touch, ln_s, compare_file.

require "fileutils" (default gem).

Tempfile

Temporary files that remove themselves.

require "tempfile" (default gem).

StringIO

An in-memory object with the IO interface.

require "stringio" (default gem).

Find

Depth-first directory walk with pruning.

require "find" (default gem).

IO::Buffer

A block of memory (or a mapped file) addressed by offset and typed values; the basis of the fiber scheduler’s io_read and io_write hooks.

Core, still reported as experimental.

ARGF, DATA, $stdin, $stdout, $stderr

The program’s input files, its own trailing data, and the standard streams.

Core.

All the file system functions raise a subclass of SystemCallError (the Errno family) when the operating system reports a failure, so a missing file is Errno::ENOENT and a permission problem is Errno::EACCES; the section on errors lists them. Kernel#open and the old IO.popen shortcuts are not covered here: prefer File.open for files, and see Processes, Signals and Shell for processes.

Opening and closing files

File.open with a block

A file descriptor is a limited operating-system resource, and a buffered stream can hold unwritten data, so every file that is opened must be closed. The idiom that makes this automatic is the block form: File.open(path, mode) { |file| ... } opens the file, yields it, closes it when the block ends (normally, through break or return, or through an exception) and returns the value of the block. Without a block, File.open and File.new return an open file and you must call close yourself, normally inside an ensure. Using a closed stream raises IOError with the message closed stream.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "notes.txt")

  # The block form closes the file when the block ends, even on an exception.
  result = File.open(path, "w") do |file|
    file.puts "first line"
    file.class
  end
  puts "block value: #{result}"

  leaked = nil
  begin
    File.open(path) do |file|
      leaked = file
      raise "boom"
    end
  rescue RuntimeError => e
    puts "rescued #{e.message}, closed? #{leaked.closed?}"
  end

  # Without a block you own the file and must close it.
  file = File.new(path)
  puts file.read
  file.close
  puts "closed? #{file.closed?}"

  begin
    file.read
  rescue IOError => e
    puts "#{e.class}: #{e.message}"
  end

  # IO#path (3.2) returns the path the file was opened with.
  puts File.open(path, &:path) == path
end

Output:

block value: File
rescued boom, closed? true
first line
closed? true
IOError: closed stream
true

The one-shot class methods File.read, File.write, File.readlines, File.foreach and their binread and binwrite variants open and close the file for you, so use them whenever the whole operation is a single call. Reach for the block form of File.open when you need several operations on one handle or a specific mode. IO#path (Ruby 3.2) returns the path a file was opened with, and File.new and File.open accept the same arguments; File.open with a block is the one that closes for you.

A file that is garbage collected without being closed is closed by a finalizer, but you cannot predict when, and written data is only guaranteed to be flushed by close or flush. Do not depend on it.

Modes and flags

The second argument of File.open is a mode string, an integer made from File::Constants flags, or absent (read-only). The keyword form mode:, flags: and perm: is equivalent and often clearer.

Mode Meaning

"r"

Read only, starting at the beginning. The file must exist (Errno::ENOENT otherwise). The default.

"r+"

Read and write, starting at the beginning, without truncating. The file must exist.

"w"

Write only. Creates the file or truncates it to zero length.

"w+"

Read and write. Creates or truncates.

"a"

Write only, every write at the end of the file (append). Creates the file if needed.

"a+"

Read and append. Reads start at the beginning, writes go to the end.

"b" suffix

Binary mode ("rb", "wb"): no newline conversion, and the external encoding is ASCII-8BIT unless you name one.

"t" suffix

Text mode ("rt"): universal newline conversion on read.

"x" suffix

With "w" only ("wx"): fail with Errno::EEXIST if the file exists, which makes creation exclusive and race-free.

":enc" suffix

An external (and optionally internal) encoding, "r:iso-8859-1:utf-8".

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "log.txt")

  File.write(path, "one\n")
  File.open(path, "a") { |f| f.puts "two" }
  puts File.read(path).inspect # => "one\ntwo\n"

  File.open(path, "r+") do |f|
    f.write("ONE")
    f.rewind
    puts f.read.inspect
  end

  File.open(path, "w") { |f| f.print "truncated" }
  puts File.size(path)

  # "x" adds File::EXCL: fail if the file exists.
  begin
    File.open(path, "wx") { |f| f.puts "never" }
  rescue Errno::EEXIST => e
    puts "#{e.class}: #{e.message.sub(dir, '<dir>')}"
  end
end

Output:

"one\ntwo\n"
"ONE\ntwo\n"
9
Errno::EEXIST: File exists @ rb_sysopen - <dir>/log.txt

The integer form is the one that exposes everything the operating system offers. The flags are the constants in File::Constants (also reachable as File::RDONLY and so on): RDONLY, WRONLY, RDWR, APPEND, CREAT, EXCL, TRUNC, NONBLOCK, NOCTTY, SYNC, DSYNC, NOFOLLOW, BINARY and SHARE_DELETE. Combine them with |. The third argument is the permission mask applied when the file is created (before the process umask), in octal. A new file that holds anything sensitive should be created with a restrictive mask such as 0o600.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  # Integer flags and a permission argument.
  flags = File::WRONLY | File::CREAT | File::TRUNC
  path = File.join(dir, "private.txt")
  File.open(path, flags, 0o600) { |f| f.puts "first" }
  puts format("%o", File.stat(path).mode & 0o777)

  # Keyword form of mode:, flags: and perm:.
  File.open(path, mode: "a", perm: 0o600) { |f| f.puts "more" }
  puts File.readlines(path, chomp: true).inspect

  begin
    File.open(File.join(dir, "missing.txt"))
  rescue Errno::ENOENT => e
    puts "#{e.class}: #{e.message.sub(dir, '<dir>')}"
    puts e.errno == Errno::ENOENT::Errno
  end
end

Output:

600
["first", "more"]
Errno::ENOENT: No such file or directory @ rb_sysopen - <dir>/missing.txt
true

File::NULL is the platform’s null device (/dev/null, or NUL on Windows), handy as a sink. IO#sync = true turns off Ruby’s write buffering for that stream, IO#flush pushes buffered data to the operating system, and IO#fsync and IO#fdatasync ask the operating system to push it to the disk; they are the difference between "written" and "durable".

Kernel#open and other shortcuts

Kernel#open("path") opens a file like File.open, but until Ruby 3.3 a path starting with | ran a shell command instead (deprecated in 3.3 together with the same behavior of IO.read, IO.readlines, IO.foreach, IO.write, IO.binread and URI.open). In Ruby 4.0 that behavior is removed: all of these now treat the string as a file name, and a name such as "|echo hi" raises Errno::ENOENT. Use File.open (or File.read) for files and Open3 or IO.popen with an argument array for programs; the processes page covers the command-injection reasons.

Encodings and newlines

A file holds bytes; Ruby turns them into strings with an encoding. Every IO has an external encoding (how the bytes in the file are interpreted) and an optional internal encoding (what the strings you get should be converted to). The external encoding defaults to Encoding.default_external, which comes from the locale (LANG, LC_ALL) and is UTF-8 on a typical modern system; with no internal encoding set, strings are tagged with the external encoding and no conversion happens. Setting both, as in "r:iso-8859-1:utf-8", makes Ruby transcode while reading. Writing converts from the string’s encoding to the external encoding when they differ. The command-line flags -E ext:int and -U change the defaults for the whole process.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "latin1.txt")
  File.binwrite(path, "caf\xE9\n".b)

  File.open(path) { |f| puts f.read.encoding } # default external, UTF-8
  File.open(path, "r:iso-8859-1") { |f| puts f.read.encoding }

  # external:internal transcodes while reading.
  File.open(path, "r:iso-8859-1:utf-8") do |f|
    text = f.read
    puts "#{text.inspect} #{text.encoding}"
  end

  # Keyword form.
  text = File.read(path, encoding: "iso-8859-1:utf-8")
  puts text.valid_encoding?

  # Writing transcodes from the string encoding to the external one.
  out = File.join(dir, "out.txt")
  File.write(out, "café", encoding: "iso-8859-1")
  p File.binread(out) # => "caf\xE9"

  # Byte order marks: "bom|utf-8" strips a UTF-8 BOM.
  bom = File.join(dir, "bom.txt")
  File.binwrite(bom, "\xEF\xBB\xBFhello".b)
  p File.read(bom).start_with?("\uFEFF")
  p File.read(bom, encoding: "bom|utf-8")

  # Binary mode: no newline conversion, ASCII-8BIT strings.
  File.open(path, "rb") { |f| puts f.read.encoding }
  puts Encoding.default_external
end

Output:

UTF-8
ISO-8859-1
"café\n" UTF-8
true
"caf\xE9"
true
"hello"
ASCII-8BIT
UTF-8

Things to remember:

  • A file with a byte order mark needs "bom|utf-8": without it the BOM stays in the string as "" and breaks the first comparison or CSV header. IO#set_encoding_by_bom does the same on an already-open binary stream.

  • Binary mode ("rb", File.binread, IO#binmode) gives ASCII-8BIT (Encoding::BINARY) strings and disables newline and encoding conversion. Use it for images, archives, Marshal data and anything you pass to pack and unpack.

  • Reading invalid bytes does not raise: the string just reports valid_encoding? as false, and the error surfaces later, for example in a regular expression match. Transcoding with "r:ext:int" does raise Encoding::InvalidByteSequenceError or Encoding::UndefinedConversionError, unless you pass invalid: :replace and undef: :replace (with replace:).

  • Encoding.default_internal is nil unless you set it; setting it globally is discouraged because every library then gets transcoded text.

Newline conversion

On Unix a file is read as is: a CRLF line ending keeps its \r. Text mode ("rt") or the universal_newline: true option converts CRLF and CR to LF on read, and crlf_newline: true or cr_newline: true converts LF on write (newline: :universal, :crlf, :cr and :lf are the keyword spellings). chomp: true on the line-reading methods strips either ending. On Windows, text mode (the default there) converts automatically, which is why files written on one system sometimes carry stray \r on the other.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "crlf.txt")
  File.binwrite(path, "a\r\nb\r\n")

  p File.read(path)                                     # binary-safe default on Unix
  p File.read(path, mode: "rt")                         # text mode, universal newlines
  p File.open(path, universal_newline: true, &:read)
  p File.readlines(path, chomp: true)                   # chomp strips CRLF too

  out = File.join(dir, "out.txt")
  File.open(out, "w", crlf_newline: true) { |f| f.puts "x" }
  p File.binread(out)

  File.open(path) do |f|
    f.timeout = 2.5 # IO#timeout raises IO::TimeoutError on blocking reads
    puts f.timeout
    f.sync = true
    f.fsync
    f.fdatasync
  end
  puts IO::TimeoutError.ancestors.first(3).inspect
end

Output:

"a\r\nb\r\n"
"a\nb\n"
"a\nb\n"
["a", "b"]
"x\r\n"
2.5
[IO::TimeoutError, IOError, StandardError]

The last lines of that example show IO#timeout (Ruby 3.2): a stream can be given a timeout in seconds after which a blocking read or write raises IO::TimeoutError (a subclass of IOError) instead of waiting forever. sync, fsync and fdatasync are described above.

Reading and writing

Whole-file helpers

Method What it does

File.read(path), File.read(path, length, offset)

The whole file, or length bytes starting at offset, as a string in the external encoding.

File.binread(path)

The same in binary mode (ASCII-8BIT).

File.write(path, string)

Creates or truncates the file, writes, closes, and returns the number of bytes written. Accepts mode: "a" to append, offset: to overwrite from a position, and perm:.

File.binwrite(path, string)

Binary-mode write.

File.readlines(path, chomp: true)

An array of lines. Loads the whole file: avoid for large files.

File.foreach(path, chomp: true)

Yields one line at a time (an enumerator without a block); memory use stays flat.

IO.read, IO.write, IO.readlines, IO.foreach, IO.binread, IO.binwrite

The same methods on IO; they take file names too, so they work like the File versions (since 4.0 without the pipe-command form).

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "books.txt")
  bytes = File.write(path, "Dune\nEmma\nUlysses\n")
  puts "wrote #{bytes} bytes"

  puts File.read(path, 4).inspect         # first four bytes
  puts File.read(path, 4, 5).inspect      # four bytes from offset 5
  p File.readlines(path)
  p File.readlines(path, chomp: true)

  File.foreach(path, chomp: true).with_index(1) do |line, number|
    puts "#{number}: #{line}"
  end
end

Output:

wrote 18 bytes
"Dune"
"Emma"
["Dune\n", "Emma\n", "Ulysses\n"]
["Dune", "Emma", "Ulysses"]
1: Dune
2: Emma
3: Ulysses

File.read(path) loads the whole file into one string, which is simple and fast for files up to a few megabytes and wrong for a log file of several gigabytes. For big inputs iterate with File.foreach, IO#each_line or IO#read(size) in a loop. For copying use IO.copy_stream (below), which does not build the file in memory. File.write replaces the file in place: if your program is killed in the middle, the file is truncated or partial; the atomic replacement pattern later in this page avoids that.

The cursor: gets, readline, each_line, seek

An open file has a position. gets returns the next line (including its newline) or nil at the end of the file; readline does the same but raises EOFError at the end, which suits a protocol where the end is unexpected. read without an argument reads to the end and returns "" at the end of the file, while read(n) returns nil at the end. eof? tests the position, pos, seek, rewind and lineno move and report it, IO::SEEK_SET, IO::SEEK_CUR and IO::SEEK_END select the reference point (seek also accepts :SET, :CUR and :END symbols). each_line, each_char, each_byte, readlines and readchar and readbyte read from the cursor, and the line methods accept a separator ("\n" by default; nil means "everything"; "" means "paragraph mode", split on blank lines), a byte limit and chomp: true.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "books.txt")
  File.write(path, "Dune\nEmma\nUlysses\n")

  File.open(path) do |file|
    puts file.gets.inspect
    puts file.lineno
    puts file.readline(chomp: true)
    puts file.pos
    file.each_line { |line| puts "rest: #{line.chomp}" }
    puts file.eof?
    puts file.gets.inspect # nil at end of file
    begin
      file.readline
    rescue EOFError => e
      puts "#{e.class}: #{e.message}"
    end
    file.rewind
    file.seek(-8, IO::SEEK_END)
    puts file.read.inspect
  end
end

Output:

"Dune\n"
1
Emma
10
rest: Ulysses
true
nil
EOFError: end of file reached
"Ulysses\n"

Reading in a loop of read(size) until eof? or read returns nil is the pattern for binary data. IO#readpartial(maxlen) returns whatever is available (at least one byte) and raises EOFError at the end: it is the right call for pipes and sockets, where read(n) would wait for all n bytes. IO#pread(length, offset) and IO#pwrite(string, offset) read and write at an offset without moving the position, which avoids races on the shared position when several threads read one file.

Writing

puts writes its arguments each followed by a newline (and flattens arrays), print writes without adding anything, write writes any number of strings and returns the byte count, << writes one object and returns the stream so calls chain, and printf formats. Output is buffered by Ruby; close flushes, and so does flush or sync = true. In "a" mode every write goes to the end even if you seek elsewhere.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "books.txt")
  File.write(path, "Dune\nEmma\nUlysses\n")

  File.open(path, "a") do |file|
    file << "Hamlet" << "\n"
    file.printf("%-8s|\n", "pad")
    file.write("a", "b", "\n")
  end
  puts File.read(path)

  # Reading in chunks keeps memory use flat.
  chunks = []
  File.open(path) { |f| chunks << f.read(10) until f.eof? }
  p chunks.map(&:size)

  # Splitting by a separator or limit.
  File.open(path) { |f| p f.gets("a") }
  File.open(path) { |f| p f.gets(nil, 6) }
  File.open(path) { |f| p f.each_char.first(3) }
  File.open(path) { |f| p f.each_byte.first(3) }
end

Output:

Dune
Emma
Ulysses
Hamlet
pad     |
ab
[10, 10, 10, 8]
"Dune\nEmma"
"Dune\nE"
["D", "u", "n"]
[68, 117, 110]

The $stdout and $stderr objects are ordinary IO instances (puts and warn are shortcuts to them), so everything above applies, and $stdout = StringIO.new redirects output for tests. $stdout.sync is false by default: output is line-buffered on a terminal but block-buffered when piped, which is why output sent to another program can appear late or interleave with $stderr. Set $stdout.sync = true for logs that must show up immediately.

Copying and binary data

IO.copy_stream(src, dst, copy_length = nil, src_offset = nil) copies between file names, IO objects or any object with readpartial and write, and returns the number of bytes copied. It uses the operating system’s fast copy when it can and does not allocate a string for the whole content.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  src = File.join(dir, "src.bin")
  dst = File.join(dir, "dst.bin")
  File.binwrite(src, [1, 2, 3, 255].pack("C*"))
  p File.binread(src).bytes

  copied = IO.copy_stream(src, dst)
  puts "copied #{copied} bytes"
  puts File.binread(dst) == File.binread(src)

  File.open(src, "rb") do |f|
    puts f.read.encoding
    puts f.binmode?
  end

  # pread/pwrite do not move the file position.
  File.open(src, "rb") do |f|
    p f.pread(2, 1).bytes
    puts f.pos
  end

  # StringIO source and destination work with copy_stream too.
  require "stringio"
  sink = StringIO.new
  IO.copy_stream(StringIO.new("hello"), sink)
  puts sink.string
end

Output:

[1, 2, 3, 255]
copied 4 bytes
true
ASCII-8BIT
true
[2, 3]
0
hello

Standard streams, ARGF and DATA

$stdin, $stdout and $stderr are file descriptors 0, 1 and 2 (the constants STDIN, STDOUT and STDERR hold the original objects, so assigning to $stdout does not change STDOUT). Kernel#gets, readline and readlines do not read $stdin: they read ARGF, the concatenation of the files named in ARGV, or standard input when ARGV is empty. That is how ruby script.rb a.txt b.txt can read two files as one stream, and why gets in a script that receives arguments tries to open them as file names (and raises Errno::ENOENT if they are not). Use $stdin.gets when you mean standard input. DATA is a File positioned just after a line containing only __END__ in the main script, so a script can carry its own data.

# frozen_string_literal: true

$stdout.puts "to stdout"
warn "to stderr" # Kernel#warn writes to $stderr
$stdout.sync = true
puts $stdout.sync, $stdout.fileno, $stderr.fileno, $stdin.fileno
puts $stdout.tty?.class
puts $PROGRAM_NAME == __FILE__
puts DATA.class
DATA.each_line { |line| puts "DATA: #{line}" }
__END__
inline one
inline two

Output (run with standard error discarded; tty? is false because the output is piped):

to stdout
true
1
2
0
FalseClass
true
File
DATA: inline one
DATA: inline two
# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  one = File.join(dir, "one.txt")
  two = File.join(dir, "two.txt")
  File.write(one, "Dune\nEmma\n")
  File.write(two, "Ulysses\n")

  # ARGF reads the files named in ARGV one after another (or stdin if empty).
  ARGV.replace([one, two])
  ARGF.each_line do |line|
    puts "#{File.basename(ARGF.filename)}:#{ARGF.file.lineno}: #{line}"
  end
  puts ARGF.class

  # Paragraph mode: an empty separator splits on blank lines.
  text = File.join(dir, "para.txt")
  File.write(text, "a\nb\n\n\nc\n")
  p File.foreach(text, "").to_a
  p File.foreach(text, "", chomp: true).to_a
  p File.foreach(text, 2).to_a
end

Output:

one.txt:1: Dune
one.txt:2: Emma
two.txt:1: Ulysses
ARGF.class
["a\nb\n\n", "c\n"]
["a\nb", "c\n"]
["a\n", "b\n", "\n", "\n", "c\n"]

Paragraph mode ("" as the separator) splits on one or more blank lines and ends each paragraph with a single "\n\n". With chomp: true the paragraph break is removed from the paragraphs that have one, while the last paragraph keeps its own final newline, a subtlety worth testing before relying on it. A numeric argument limits the bytes per line, so a long line arrives in pieces.

Paths and file metadata

Working with names

The path functions on File operate on strings and never touch the disk (except realpath). File.join assembles with the platform separator and collapses duplicate separators, basename and dirname split, extname returns the last extension (with its dot), File.dirname(path, level) strips several components (Ruby 3.1), expand_path resolves .., . and "~" against a base (the current directory by default) and absolute_path does the same without expanding "~". realpath resolves symbolic links and raises if the file is missing. File.fnmatch is the glob matcher behind Dir.glob; by default * does not match a leading dot, and File::FNM_DOTMATCH, FNM_PATHNAME, FNM_EXTGLOB, FNM_CASEFOLD and FNM_NOESCAPE change that.

# frozen_string_literal: true

path = "/var/lib/shelf/data/books.2026.csv"

puts File.basename(path)                 # => "books.2026.csv"
puts File.basename(path, ".csv")         # => "books.2026"
puts File.basename(path, ".*")           # => "books.2026"
puts File.dirname(path)                  # => "/var/lib/shelf/data"
puts File.dirname(path, 2)               # => "/var/lib/shelf"
puts File.extname(path)                  # => ".csv"
p File.split(path)
puts File.join("data", "books", "a.csv")
puts File.join("data/", "/books") # duplicate separators are collapsed
puts File.expand_path("../x.txt", "/a/b")
puts File.expand_path("~") == Dir.home
puts File.absolute_path("x", "/a/b")
puts File.absolute_path?("/a")
puts File.fnmatch("*.csv", "books.csv")
puts File.fnmatch("*.csv", ".hidden.csv")
puts File.fnmatch("*.csv", ".hidden.csv", File::FNM_DOTMATCH)
puts File.fnmatch("**/*.rb", "lib/shelf/book.rb", File::FNM_PATHNAME)
puts File::SEPARATOR

Output:

books.2026.csv
books.2026
books.2026
/var/lib/shelf/data
/var/lib/shelf
.csv
["/var/lib/shelf/data", "books.2026.csv"]
data/books/a.csv
data/books
/a/x.txt
true
/a/b/x
true
true
false
true
true
/

Predicates, stat and permissions

Method Answers

File.exist?(path)

Does anything exist at the path (it follows symbolic links)? Also Dir.exist?.

File.file?, File.directory?, File.symlink?

The kind of entry; File.ftype returns it as a string ("file", "directory", "link", "socket" …​).

File.readable?, writable?, executable?

Permission for the effective user. File.world_readable? returns the mode bits or nil.

File.size, File.empty?

Size in bytes; empty means size zero. File.zero? is the older alias, and File.size? returns nil for an empty file.

File.mtime, atime, ctime, birthtime

Time of modification, access, inode change, and creation (where the system records it). Return Time.

File.stat(path) and File.lstat(path)

A File::Stat snapshot (lstat does not follow a final symbolic link): size, mode, uid, gid, nlink, ino, dev, blksize, blocks and the predicates.

File.chmod(mode, *paths), chown, utime(atime, mtime, *paths)

Change permission bits, ownership and timestamps.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "a.txt")
  puts File.exist?(path)
  File.write(path, "hello")

  puts File.exist?(path), File.file?(path), File.directory?(dir)
  puts File.size(path), File.empty?(path)
  puts File.readable?(path), File.writable?(path), File.executable?(path)
  puts File.mtime(path).class
  puts File.ftype(path), File.ftype(dir)

  stat = File.stat(path)
  puts stat.size, stat.file?, format("%o", stat.mode & 0o777)

  File.chmod(0o600, path)
  puts format("%o", File.stat(path).mode & 0o777)

  File.utime(Time.at(0), Time.at(86_400), path)
  puts File.atime(path).utc, File.mtime(path).utc

  puts File.respond_to?(:exists?)
  begin
    File.method(:exists?)
  rescue NameError => e
    puts e.message
  end
end

Output:

false
true
true
true
5
false
true
true
false
Time
file
directory
5
true
644
600
1970-01-01 00:00:00 UTC
1970-01-02 00:00:00 UTC
false
undefined method 'exists?' for class '#<Class:File>'

File.exists? and Dir.exists? were deprecated for years and are removed in Ruby 3.2: calling them raises NoMethodError. The fix is the singular exist?. RuboCop’s Lint/DeprecatedClassMethods flags the old spelling, which is why the example above probes it with File.method(:exists?), and code written before 3.2 still contains them.

A check such as File.exist?(path) followed by an action on the path is a race: another process can create or delete the file between the two calls (a time-of-check to time-of-use problem). Prefer the single operation that fails if the state is wrong: File.open(path, "wx") to create exclusively, FileUtils.mkdir_p (which is idempotent), or rescue Errno::ENOENT around the read. RuboCop’s Lint/NonAtomicFileOperation warns about the check-then-act pattern.

File.rename(old, new) renames or moves within one file system and replaces an existing destination atomically on POSIX systems; across file systems it raises Errno::EXDEV (FileUtils.mv falls back to copy and delete). File.delete (alias unlink) removes files and raises Errno::ENOENT if the file is gone. File.symlink(target, link) makes a symbolic link (the target is stored as written, it does not need to exist), File.link a hard link (a second name for the same inode), File.readlink reads the stored target, File.identical? tests whether two names refer to the same file, and File.truncate shortens or extends a file.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  a = File.join(dir, "a.txt")
  b = File.join(dir, "b.txt")
  link = File.join(dir, "link.txt")
  File.write(a, "data")

  File.rename(a, b)
  File.symlink(b, link)
  puts File.symlink?(link), File.readlink(link) == b
  puts File.lstat(link).symlink?, File.stat(link).symlink?
  puts File.realpath(link) == File.realpath(b)

  File.delete(link)
  puts File.exist?(link)
  puts File.exist?(b)

  begin
    File.delete(link)
  rescue SystemCallError => e
    puts e.class
  end

  File.link(b, a) # hard link
  puts File.stat(a).nlink
  puts File.identical?(a, b)
  File.truncate(b, 2)
  puts File.read(a)
end

Output:

true
true
true
false
true
false
true
Errno::ENOENT
2
true
da

Directories

Listing

Dir.children(dir) returns the entry names except . and .., Dir.entries(dir) includes them, and Dir.each_child(dir) and Dir.foreach(dir) iterate without building an array. The order is whatever the file system returns: it is neither alphabetical nor stable, so sort when the order matters. Names are bare names, not paths; join them with the directory. Dir.empty?(dir) tests for an empty directory, and Dir.exist? for existence. Dir.mkdir(path, mode) creates one level (the parent must exist), Dir.rmdir removes an empty directory (Errno::ENOTEMPTY otherwise), and FileUtils.mkdir_p and rm_rf handle trees.

Dir.glob

Dir.glob(pattern, flags = 0, base: nil, sort: true) (also Dir[]) expands shell-style patterns and returns an array of paths:

Pattern Matches

*

Any run of characters within one path segment; does not match a leading dot.

**/

Zero or more directories, recursively: lib/**/*.rb finds Ruby files at any depth.

?

Exactly one character.

[ab], [a-c], [^a]

One character from the set, the range, or not in the set.

{rb,txt}

Alternatives; the braces expand before matching, so *.{rb,txt} lists both.

a backslash

Escapes a metacharacter (inside a double-quoted Ruby string the backslash itself must be doubled).

The flags are the File::FNM_* constants; File::FNM_DOTMATCH includes hidden files. base: (Ruby 2.5) makes the search relative to a directory and returns paths relative to it, which avoids building absolute patterns (a directory name that contains * or [ is then not interpreted as a pattern). Since Ruby 3.0, results are sorted by default, and sort: false returns them in file system order, which can be faster on huge trees. An array of patterns is accepted. Glob patterns always use /, even on Windows.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  Dir.mkdir(File.join(dir, "lib"))
  Dir.mkdir(File.join(dir, "lib", "shelf"))
  %w[lib/a.rb lib/shelf/b.rb lib/shelf/c.txt .hidden README.md].each do |name|
    File.write(File.join(dir, name), "")
  end

  p Dir.children(dir).sort
  p Dir.entries(dir).sort
  p Dir.each_child(dir).to_a.sort
  p Dir.glob("**/*.rb", base: dir)
  p Dir.glob("*", base: dir)
  p Dir.glob("*", File::FNM_DOTMATCH, base: dir)
  p Dir.glob("lib/**/*.{rb,txt}", base: dir)
  p Dir.glob(%w[*.md lib/*.rb], base: dir)
  p Dir.glob("lib/shelf/?.rb", base: dir)
  p Dir.glob("lib/[ab]*", base: dir)
  p Dir[File.join(dir, "lib", "*")].map { |f| File.basename(f) }.sort
  puts Dir.exist?(dir), Dir.empty?(File.join(dir, "lib"))
  p Dir.glob("lib/*", base: dir, sort: false).sort
end

Output:

[".hidden", "README.md", "lib"]
[".", "..", ".hidden", "README.md", "lib"]
[".hidden", "README.md", "lib"]
["lib/a.rb", "lib/shelf/b.rb"]
["README.md", "lib"]
[".", ".hidden", "README.md", "lib"]
["lib/a.rb", "lib/shelf/b.rb", "lib/shelf/c.txt"]
["README.md", "lib/a.rb"]
["lib/shelf/b.rb"]
["lib/a.rb"]
["a.rb", "shelf"]
true
false
["lib/a.rb", "lib/shelf"]

The working directory

Dir.pwd is the process-wide current directory and Dir.chdir(path) changes it. With a block the directory is restored afterwards. The directory belongs to the whole process, not to the thread, so Dir.chdir in a block while another thread also uses Dir.chdir raises RuntimeError (conflicting chdir during another chdir block), and relative paths used by a different thread silently change meaning. In libraries pass base: or build absolute paths instead of changing directory. Dir.home returns the home directory and Dir.open returns a Dir object that offers children, each_child, read and close.

# frozen_string_literal: true

require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  here = Dir.pwd
  Dir.chdir(dir) do
    puts Dir.pwd == File.realpath(dir)
    File.write("rel.txt", "relative")
  end
  puts Dir.pwd == here
  puts File.exist?(File.join(dir, "rel.txt"))

  begin
    Dir.rmdir(dir)
  rescue SystemCallError => e
    puts e.class
  end

  begin
    Dir.mkdir(dir)
  rescue SystemCallError => e
    puts e.class
  end

  Dir.open(dir) do |d|
    puts d.class, d.path == dir
    p d.children
  end
  puts Dir.tmpdir.class
end

Output:

true
true
true
Errno::ENOTEMPTY
Errno::EEXIST
Dir
true
["rel.txt"]
String

Dir.fchdir and Dir.for_fd (Ruby 3.3) work with directory file descriptors on systems that support them.

Walking a tree with Find

Find.find(dir) yields every path under a directory depth-first, and Find.prune skips the current directory, which Dir.glob("**/*") cannot do. It also handles huge trees without building an array.

# frozen_string_literal: true

require "find"
require "fileutils"
require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  %w[lib/a.rb lib/deep/b.rb node_modules/x.js].each do |name|
    full = File.join(dir, name)
    FileUtils.mkdir_p(File.dirname(full))
    File.write(full, "")
  end

  found = []
  Find.find(dir) do |path|
    Find.prune if File.basename(path) == "node_modules"
    found << path.delete_prefix("#{dir}/") if File.file?(path)
  end
  p found.sort
end

Output:

["lib/a.rb", "lib/deep/b.rb"]

Pathname

Pathname wraps a path string in an object that answers the questions you would otherwise ask File, Dir, FileTest and FileUtils, and that composes with / and join. Its aim is readability: root / "data" / "books.csv" instead of File.join(root, "data", "books.csv"). Ruby 4.0 promoted Pathname from a default gem to a core class (Feature #17473), so Pathname.new("x") and the Pathname("x") conversion method work without require. Existing require "pathname" lines are harmless and still needed on Ruby 3.4 and older. In 4.0 two methods, Pathname#find and Pathname#rmtree, still come from the library file and need the require (otherwise rmtree raises NoMethodError). RuboCop with target 4.0 reports the require as redundant, so add it with an explanatory comment when you use those two.

# frozen_string_literal: true

# No require "pathname" is needed in Ruby 4.0.
root = Pathname.new("/srv/shelf")
data = root / "data" / "books.csv"

puts data
p data
puts data.basename, data.dirname, data.extname
puts data.sub_ext(".json")
puts data.parent.parent
puts root + Pathname("../etc")
puts root.join("/abs")
puts Pathname("a//b/../c/./d").cleanpath
puts data.relative_path_from(Pathname("/srv"))
puts data.absolute?, Pathname("x").relative?
p data.each_filename.to_a
p data.descend.map(&:to_s)
p Pathname("a/b/c").ascend.map(&:to_s)
puts Pathname("a/b.rb").to_path.class
puts data.sub("books", "authors")
puts data.fnmatch?("*.csv")
puts Pathname("a") <=> Pathname("b")
same = Pathname("a")
puts same == Pathname("a")

Output:

/srv/shelf/data/books.csv
#<Pathname:/srv/shelf/data/books.csv>
books.csv
/srv/shelf/data
.csv
/srv/shelf/data/books.json
/srv/shelf
/srv/etc
/abs
a/c/d
shelf/data/books.csv
true
true
["srv", "shelf", "data", "books.csv"]
["/", "/srv", "/srv/shelf", "/srv/shelf/data", "/srv/shelf/data/books.csv"]
["a/b/c", "a/b", "a"]
String
/srv/shelf/data/authors.csv
true
-1
true

Points worth noting from that output: + and join follow path semantics ("/abs" as the right operand replaces the left side, and .. is resolved against the left side), cleanpath simplifies without touching the disk (it can be wrong in the presence of symbolic links, use realpath for that), and relative_path_from computes a relative path between two paths of the same kind. A Pathname is not a String: it has to_s and to_path, and every File and Dir method accepts it because they call to_path. Pathname#== compares the strings, so a/b and a//b are different pathnames until cleaned.

The file system half of the API mirrors the other classes:

Pathname method Equivalent to

exist?, file?, directory?, size, empty?, mtime, readable?

File.exist?(p) and the other predicates.

read, write, readlines, binread, binwrite, each_line, open

File.read(p) and friends (write returns the byte count).

children, each_child, entries, glob

Dir.children, Dir.each_child, Dir.entries and Dir.glob(pattern, base: p) returning Pathnames; children(false) returns bare names.

mkdir, mkpath, rmdir, rmtree, delete, rename, unlink

Dir.mkdir, FileUtils.mkdir_p, Dir.rmdir, FileUtils.rm_rf and the File methods.

Pathname.pwd, Pathname.glob, Pathname.mktmpdir

The class-level conveniences.

# frozen_string_literal: true

require "pathname" # rubocop:disable Lint/RedundantRequireStatement -- rmtree and find need it
require "tmpdir"

Dir.mktmpdir("shelf") do |tmp|
  dir = Pathname(tmp) / "shelf"
  dir.mkpath
  (dir / "b.txt").write("beta\n")
  (dir / "a.txt").write("alpha\n")
  (dir / "sub").mkdir

  puts dir.exist?, dir.directory?, (dir / "a.txt").file?
  puts (dir / "a.txt").read
  p (dir / "b.txt").readlines(chomp: true)
  p dir.children.map { |c| c.basename.to_s }.sort
  p dir.children(false).map(&:to_s).sort
  p dir.glob("*.txt").map { |c| c.basename.to_s }.sort
  p Pathname.glob("*.txt", base: dir).map(&:to_s).sort
  dir.each_child { |c| puts "child #{c.basename}" if c.directory? }
  (dir / "a.txt").rename(dir / "z.txt")
  puts (dir / "z.txt").size
  puts dir.empty?
  (dir / "sub").rmdir
  dir.rmtree
  puts dir.exist?
end

Output:

true
true
true
alpha
["beta"]
["a.txt", "b.txt", "sub"]
["a.txt", "b.txt", "sub"]
["a.txt", "b.txt"]
["a.txt", "b.txt"]
child sub
6
false
false

Use Pathname for application code that builds and passes around paths; use plain strings when a path is a leaf value you only hand to File.open. The two mix freely.

FileUtils

FileUtils is the library for operations on trees, modelled on shell commands. Every method accepts a single path or an array and the options verbose: (print the equivalent shell command), noop: (do nothing; for a dry run) and, where it makes sense, force:, preserve: and mode:. There are three companion modules, FileUtils::Verbose, FileUtils::NoWrite and FileUtils::DryRun, that you can include to change the default for every call in a class.

Method Shell equivalent and notes

mkdir_p(dir) (makedirs, mkpath)

mkdir -p; creates parents and does nothing if the directory exists.

cp(src, dest), cp_r(src, dest)

cp and cp -r; preserve: true keeps times and mode.

mv(src, dest) (move)

mv; works across file systems.

rm(files), rm_f

rm (raises if missing) and rm -f (ignores missing files).

rm_r(dir), rm_rf(dir) (rmtree)

rm -r and rm -rf. Dangerous with an interpolated or empty path.

touch(file)

touch; creates an empty file or updates its time.

ln_s(target, link), ln_sf, ln_sr (3.2)

Symbolic links; ln_sr makes the target relative to the link.

chmod(mode, files), chmod_R, chown_R

Permission and ownership, recursively with _R; symbolic modes such as "u+x" work too.

compare_file(a, b) (identical?, cmp)

Whether the two files have identical content.

install(src, dest, mode: 0o755)

Copy with a mode, like install.

cd(dir) { ... } (chdir)

Dir.chdir, with the same process-wide caveat.

remove_entry(path), remove_entry_secure(path)

Remove a file or tree; the _secure variant is safe against symlink races in world-writable directories such as the temporary directory.

# frozen_string_literal: true

require "fileutils"
require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  Dir.chdir(dir) do
    FileUtils.mkdir_p("out/reports/2026")
    FileUtils.touch("out/reports/2026/a.txt")
    FileUtils.cp("out/reports/2026/a.txt", "out/b.txt")
    FileUtils.cp_r("out/reports", "out/backup")
    FileUtils.mv("out/b.txt", "out/c.txt")
    p Dir.glob("**/*", base: "out").sort

    FileUtils.rm("out/c.txt")
    FileUtils.rm_f("out/missing.txt") # no error when absent
    begin
      FileUtils.rm("out/missing.txt")
    rescue Errno::ENOENT => e
      puts e.class
    end

    FileUtils.rm_r("out/backup")
    FileUtils.ln_s("reports", "out/latest")
    puts File.symlink?("out/latest")
    puts FileUtils.compare_file("out/reports/2026/a.txt", "out/reports/2026/a.txt")
    FileUtils.rm_rf("out")
    puts Dir.exist?("out")
  end
end

Output:

["backup", "backup/2026", "backup/2026/a.txt", "c.txt", "reports", "reports/2026", "reports/2026/a.txt"]
Errno::ENOENT
true
true
false
# frozen_string_literal: true

require "fileutils"
require "tmpdir"

Dir.mktmpdir("shelf") do |dir|
  out = File.join(dir, "build")
  FileUtils.mkdir_p(out, verbose: true, noop: true)
  puts Dir.exist?(out)

  FileUtils.mkdir_p(out, mode: 0o750)
  FileUtils.touch(File.join(out, "x"))
  FileUtils.cd(out) { puts Dir.pwd == File.realpath(out) }
  puts FileUtils.pwd.class
  FileUtils.remove_entry_secure(out) # safe against races in world-writable dirs
  puts Dir.exist?(out)
  begin
    FileUtils.remove_entry(out)
  rescue Errno::ENOENT => e
    puts e.class
  end
end

Output:

mkdir -p /tmp/shelf20261018-xxxx/build
false
true
String
false
Errno::ENOENT

rm_rf and rm_r do exactly what they say: FileUtils.rm_rf("#{prefix}/#{name}") with an empty prefix and name deletes from the file system root if permissions allow it. Validate the path (see the safety section) before deleting, prefer remove_entry on a path you just created, and use noop: true, verbose: true while developing a cleanup script. cp and mv overwrite an existing destination without asking.

Temporary files and directories

Tempfile.create and Dir.mktmpdir are the supported ways to get scratch space. Both create the object with an unpredictable name in the system temporary directory (Dir.tmpdir, which honors the TMPDIR environment variable), and Tempfile creates the file with mode 0600, so other users cannot read or replace it. Both have a block form that deletes the object when the block ends, which should be your default. Tempfile.new returns a Tempfile wrapper (a delegator to a File) whose file is deleted when the object is garbage collected or at exit, unlink removes it early, and close! closes and deletes it in one step. The name can take a prefix and suffix as an array (Tempfile.create(["books", ".csv"])). Tempfile.create(anonymous: true) (Ruby 3.4) creates a file that is removed immediately and is reachable only through the open handle, so nothing can leak.

# frozen_string_literal: true

require "tempfile"

# Tempfile.create with a block removes the file when the block ends.
path = nil
Tempfile.create(["books", ".csv"]) do |file|
  path = file.path
  file.puts "isbn,title"
  file.flush
  puts File.basename(path).match?(/\Abooks.*\.csv\z/)
  puts File.read(path)
end
puts File.exist?(path)

# Tempfile.new returns a wrapper that is removed by the garbage collector.
tmp = Tempfile.new("scratch")
tmp.write("data")
tmp.close
puts File.exist?(tmp.path)
tmp.open # reopen for reading
puts tmp.read
tmp.close! # close and unlink immediately
puts tmp.path.inspect

Tempfile.open("block") { |f| puts f.class }

Output:

true
isbn,title
false
true
data
nil
Tempfile

Tempfile.create returns a plain File, while Tempfile.new returns the delegating wrapper; prefer create with a block for new code, and use new when the temporary file’s lifetime has to follow an object that is passed around. When another process or library needs the file by name, call flush or close first so the data is on disk.

# frozen_string_literal: true

require "tmpdir"

kept = nil
Dir.mktmpdir(["shelf-", "-work"]) do |dir|
  kept = dir
  puts File.basename(dir).start_with?("shelf-")
  File.write(File.join(dir, "a.txt"), "x")
end
puts Dir.exist?(kept)

# Without a block the directory stays until you remove it.
dir = Dir.mktmpdir("manual")
puts Dir.exist?(dir)
Dir.rmdir(dir)
puts Dir.tmpdir == File.realpath(Dir.tmpdir) || Dir.tmpdir.class

Output:

true
false
true
String

Source: Ruby 4.0 documentation: Tempfile; Dir.mktmpdir and Dir.tmpdir belong to the tmpdir default library documented under Dir

Test suites should do all their file work in a Dir.mktmpdir directory (or use StringIO, next), which keeps tests independent and leaves nothing behind. Never invent temporary names such as "/tmp/mydata.txt" or "/tmp/#{Process.pid}": they are predictable, so another user can plant a file or symbolic link there first.

StringIO

StringIO implements the IO interface over a string: gets, each_line, read, write, puts, printf, <<, rewind, seek, ungetc, eof? and so on. It is how you test code that reads or writes files, capture output, or feed a parser from a string. It needs require "stringio". Differences from a real file: there is no file descriptor (fileno is nil, and it cannot go to IO.select or a child process), ungetc and writes modify the underlying string in place, and a StringIO made from a frozen string (which includes every string literal in a file with # frozen_string_literal: true) is read-only, so writes raise IOError (not opened for writing) and ungetc raises IOError (not modifiable string). Use +"..." or String.new for a writable buffer.

# frozen_string_literal: true

require "stringio"

# String literals are frozen here, so build a mutable copy for ungetc.
io = StringIO.new(+"one\ntwo\nthree\n")
puts io.gets.inspect
p io.each_line.map(&:chomp)
puts io.eof?
io.rewind
p io.read(3)
io.ungetc("X")
p io.read(4)

out = StringIO.new
out.puts "hello"
out.print "a", "b"
out << "!"
out.printf("%03d", 7)
p out.string

# A frozen string yields a read-only StringIO.
begin
  StringIO.new("fixed").write("x")
rescue IOError => e
  puts "#{e.class}: #{e.message}"
end

# Capture $stdout in a test.
original = $stdout
$stdout = StringIO.new
puts "captured"
captured = $stdout.string
$stdout = original
p captured

Output:

"one\n"
["two", "three"]
true
"one"
"X\ntw"
"hello\nab!007"
IOError: not opened for writing
"captured\n"

Writing code against "anything with puts`" instead of against file names makes it testable. The shelf scenario shows the shape: the export method takes the destination as an argument and does not care whether it is a `File, $stdout or a StringIO.

# frozen_string_literal: true

require "stringio"

Author = Data.define(:name)
Book = Data.define(:isbn, :title, :author) do
  def to_s = "#{title} by #{author.name}"
end

# Writes any enumerable of books to any IO-like object.
def export_books(books, io)
  books.each { |book| io.puts [book.isbn, book.title, book.author.name].join("\t") }
  io
end

books = [Book.new("9780441172719", "Dune", Author.new("Frank Herbert")),
         Book.new("9780140449136", "The Odyssey", Author.new("Homer"))]

sink = export_books(books, StringIO.new)
print sink.string
export_books(books.first(1), $stdout)

lines = StringIO.new(sink.string).each_line(chomp: true).map { |l| l.split("\t") }
p lines.first

Output (the shelf Book and Author value objects are repeated in trimmed form so the file runs on its own):

9780441172719	Dune	Frank Herbert
9780140449136	The Odyssey	Homer
9780441172719	Dune	Frank Herbert
["9780441172719", "Dune", "Frank Herbert"]

IO::Buffer

IO::Buffer (Ruby 3.1) is a fixed-size block of bytes, either owned memory (IO::Buffer.new(size)), a view over a string (IO::Buffer.for(string), read-only for a frozen string) or a memory-mapped file (IO::Buffer.map(file)). You read and write typed values by offset (get_value(:U32, offset), set_value, get_string, set_string; the type symbols include :U8, :S8, :U16 for big-endian and :u16 for little-endian, :U32, :U64, :F32, :F64 and so on), slice it without copying, compare, XOR and copy between buffers, and read from or write to an IO with read, write, pread and pwrite. It is the zero-copy layer under the fiber scheduler’s io_read and io_write hooks (see Fibers and the Fiber Scheduler). Ruby 3.4 lets IO::Buffer#copy release the GVL for large copies. The class still prints an experimental warning when first used, because its interface may change: this example turns off that one warning category with Warning[:experimental] = false. For everyday code you almost never need it; use strings, pack and unpack.

# frozen_string_literal: true

# IO::Buffer is still marked experimental; silence only that category.
Warning[:experimental] = false

buffer = IO::Buffer.new(16)
buffer.set_string("shelf", 0)
buffer.set_value(:U32, 8, 0xCAFEBABE)
puts buffer.get_string(0, 5)
puts format("%#x", buffer.get_value(:U32, 8))
puts buffer.size
puts buffer.external?, buffer.readonly?

view = IO::Buffer.for("read only text")
puts view.readonly?
puts view.get_string(5, 4)
begin
  view.set_string("X")
rescue IO::Buffer::AccessError => e
  puts "#{e.class}: #{e.message}"
end

slice = buffer.slice(0, 5)
slice.set_string("SHELF")
puts buffer.get_string(0, 5)
p buffer.each(:U8, 0, 3).to_a

Output:

shelf
0xcafebabe
16
false
false
true
only
IO::Buffer::AccessError: Buffer is not writable!
SHELF
[[0, 83], [1, 72], [2, 69]]

Pipes, readiness and locks

Pipes and non-blocking reads

IO.pipe returns a connected reader and writer: whatever one thread or process writes, the other reads. The reader sees end of file only after every copy of the writer is closed, a common source of hangs when a forked child still holds one. A blocking read or gets waits for data. IO#wait_readable(timeout) waits until a stream is ready (and returns nil on timeout), IO#read_nonblock(n) never blocks and raises IO::WaitReadable (IO::EAGAINWaitReadable) when nothing is available, unless you pass exception: false, in which case it returns :wait_readable; at the end of file it raises EOFError or returns nil with exception: false. write_nonblock is the mirror. IO.select(readers, writers, errors, timeout) waits on several streams at once and returns the ready ones (nil on timeout; since 4.0 the timeout may be Float::INFINITY). Under a fiber scheduler these calls yield to other fibers instead of blocking the thread.

# frozen_string_literal: true

reader, writer = IO.pipe
writer.puts "from the writer"
writer.close
puts reader.read
reader.close

r, w = IO.pipe
p r.wait_readable(0.1) # nothing to read yet
w.write "x"
puts r.wait_readable(0.1) == r
puts r.read_nonblock(10)
begin
  r.read_nonblock(10)
rescue IO::WaitReadable => e
  puts e.class
end
p r.read_nonblock(10, exception: false)
w.close
p r.read_nonblock(10, exception: false) # nil at end of file
r.close

# IO.select waits on several IO objects at once.
a_r, a_w = IO.pipe
b_r, b_w = IO.pipe
b_w.write "b is ready"
ready, = IO.select([a_r, b_r], nil, nil, 0.1)
puts ready.map { |io| io == b_r }.inspect
[a_r, a_w, b_r, b_w].each(&:close)

Output:

from the writer
nil
true
x
IO::EAGAINWaitReadable
:wait_readable
nil
[true]

Locking and atomic replacement

File#flock takes an advisory lock: File::LOCK_SH (shared), File::LOCK_EX (exclusive), optionally ORed with File::LOCK_NB to return false instead of waiting, and File::LOCK_UN to release. Advisory means that it only constrains programs that also call flock; a program that ignores locking can still write. The lock is released when the file is closed, which is another reason to use the block form. Never truncate a file before locking it: open without "w", lock, and then truncate.

To replace a file so that readers see either the old or the new content and never a partial file, write a temporary file in the same directory (a rename is atomic only inside one file system) and rename it over the target.

# frozen_string_literal: true

require "tmpdir"

# Replace a file atomically: write a sibling temp file, then rename over it.
def write_atomically(path, content)
  tmp = "#{path}.#{Process.pid}.tmp"
  File.write(tmp, content)
  File.rename(tmp, path)
end

Dir.mktmpdir("shelf") do |dir|
  path = File.join(dir, "catalog.txt")
  write_atomically(path, "v1\n")
  write_atomically(path, "v2\n")
  puts File.read(path)

  # Advisory locking with flock; the lock is released on close.
  File.open(path, File::RDWR | File::CREAT) do |file|
    file.flock(File::LOCK_EX)
    file.truncate(0)
    file.write("locked write\n")
    file.flush
    File.open(path) { |other| puts other.flock(File::LOCK_SH | File::LOCK_NB) }
  end
  puts File.read(path)
end

Output:

v2
false
locked write

Errors

File operations raise Errno:: classes, all subclasses of SystemCallError, which carry the C error name, the message from the operating system, the function that failed (@ rb_sysopen) and the path. SystemCallError#errno returns the number. IOError signals a misuse of the stream (closed, not opened for that direction) and EOFError, a subclass of IOError, signals reading past the end. Rescue the narrowest class that you can handle and let the rest propagate.

Exception Typical cause

Errno::ENOENT

The file or a parent directory does not exist (also deleting a missing file).

Errno::EEXIST

Creating something that exists ("wx", Dir.mkdir, File.symlink).

Errno::EACCES, Errno::EPERM

Not allowed: permission bits, or a protected operation.

Errno::EISDIR, Errno::ENOTDIR

A directory where a file was expected, or a file used as a directory component.

Errno::ENOTEMPTY

Dir.rmdir on a non-empty directory.

Errno::EXDEV

File.rename across file systems.

Errno::ENOSPC, Errno::EDQUOT

Disk full or quota exceeded; surfaces on write, flush or close, not on open.

Errno::EMFILE, Errno::ENFILE

Too many open files: usually a descriptor leak (missing close).

Errno::EPIPE

Writing to a pipe whose reader has gone.

Errno::EAGAIN (same as EWOULDBLOCK)

A non-blocking call would block; raised as IO::EAGAINWaitReadable or IO::EAGAINWaitWritable.

IOError (closed stream, not opened for reading)

Programming errors on the stream.

EOFError

readline, readchar or read_nonblock at the end of the file.

ArgumentError (path name contains null byte)

A file name containing "\0".

# frozen_string_literal: true

require "tmpdir"

def try
  yield
rescue SystemCallError, IOError => e
  puts "#{e.class} < #{e.class.superclass}: #{e.message.gsub(%r{/[^ ]*/}, '<dir>/')}"
end

def read_twice(file)
  File.open(file) do |f|
    f.read
    f.readline
  end
end

Dir.mktmpdir("shelf") do |dir|
  file = File.join(dir, "f.txt")
  File.write(file, "x")
  try { File.read(File.join(dir, "nope")) }
  try { File.read(dir) }
  try { Dir.mkdir(dir) }
  try { Dir.rmdir(dir) }
  try { File.read(File.join(file, "child")) }
  try { File.open(file, "r") { |f| f.write("y") } }
  try { File.open(file, "w", &:read) }
  try { read_twice(file) }
  File.chmod(0o000, file)
  try { File.read(file) }
  puts Errno::ENOENT::Errno, Errno::ENOENT.new.errno
  puts Errno::EAGAIN == Errno::EWOULDBLOCK
end

Output:

Errno::ENOENT < SystemCallError: No such file or directory @ rb_sysopen - <dir>/nope
Errno::EISDIR < SystemCallError: Is a directory @ io_fread - <dir>/shelf20261018-xxxx
Errno::EEXIST < SystemCallError: File exists @ dir_s_mkdir - <dir>/shelf20261018-xxxx
Errno::ENOTEMPTY < SystemCallError: Directory not empty @ dir_s_rmdir - <dir>/shelf20261018-xxxx
Errno::ENOTDIR < SystemCallError: Not a directory @ rb_sysopen - <dir>/child
IOError < StandardError: not opened for writing
IOError < StandardError: not opened for reading
EOFError < IOError: end of file reached
Errno::EACCES < SystemCallError: Permission denied @ rb_sysopen - <dir>/f.txt
2
2
true

The last example is an application of all this to the shelf scenario: a small class that saves books as tab-separated lines through a temporary file and rename, and reads them lazily with Pathname#each_line, building the same validated Isbn objects as the rest of the gem. A missing file surfaces as Errno::ENOENT, which the caller decides how to handle. It needs the shelf gem on the load path (ruby -I lib).

# frozen_string_literal: true

require "shelf"
require "tmpdir"

# Persists a catalogue as one tab-separated line per book.
class BookFile
  def initialize(path) = @path = Pathname(path)

  def save(books)
    tmp = @path.sub_ext(".tmp")
    tmp.open("w") do |io|
      books.each { |b| io.puts [b.isbn, b.title, b.author.name].join("\t") }
    end
    tmp.rename(@path)
  end

  def each_book
    return enum_for(:each_book) unless block_given?

    @path.each_line(chomp: true) do |line|
      isbn, title, name = line.split("\t")
      yield Shelf::Book.new(Shelf::Isbn.new(isbn), title, Shelf::Author.new(name))
    end
  end
end

Dir.mktmpdir("shelf") do |dir|
  file = BookFile.new(File.join(dir, "books.tsv"))
  dune = Shelf::Book.new(Shelf::Isbn.new("978-0-441-17271-9"), "Dune", Shelf::Author.new("Frank Herbert"))
  file.save([dune])
  puts file.each_book.map(&:to_s)
  puts File.read(File.join(dir, "books.tsv")).inspect

  begin
    BookFile.new(File.join(dir, "missing.tsv")).each_book.to_a
  rescue Errno::ENOENT => e
    puts e.class
  end
end

Output:

Dune by Frank Herbert
"9780441172719\tDune\tFrank Herbert\n"
Errno::ENOENT

Safe handling of paths

  • Never trust a name from outside. A user-supplied name such as "../../etc/passwd" or an absolute path escapes the directory you meant. Resolve the candidate against the root with File.expand_path and check that the result still starts with the root plus the separator. File.join(root, name) alone does not stop .., and an absolute name is not safe with Pathname#join or Pathname#+ (it replaces the root). Use realpath when symbolic links are involved, because a link inside the root can point outside it.

  • A file name containing a null byte raises ArgumentError before the system call, so the classic null-byte truncation trick fails in Ruby.

  • Do not interpolate names into shell strings; pass an argument array to system, spawn or Open3, see Processes, Signals and Shell.

  • Create files with the narrowest mode they need (0o600 for secrets) and exclusively ("wx") when overwriting would be a bug.

  • Use Tempfile and Dir.mktmpdir, not predictable names, and remove_entry_secure when deleting in shared directories.

  • Load data from files with the safe loaders (JSON.parse, YAML.safe_load); Marshal.load and YAML.unsafe_load on a file someone else wrote can execute code, see Security.

# frozen_string_literal: true

require "tmpdir"

# Resolve a user-supplied name inside a root directory, or return nil.
def inside(root, name)
  base = File.realpath(root)
  full = File.expand_path(name, base)
  full if full.start_with?("#{base}#{File::SEPARATOR}")
end

Dir.mktmpdir("shelf") do |root|
  puts inside(root, "books.csv").sub(File.realpath(root), "<root>")
  p inside(root, "../etc/passwd")
  p inside(root, "/etc/passwd")

  begin
    File.read("books\0.csv")
  rescue ArgumentError => e
    puts "#{e.class}: #{e.message}"
  end
end

Output:

<root>/books.csv
nil
nil
ArgumentError: path name contains null byte

Pitfalls and a decision guide

Situation Do this

Reading or writing a small file in one go

File.read, File.write, Pathname#read.

Processing a big file line by line

File.foreach(path, chomp: true) or `File.open(path) {

f

f.each_line …​ }`.

Several operations on one open file

`+File.open(path, mode) {

file

…​ }+; never an unclosed `File.new.

Binary data

File.binread, File.binwrite, mode "rb"/"wb".

A file that may have a byte order mark

encoding: "bom|utf-8".

Copying a file or stream

IO.copy_stream, FileUtils.cp.

Building and passing paths in application code

Pathname (/, join, children).

Finding files by pattern

Dir.glob("**/*.rb", base: dir); Find.find when you need to prune.

Recursive create, copy, move, delete

FileUtils, with noop: true, verbose: true while testing.

Scratch files and directories

Tempfile.create with a block, Dir.mktmpdir with a block.

Testing code that does I/O

Pass an object with puts (StringIO) or use Dir.mktmpdir.

Replacing a file safely

Write a sibling temporary file, then File.rename.

Several processes updating one file

File#flock with LOCK_EX; or use a database.

Waiting on several streams

IO.select, or a fiber scheduler.

Deleting from a computed path

Validate the path first; remove_entry_secure in shared directories.

Common pitfalls:

  • File.open without a block and no close leaks a descriptor; a long-running process then fails with Errno::EMFILE.

  • File.readlines and File.read on a huge file exhaust memory; stream instead.

  • File.write overwrites; use mode: "a" to append.

  • gets in a script reads ARGF, not standard input, when arguments are present.

  • Dir.children and Dir.glob(sort: false) have no stable order, and Dir.children returns names rather than paths.

  • Dir.chdir is process-wide and raises in a second thread’s block; build absolute paths instead.

  • Dir.glob does not match hidden files unless File::FNM_DOTMATCH is given, and * never crosses a directory separator.

  • Output to a pipe is buffered; set $stdout.sync = true or flush when timing matters.

  • An exception during a write can leave a partial file: replace atomically for anything important.

  • Text read on Unix keeps \r; use chomp: true or "rt" for files that may come from Windows.

  • A StringIO over a frozen literal is read-only.

  • Tempfile.new files are removed when the object is collected: keep a reference for as long as another process needs the name.

Where older material is outdated

Older code and tutorials Ruby 4.0

File.exists?, Dir.exists?

Removed in 3.2; use exist?.

Kernel#open and IO.read with a leading pipe character in the name and the other file-open shortcuts that spawned processes

Deprecated in 3.3, removed in 4.0 (Errno::ENOENT). Use Open3, IO.popen with an argument array, or system.

require "pathname" before every use

Not needed in 4.0 (except for find and rmtree); still required on 3.4 and older.

Dir.glob returning an unspecified order, sorted by hand

Sorted by default since 3.0; sort: false restores file system order.

File.open(path) without a block, closing in ensure by hand

Prefer the block form or the one-call helpers.

Examples that call File.create or File.save

These methods do not exist in Ruby; use File.write or File.open(path, "w").

Dir.glob("#{dir}/*") with a changing directory

Dir.glob("*", base: dir) (2.5).

Temporary files named by hand

Tempfile.create and Dir.mktmpdir.

IO#path missing on IO objects

Available on IO since 3.2 (it was already on File).

IO::Buffer and Pathname described as experimental or an add-on

Pathname is core in 4.0; IO::Buffer is still marked experimental.

Version notes

Version Change

3.0

Dir.glob and Dir[] sort their results and take sort:; the fiber scheduler can intercept blocking I/O.

3.1

File.dirname(path, level); IO::Buffer and the io_read and io_write scheduler hooks.

3.2

File.exists? and Dir.exists? removed; IO#timeout and IO::TimeoutError; IO#path and IO.new(..., path:); FileUtils.ln_sr and ln_s(relative:); Pathname#lutime.

3.3

File-open shortcuts that spawn processes deprecated; Dir.fchdir and Dir.for_fd.

3.4

Tempfile.create(anonymous: true); IO::Buffer#copy can release the GVL.

4.0

Pathname is a core class; the pipe-command behavior of Kernel#open, IO.read and friends removed; IO.select accepts Float::INFINITY as a timeout.