IO #
Almost every useful program performs Input/Output operations: reading configuration from files, writing logs, processing CSV data, or interacting with the terminal. Ruby provides a comprehensive IO class hierarchy — IO as the base class, File as its subclass for filesystem operations, and StringIO for memory-based IO simulation. Understanding how IO works in Ruby isn’t just memorizing the File.read method — you need to understand file opening modes, buffering, resource management with blocks, and when to read everything at once versus line by line. Mistakes in IO management are one of the most common sources of bugs and resource leaks in production programs.
IO Hierarchy and Basic Concepts #
Before diving into practical usage, it’s important to understand Ruby’s IO class structure and how the classes relate.
classDiagram
class IO {
+read()
+write()
+close()
+each_line()
+flush()
}
class File {
+path()
+stat()
+truncate()
+chmod()
}
class StringIO {
+string()
+rewind()
+pos()
}
class BasicSocket
IO <|-- File
IO <|-- StringIO
IO <|-- BasicSocketRuby has three standard streams always available since the program starts:
# Standard streams — always available, no manual opening needed
$stdin # => #<IO:<STDIN>> — reads input from the keyboard / pipe
$stdout # => #<IO:<STDOUT>> — writes normal output
$stderr # => #<IO:<STDERR>> — writes error messages
# Alias constants
STDIN == $stdin # true
STDOUT == $stdout # true
STDERR == $stderr # true
# puts, print, p all write to $stdout by default
$stdout.puts "Hello" # same as puts "Hello"
$stderr.puts "Error!" # writes to stderr, doesn't mix with stdout
# Redirect $stdout to a file (useful for temporary logging)
$stdout = File.open("output.log", "w")
puts "This goes to the file"
$stdout = STDOUT # restore
File Opening Modes #
File opening modes determine which operations are allowed and what happens to existing content. Choosing the wrong mode is a common mistake that can corrupt data.
| Mode | Description | Initial Position | Create If Missing | Truncate Content |
|---|---|---|---|---|
"r" | Read only | Beginning | No (error) | No |
"w" | Write only | Beginning | Yes | Yes |
"a" | Append only | End | Yes | No |
"r+" | Read + Write | Beginning | No (error) | No |
"w+" | Read + Write | Beginning | Yes | Yes |
"a+" | Read + Append | End | Yes | No |
"b" | Binary (suffix) | — | — | — |
# Mode "r" — read only, the file must exist
File.open("config.txt", "r") do |f|
puts f.read
end
# Mode "w" — write new, DELETES old content!
File.open("output.txt", "w") do |f|
f.write("New content")
end
# Mode "a" — append at the end, old content is safe
File.open("log.txt", "a") do |f|
f.puts "#{Time.now} — new entry"
end
# Binary mode — for non-text files (images, PDFs, etc.)
File.open("image.png", "rb") do |f|
data = f.read
puts "Size: #{data.bytesize} bytes"
end
Modes"w"and"w+"immediately delete the entire file content when the file is opened, even before you write anything. If you intend to append content, use mode"a". This mistake is very common and can destroy important data.
Reading Files #
Ruby provides several ways to read files, each suited to different scenarios. The right choice directly impacts the program’s memory usage.
Reading Everything at Once #
# File.read — the simplest way, reads everything into memory
content = File.read("article.txt")
puts content
# File.readlines — read all lines into an array
lines = File.readlines("list.txt")
lines.each { |l| puts l.chomp }
# File.readlines with automatic chomp (Ruby 2.4+)
lines = File.readlines("list.txt", chomp: true)
# Read with a specific encoding
content = File.read("arabic.txt", encoding: "UTF-8")
Reading Line by Line (Memory Efficient) #
# each_line — iterate lines without loading everything into memory
File.open("large_data.csv", "r") do |file|
file.each_line do |line|
columns = line.chomp.split(",")
process(columns)
end
end
# foreach — a shortcut without manual open/close
File.foreach("large_data.csv") do |line|
puts line.chomp
end
# Read N bytes at a time — for binary files or streaming
File.open("video.mp4", "rb") do |f|
while (chunk = f.read(4096)) # read 4KB at a time
process_chunk(chunk)
end
end
Reading with Position (Seek) #
File.open("data.bin", "rb") do |f|
# Move to byte position 100
f.seek(100)
puts f.pos # => 100
# Read 10 bytes from the current position
data = f.read(10)
# Move relative to the current position
f.seek(50, IO::SEEK_CUR)
# Move from the end of the file
f.seek(-20, IO::SEEK_END)
# Back to the beginning
f.rewind
puts f.pos # => 0
end
flowchart TD
A[Need to read a file] --> B{How big is\nthe file?}
B -- "Small < 10MB" --> C{Need all\nlines at once?}
B -- "Large > 10MB" --> D[each_line or\nread with chunks]
C -- Yes --> E["File.readlines"]
C -- No --> F["File.read"]
D --> G{Structured format?}
G -- "CSV" --> H["CSV.foreach"]
G -- "Text lines" --> I["File.foreach"]
G -- "Binary / custom" --> J["read(chunk_size)"]Writing Files #
Writing to files also has several approaches, depending on whether you write all at once or incrementally.
# File.write — write all at once, returns the number of bytes written
bytes = File.write("output.txt", "Article content\n")
puts bytes # => 16
# File.write with append mode
File.write("log.txt", "New line\n", mode: "a")
# Writing incrementally with an open block
File.open("report.txt", "w") do |f|
f.puts "Daily Report" # puts adds a newline automatically
f.puts "=" * 30
f.write "No automatic newline"
f.print "Alias of write"
f.printf "Name: %-10s Score: %d\n", "Ahmad", 95
end
# Shovel operator for appending
File.open("log.txt", "a") do |f|
f << "#{Time.now}: Log message\n"
f << "Next line\n"
end
# flush — force the buffer to disk before closing
File.open("important.txt", "w") do |f|
f.write("Critical data")
f.flush # make sure it's on disk, not in the OS buffer
end
Writing with sync #
# Enable sync — every write goes directly to disk, no buffering
File.open("realtime.log", "a") do |f|
f.sync = true
loop do
f.puts "#{Time.now}: status OK"
sleep 1
end
end
Resource Management with Blocks #
One of the most overlooked bug sources is forgetting to close files. Ruby solves this with the block idiom that guarantees the file is always closed.
# ANTI-PATTERN: opening a file without a block
file = File.open("data.txt", "r")
content = file.read
# ... other code that might raise an exception ...
file.close # this line might never execute!
# CORRECT: use a block — the file is automatically closed even on exceptions
File.open("data.txt", "r") do |file|
content = file.read
puts content
end
# the file is definitely closed here
# CORRECT: or use the class method shortcut
content = File.read("data.txt") # open, read, close automatically
sequenceDiagram
participant Program
participant OS
participant File
Note over Program: Without a block (dangerous)
Program->>OS: File.open("data.txt")
OS-->>Program: file descriptor
Program->>File: file.read
Program-xFile: Exception! close() never called
Note over OS: File descriptor leaked!
Note over Program: With a block (safe)
Program->>OS: File.open("data.txt") do |f|
OS-->>Program: file descriptor
Program->>File: f.read
File-->>Program: content
Program->>OS: ensure: f.close()
Note over OS: File descriptor releasedEvery unclosed file descriptor is a leaked OS resource. In programs that open many files or run for a long time, this can cause Too many open files errors.
File Metadata Operations #
Besides reading and writing content, you often need to check information about the file itself — whether it exists, its size, and when it was last modified.
# Existence checks
File.exist?("config.yml") # => true / false
File.file?("config.yml") # => true if a regular file
File.directory?("folder/") # => true if a directory
File.symlink?("link_to_file") # => true if a symbolic link
File.readable?("data.txt") # => true if readable
File.writable?("log.txt") # => true if writable
File.executable?("script.rb") # => true if executable
# File size
File.size("video.mp4") # => size in bytes
File.zero?("empty.txt") # => true if the file is 0 bytes
# Stat — complete information
stat = File.stat("data.txt")
stat.size # size in bytes
stat.mtime # last modification time (Time object)
stat.atime # last access time
stat.ctime # status change time
stat.mode # permission bits
stat.owned? # whether owned by the current process
# Path manipulation
File.basename("/home/user/documents/report.pdf") # => "report.pdf"
File.basename("/home/user/documents/report.pdf", ".pdf") # => "report"
File.dirname("/home/user/documents/report.pdf") # => "/home/user/documents"
File.extname("report.pdf") # => ".pdf"
File.split("/home/user/report.pdf") # => ["/home/user", "report.pdf"]
# Expand path — make the path absolute
File.expand_path("../config", __FILE__)
File.expand_path("~/.bashrc") # expand the home directory
Filesystem Operations #
Besides reading/writing content, Ruby also provides methods for manipulating files as entities in the filesystem.
# Copy files
FileUtils.cp("original.txt", "copy.txt")
FileUtils.cp_r("source_folder/", "destination_folder/") # recursive
# Move / rename
FileUtils.mv("old.txt", "new.txt")
File.rename("old.txt", "new.txt") # built-in alternative
# Delete files
File.delete("temp.txt")
FileUtils.rm_f("maybe_exists.txt") # no error if it doesn't exist
FileUtils.rm_rf("temp_folder/") # recursive delete (CAREFUL!)
# Create directories
Dir.mkdir("new_folder")
FileUtils.mkdir_p("a/b/c/d") # create including parents (like mkdir -p)
# Check and create if missing
FileUtils.mkdir_p("logs") unless Dir.exist?("logs")
# Change permissions
File.chmod(0o755, "script.rb") # rwxr-xr-x
FileUtils.chmod_R(0o644, "folder/") # recursive
# Create a symbolic link
File.symlink("target.txt", "link.txt")
# Create a temp file — automatically deleted after the block
require "tempfile"
Tempfile.create("prefix") do |f|
f.write("Temporary data")
f.flush
process_file(f.path)
end
# the file is automatically deleted
Directory Operations #
Iterating directory contents and finding files by pattern are very common operations in scripting and tooling.
# List directory contents
Dir.entries(".") # => [".", "..", "file1.rb", "folder1", ...]
Dir.children(".") # => ["file1.rb", "folder1", ...] (without . and ..)
# Glob — wildcard search
Dir.glob("*.rb") # all .rb files in the current directory
Dir.glob("**/*.rb") # all .rb files recursively
Dir.glob("config/*.{yml,yaml}") # yml or yaml files in the config folder
Dir.glob("test/*_test.rb") # files with the _test.rb suffix
# Shortcut [] is the same as glob
Dir["**/*.log"]
# Directory iteration
Dir.each_child(".") do |name|
puts name
end
# Change the working directory
Dir.chdir("/tmp") do
puts Dir.pwd # => "/tmp"
# operations within tmp
end
puts Dir.pwd # back to the original directory
# Current and home directories
Dir.pwd # => "/home/user/project"
Dir.home # => "/home/user"
Dir.home("root") # => "/root" (a specific user's home)
# Idiomatic pattern: process all Ruby files in a project
Dir.glob("**/*.rb").each do |path|
content = File.read(path)
line_count = content.lines.count
puts "#{path}: #{line_count} lines"
end
# Find the largest file in a directory
largest_file = Dir.glob("**/*")
.select { |f| File.file?(f) }
.max_by { |f| File.size(f) }
puts "Largest file: #{largest_file} (#{File.size(largest_file)} bytes)"
StringIO — Memory-Based IO #
StringIO lets you use the exact same IO API, but with a String as the backing store — not a file on disk. This is very useful for testing, buffering output, and processing text as if it came from a file.
require "stringio"
# Create a StringIO like opening a file
sio = StringIO.new("First line\nSecond line\nThird line\n")
# All IO methods are available
sio.readline # => "First line\n"
sio.pos # => 15
sio.rewind # back to the beginning
sio.read # => the entire content
# StringIO for writing
output = StringIO.new
output.puts "Header"
output.puts "=" * 20
output.puts "Report content"
puts output.string # fetch the result as a String
StringIO for Testing #
StringIO is very useful for isolating code that writes to $stdout or $stderr in unit tests:
require "stringio"
# Capture output that normally goes to stdout
def capture_output
old_stdout = $stdout
$stdout = StringIO.new
yield
$stdout.string
ensure
$stdout = old_stdout
end
# In a test
output = capture_output do
puts "Hello from the tested function"
print "Second line"
end
puts output # => "Hello from the tested function\nSecond line"
flowchart LR
A[Code using IO] --> B{IO destination?}
B -- "Real file" --> C["File.open(path)"]
B -- "Test / buffer" --> D["StringIO.new"]
B -- "Terminal output" --> E["$stdout / STDOUT"]
C --> F[Disk]
D --> G[RAM / String]
E --> H[Terminal]IO Error Handling #
IO operations can always fail — files not found, permissions denied, disks full, or network connections dropped. Robust programs must handle all these possibilities correctly.
# The IO exception hierarchy
# Exception
# └── StandardError
# └── IOError
# ├── EOFError
# └── SystemCallError (Errno::*)
# ├── Errno::ENOENT (file not found)
# ├── Errno::EACCES (permission denied)
# ├── Errno::ENOSPC (disk full)
# ├── Errno::EISDIR (target is a directory)
# └── Errno::EEXIST (file already exists)
# Specific error handling
begin
File.open("secret.txt", "r") do |f|
puts f.read
end
rescue Errno::ENOENT => e
puts "File not found: #{e.message}"
rescue Errno::EACCES => e
puts "Access denied: #{e.message}"
rescue IOError => e
puts "IO error: #{e.message}"
end
# Checking without exceptions (for predictable conditions)
def read_config(path)
return nil unless File.exist?(path)
return nil unless File.readable?(path)
File.read(path)
end
# EOFError — when reading past the end of the file
File.open("data.txt") do |f|
loop do
begin
line = f.readline
process(line)
rescue EOFError
break
end
end
end
# A more idiomatic way to iterate until EOF
File.open("data.txt") do |f|
f.each_line { |line| process(line) }
end
# ANTI-PATTERN: rescuing Exception is too broad
begin
File.write("/root/system.conf", content)
rescue Exception => e # catches EVERYTHING, including interrupts!
puts "Failed"
end
# CORRECT: rescue specific exceptions
begin
File.write("/root/system.conf", content)
rescue Errno::EACCES
puts "Permission denied — root access needed"
rescue Errno::ENOSPC
puts "Disk full — free up space first"
rescue IOError => e
puts "Unexpected IO error: #{e.message}"
end
IO Buffering and Performance #
Ruby does IO buffering by default for efficiency. Understanding buffering helps you avoid data-loss problems or delayed output.
# sync=false (default) — the buffer fills first, then writes to the OS
# sync=true — every write goes directly to the OS
# A common buffering problem: output doesn't appear on crash
$stdout.sync = true # enable for real-time output
# REQUIRED when deploying to heroku / containers that line-buffer stdout
# Usually with: STDOUT.sync = true at the start of the program, or
# the environment variable: RUBY_STDOUT_SYNC=1
# flush — force the buffer to drain now
$stdout.flush
# How to write large data efficiently
File.open("large.txt", "w") do |f|
10_000.times do |i|
f.print "Line #{i}\n" # buffered automatically
end
# flush happens automatically when the block ends and the file closes
end
# For log files that must be written immediately
File.open("audit.log", "a") do |f|
f.sync = true
f.puts "#{Time.now.iso8601}: #{message}"
end
| Setting | When to Use |
|---|---|
sync = false (default) | Writing large data in batches, better performance |
sync = true | Real-time logs, output that must be visible immediately |
Manual flush | Specific points where data must be safe on disk |
fsync | Critical data that must truly persist to the hardware |
Idiomatic IO Patterns in Ruby #
After understanding all the IO mechanisms, here are the patterns commonly used in production Ruby code.
Read-Transform-Write #
# The classic pipeline: read, process, write
File.open("output.txt", "w") do |out|
File.foreach("input.txt") do |line|
out.puts line.chomp.upcase.gsub(/\s+/, "_")
end
end
Atomic Write — Safe Writing Without Data Corruption #
# ANTI-PATTERN: writing directly to the destination file
# If the program crashes midway, the file becomes corrupted/partial
File.open("config.yml", "w") do |f|
f.write(new_content) # crash here = config.yml corrupted!
end
# CORRECT: write to a temp file first, then rename (atomic at the OS level)
require "tempfile"
def atomic_write(path, content)
dir = File.dirname(path)
Tempfile.create("atomic", dir) do |tmp|
tmp.write(content)
tmp.flush
tmp.fsync
File.rename(tmp.path, path)
end
end
atomic_write("config.yml", new_content)
Processing Large CSV Files #
require "csv"
# ANTI-PATTERN: reading everything into memory
all = CSV.read("large_data.csv", headers: true)
all.each { |row| process(row) } # can OOM for GB-sized files
# CORRECT: stream line by line
CSV.foreach("large_data.csv", headers: true) do |row|
process(row["name"], row["email"])
end
Simple Log Rotation #
def write_log(message, path: "app.log", max_size: 10 * 1024 * 1024)
# Rotate if the file is too large
if File.exist?(path) && File.size(path) > max_size
File.rename(path, "#{path}.#{Time.now.strftime('%Y%m%d%H%M%S')}")
end
File.open(path, "a") do |f|
f.sync = true
f.puts "[#{Time.now.iso8601}] #{message}"
end
end
When to Switch to a Different Approach #
Keep using built-in IO/File when:
✓ Reading and writing plain text files
✓ Filesystem operations (copy, move, delete, stat)
✓ Streaming large data line by line
✓ Creating temp files
✓ Simple file logging
Consider libraries/other approaches when:
✗ Parsing complex CSV — use the csv library from stdlib
✗ ZIP/TAR files — use the Zip gem or built-in Zlib
✗ Database access — use specific adapters (PG, MySQL2, SQLite3)
✗ Network IO (HTTP) — use Net::HTTP or Faraday/HTTParty
✗ Watching file changes — use the Listen gem or inotify
✗ Complex binary file formats (PDF, XLSX) — use specific libraries
Summary #
- Always use blocks with
File.open— guarantees the file is closed even on exceptions, avoiding file descriptor leaks.- Choose opening modes correctly —
"w"instantly deletes old content; use"a"for appending and"r+"for editing without deleting.File.foreachvsFile.read— useforeachoreach_linefor large files so memory isn’t overloaded;readis only for small files.StringIOfor testing — replace$stdout/$stderrwithStringIO.newin tests to capture output without writing to disk.- Atomic writes for critical data — write to a temp file then
rename, not directly to the destination, to prevent corruption if the program crashes.sync = truefor real-time logs — enable it when output must be visible immediately, especially in container environments that buffer stdout by default.- Rescue specific IO exceptions — handle
Errno::ENOENT,Errno::EACCES, andErrno::ENOSPCseparately so error messages are meaningful to users.Dir.globfor file searching — more expressive and safer thanDir.entrieswith manual filtering; supports the*,**, and{a,b}wildcards.FileUtils.mkdir_pfor creating directories — equivalent tomkdir -p, creates the entire directory tree at once without errors if it already exists.