I/O #
Input/Output is the foundation of almost every useful program — reading configuration from files, writing logs, processing large CSVs, taking user input, or running shell commands. Ruby provides a rich I/O class hierarchy: IO as the base class, File for filesystem operations, Dir for directories, Pathname for more expressive path manipulation, and StringIO for I/O to in-memory strings. What makes Ruby I/O idiomatic is the use of blocks for automatic resource management — File.open with a block ensures the file is always closed even when an exception occurs. This article covers all aspects of I/O, from simple screen output to efficient large-file processing.
Output to the Screen #
Ruby has several methods for writing to stdout, each with different behavior:
# puts — adds a newline at the end, arrays print one per line
puts "Hello, world!" # => "Hello, world!\n"
puts [1, 2, 3] # => "1\n2\n3\n"
puts nil # => just an empty newline
puts # => a newline only
# print — doesn't add a newline
print "Name: "
print "Budi"
print "\n" # must be manual
# p — prints with inspect, suitable for debugging
p "hello" # => "hello" (with quotes)
p [1, 2, 3] # => [1, 2, 3]
p nil # => nil (not an empty line like puts)
p 42 # => 42
# p returns its argument's value — useful for debugging mid-chain
result = [1, 2, 3].map { |n| n * 2 }.tap { |a| p a }.select { |n| n > 3 }
# pp — pretty print, neater for complex data structures
require 'pp'
pp({ name: "Rina", address: { city: "Bandung", postal_code: "40111" }, active: true })
# Output formatted with indentation
# printf — C-style formatted output
printf("%-15s %5d %8.2f\n", "Laptop", 5, 15_000_000.0)
printf("%-15s %5d %8.2f\n", "Mouse", 50, 350_000.0)
# => Laptop 5 15000000.00
# => Mouse 50 350000.00
# sprintf / format — build a formatted string without printing directly
line = format("%-15s %5d", "Monitor", 10)
puts line
# $stdout vs STDOUT
$stdout.puts "To stdout" # same as puts
$stderr.puts "To stderr" # to the error stream — not mixed with regular output
STDERR.puts "Error!" # a constant, same as $stderr
Buffer Flushing #
Ruby output is buffered by default — output might not appear on the screen immediately:
# ANTI-PATTERN: buffering causes output to not appear mid-long-process
10.times do |i|
print "Processing #{i}..."
sleep 0.5
puts " done"
end
# Output appears only after everything finishes, not in real time!
# CORRECT: flush after every important output
10.times do |i|
print "Processing #{i}..."
$stdout.flush # or: STDOUT.flush
sleep 0.5
puts " done"
end
# Or: disable buffering globally
$stdout.sync = true # every puts/print is flushed immediately
# The idiomatic way — sync at the start of the program
STDOUT.sync = true
Input from the User #
# gets — reads one line of input including the newline
print "Enter your name: "
name = gets.chomp # chomp removes the newline at the end
puts "Hello, #{name}!"
# gets without chomp — there's a \n at the end
line = gets
puts line.length # the length includes the \n character
# STDIN.gets — explicit stdin (useful when ARGV is present)
name = STDIN.gets.chomp
# Reading an integer from input
print "Enter a number: "
number = Integer(gets.chomp) rescue nil
if number
puts "Double it: #{number * 2}"
else
puts "That's not a valid number"
end
# Input loop until a condition is met
loop do
print "Enter 'exit' to stop: "
input = gets&.chomp # &. because gets can be nil at EOF
break if input.nil? || input.downcase == "exit"
puts "You typed: #{input}"
end
ARGV — Command Line Arguments #
# Accessing command line arguments
# Run: ruby program.rb file1.txt file2.txt --verbose
puts ARGV.inspect # => ["file1.txt", "file2.txt", "--verbose"]
puts ARGV.length # => 3
# Simple argument processing
verbose = ARGV.delete("--verbose") # remove the flag and return its value
file_paths = ARGV # the rest are file names
puts "Verbose mode: #{!verbose.nil?}"
puts "Files to process: #{file_paths.join(', ')}"
# More complete argument parsing with OptionParser
require 'optparse'
options = {}
parser = OptionParser.new do |opts|
opts.banner = "Usage: program.rb [options] file..."
opts.on("-v", "--verbose", "Verbose mode") do
options[:verbose] = true
end
opts.on("-o", "--output FILE_NAME", "Output file") do |f|
options[:output] = f
end
opts.on("-n", "--count N", Integer, "Number of lines") do |n|
options[:count] = n
end
opts.on("-h", "--help", "Show help") do
puts opts
exit
end
end
parser.parse! # parse and remove options from ARGV
puts options.inspect
puts "Remaining arguments: #{ARGV.inspect}"
Reading Files #
How to Read — Choose the Right One #
# File.read — reads the entire file contents into a String
# FINE for small files, DANGEROUS for large files!
content = File.read("config.json")
puts content
# File.readlines — reads all lines into an Array of Strings
lines = File.readlines("data.txt")
puts "Number of lines: #{lines.length}"
lines.each { |l| puts l.chomp }
# File.readlines with chomp: true — strip newlines automatically (Ruby 2.4+)
lines = File.readlines("data.txt", chomp: true)
lines.each { |l| puts l } # no \n at the end
# File.foreach — reads one line per iteration, does NOT load everything into memory
# THE BEST WAY for large files!
File.foreach("big_data.csv") do |line|
process(line.chomp)
end
# File.foreach with chomp
File.foreach("data.txt", chomp: true) { |l| puts l }
# Lazy evaluation on large files — process with filters
File.foreach("access.log")
.lazy
.select { |line| line.include?("ERROR") }
.map { |line| line.chomp }
.first(10)
.each { |l| puts l }
flowchart TD
A[Need to read a file] --> B{File size?}
B --> C["Small\n< 10MB"]
B --> D["Large\n> 10MB or unknown"]
C --> E{Need all lines?}
E --> F["Yes → File.readlines"]
E --> G["No → File.read\nor File.foreach"]
D --> H["File.foreach\nor IO.foreach\nRead line by line"]
H --> I["Doesn't load everything\ninto memory at once"]File.open with a Block — The Most Idiomatic #
# ANTI-PATTERN: opening a file without a block — must close manually
file = File.open("data.txt", "r")
content = file.read
file.close # easy to forget, and doesn't run if an exception happens earlier!
# CORRECT: File.open with a block — automatically closed when the block ends
File.open("data.txt", "r") do |file|
file.each_line do |line|
puts line.chomp
end
end
# file.closed? => true here
# Reading with an explicit encoding
File.open("data_utf8.txt", "r:UTF-8") do |f|
puts f.read
end
File.open("data_latin.txt", "r:ISO-8859-1:UTF-8") do |f|
# read as ISO-8859-1, convert to UTF-8
puts f.read
end
# Reading a specific number of bytes
File.open("binary.dat", "rb") do |f| # rb = read binary
header = f.read(4) # read the first 4 bytes
puts header.bytes.map { |b| format("%02X", b) }.join(" ")
end
Read Methods on File Objects #
File.open("data.txt") do |f|
# Position and navigation
puts f.pos # current pointer position (bytes)
puts f.size # file size in bytes
puts f.eof? # are we at the end of the file?
# Reading with different granularities
char = f.getc # read one character
byte = f.getbyte # read one byte
line = f.gets # read one line (including \n)
line = f.readline # like gets, but raises EOFError at the end
chunk = f.read(1024) # read 1024 bytes
# Pointer navigation
f.rewind # go back to the start of the file
f.seek(100) # move to byte 100
f.seek(-50, IO::SEEK_END) # 50 bytes before the end
f.seek(20, IO::SEEK_CUR) # 20 bytes from the current position
# Read all remaining lines
remaining_lines = f.readlines
end
Writing to Files #
File Modes #
# Ruby file mode table
# "r" — read only, the file must exist
# "w" — write only, create new or truncate existing content
# "a" — append, write at the end of the file, create if missing
# "r+" — read+write, the file must exist, doesn't truncate
# "w+" — read+write, create new or truncate
# "a+" — read+append, read pointer from the start, write at the end
# "rb", "wb", "ab" — binary modes (important for non-text files)
# Write to a new file (truncates if it exists)
File.open("output.txt", "w") do |f|
f.puts "First line"
f.puts "Second line"
f.write "Without a newline"
f.write "\n"
f.print "Also without a newline"
f.puts # just a newline
end
# Append to an existing file
File.open("log.txt", "a") do |f|
f.puts "[#{Time.now}] Event occurred"
end
# File.write — a shortcut for writing a string to a file
File.write("config.json", JSON.pretty_generate(config))
# File.write with append mode
File.write("log.txt", "#{Time.now}: Event\n", mode: "a")
Writing with Buffering and Flushing #
# Write many lines efficiently — buffered in Ruby, flushed at the end
File.open("results.csv", "w") do |f|
f.puts "name,age,city" # header
1000.times do |i|
f.puts "User#{i},#{rand(20..60)},City#{rand(10)}"
# No need to flush each line — the buffer flushes automatically on close
end
end # flush and close automatically here
# Manual flushing when you need real-time writes (e.g. logs read live)
File.open("live_log.txt", "a") do |f|
loop do
event = fetch_event()
f.puts "[#{Time.now}] #{event}"
f.flush # ensure it's written to disk, not just the buffer
sleep 1
end
end
File Utility Methods #
The File class has many class methods for file operations without opening the file:
# File information
puts File.exist?("config.txt") # => true/false
puts File.file?("config.txt") # => true (it's a regular file)
puts File.directory?("data/") # => true (it's a directory)
puts File.readable?("config.txt") # => true (can be read)
puts File.writable?("config.txt") # => true (can be written)
puts File.executable?("script.sh") # => true (can be executed)
puts File.empty?("file.txt") # => true (empty file / 0 bytes)
puts File.size("data.csv") # => size in bytes
# Time metadata
puts File.mtime("config.txt") # last modification time
puts File.ctime("config.txt") # inode change time (metadata)
puts File.atime("config.txt") # last access time
# Path manipulation — without touching the filesystem
puts File.basename("/path/to/file.txt") # => "file.txt"
puts File.basename("/path/to/file.txt", ".txt") # => "file"
puts File.dirname("/path/to/file.txt") # => "/path/to"
puts File.extname("report.xlsx") # => ".xlsx"
puts File.extname("archive.tar.gz") # => ".gz"
puts File.split("/path/to/file.txt").inspect # => ["/path/to", "file.txt"]
# Building paths — more portable than string concatenation
puts File.join("data", "2024", "report.csv")
# => "data/2024/report.csv" (automatically the correct separator per OS)
# Expand path — resolve ~ and relative paths
puts File.expand_path("~/.config/ruby") # => "/home/user/.config/ruby"
puts File.expand_path("../data", __FILE__) # relative to the current file
puts File.expand_path(".") # current working directory
# File operations
File.rename("old.txt", "new.txt") # rename/move a file
File.delete("temp.txt") # delete a file
File.chmod(0644, "script.rb") # change permissions
File.truncate("file.txt", 0) # empty a file without deleting it
Pathname — OOP for Paths #
Pathname provides a more expressive object-oriented interface for file and path operations:
require 'pathname'
# Creating Pathnames
root = Pathname.new("/var/app")
config = Pathname.new("config/database.yml")
home = Pathname.new("~").expand_path
# Path navigation — using the / operator like a filesystem
log_dir = root / "log" # => Pathname: /var/app/log
access_log = root / "log" / "access.log" # => Pathname: /var/app/log/access.log
puts access_log.dirname # => /var/app/log
puts access_log.basename # => access.log
puts access_log.extname # => .log
puts access_log.exist? # => true/false
puts access_log.size # => bytes
# Read and write directly
content = (root / "config.json").read
(root / "output.txt").write("result")
(root / "log.txt").open("a") { |f| f.puts "new entry" }
# Directory iteration
(root / "log").children.each do |path|
puts "#{path.basename}: #{path.size} bytes" if path.file?
end
# Glob
(root / "data").glob("**/*.csv").each do |csv|
puts csv.relative_path_from(root)
end
# Conversion
puts access_log.to_s # => "/var/app/log/access.log" (String)
puts access_log.to_path # => "/var/app/log/access.log"
Directory Operations #
Dir — Listing and Navigation #
# Current working directory
puts Dir.pwd # => "/home/user/projects/app"
# Change directory (only within this process)
Dir.chdir("/tmp")
puts Dir.pwd # => "/tmp"
Dir.chdir("/home/user/projects/app") # back
# Temporarily change within a block
Dir.chdir("/tmp") do
puts Dir.pwd # => "/tmp"
# do something in /tmp
end
puts Dir.pwd # back to the original directory
# Directory contents
puts Dir.entries(".").inspect # includes "." and ".."
puts Dir.children(".").inspect # without "." and ".."
puts Dir["*.rb"].inspect # only .rb files (glob alias)
puts Dir.glob("**/*.rb").inspect # recursive all .rb
# Glob patterns
Dir.glob("data/*.csv") # all CSVs in the data/ folder
Dir.glob("**/*.{rb,rake}") # all .rb and .rake in all subfolders
Dir.glob("[0-9][0-9][0-9][0-9]/") # folders named with 4 digits
Dir.glob("log/*", File::FNM_DOTMATCH) # including dot files (hidden)
# Create directories
Dir.mkdir("backup") # create one level
FileUtils.mkdir_p("a/b/c/d") # create all levels at once (recursive)
FileUtils — High-Level File Operations #
FileUtils provides more powerful file operations than plain File and Dir:
require 'fileutils'
# Copy
FileUtils.cp("source.txt", "dest.txt") # copy a file
FileUtils.cp_r("src_dir/", "dest_dir/") # copy a directory recursively
# Move / rename
FileUtils.mv("old.txt", "new.txt") # move/rename a file
FileUtils.mv("data/", "backup/data/") # move a directory
# Delete
FileUtils.rm("file.txt") # delete a file
FileUtils.rm_f("maybe_not_exists.txt") # delete, ignore if missing
FileUtils.rm_r("directory/") # delete a directory recursively
FileUtils.rm_rf("directory/") # delete, ignore errors (rm -rf)
# Create directories
FileUtils.mkdir("one_level/")
FileUtils.mkdir_p("many/levels/at/once/") # mkdir -p
# Change permissions and ownership
FileUtils.chmod(0755, "script.sh")
FileUtils.chmod_R(0644, "data/") # recursive
FileUtils.chown("user", "group", "file.txt")
# Touch — update the timestamp or create an empty file
FileUtils.touch("file.txt") # create or update mtime
FileUtils.touch(["a.txt", "b.txt", "c.txt"]) # many files at once
# Verbose and noop options (dry run)
FileUtils.rm_rf("dir/", verbose: true) # print the commands being run
FileUtils.cp_r("src/", "dst/", noop: true, verbose: true) # simulation only
StringIO — I/O to In-Memory Strings #
StringIO lets you use the IO API on an in-memory string — very useful for testing:
require 'stringio'
# Write to a string (not a file)
buffer = StringIO.new
buffer.puts "First line"
buffer.puts "Second line"
buffer.write "Data: #{42}\n"
puts buffer.string # get the contents as a String
# => "First line\nSecond line\nData: 42\n"
# Read from a string
source = StringIO.new("apple\nmango\norange\n")
source.each_line { |line| puts line.chomp.upcase }
# Very useful for testing — stubbing $stdout
def capture_output
buffer = StringIO.new
$stdout = buffer
yield
buffer.string
ensure
$stdout = STDOUT
end
output = capture_output do
puts "This will be captured"
p [1, 2, 3]
end
puts output.inspect
Pipes and External Processes #
Ruby provides several ways to interact with system processes:
# Backticks / %x{} — run a command, capture the output as a String
ls_output = `ls -la`
puts ls_output
hostname = %x{hostname}.chomp
puts "Server: #{hostname}"
# Check the exit status
`git status`
puts $?.exitstatus # => 0 (success) or non-zero (failure)
# system — run a command, output goes directly to the terminal
system("clear")
system("git", "status") # safer than string interpolation
puts $?.success? # => true if successful
# IO.popen — open a pipe to an external process
IO.popen("wc -l", "r+") do |pipe|
pipe.write("first line\nsecond line\nthird line\n")
pipe.close_write
puts pipe.read.strip # => "3"
end
# Open3 — full control over stdin, stdout, stderr
require 'open3'
stdout, stderr, status = Open3.capture3("git log --oneline -5")
if status.success?
puts "The last 5 commits:"
puts stdout
else
puts "Error: #{stderr}"
end
# Streaming output from a process
Open3.popen3("ping -c 4 google.com") do |stdin, stdout, stderr, thread|
stdout.each_line { |line| print line }
thread.join
end
Idiomatic I/O Patterns #
# 1. Always use a block for File.open
# ANTI-PATTERN: manual close
f = File.open("data.txt")
data = f.read
f.close # can be forgotten or not run if an exception occurs
# CORRECT: an auto-closing block
data = File.open("data.txt") { |f| f.read }
# Or more concisely:
data = File.read("data.txt")
# 2. File.foreach for large files — doesn't load everything into memory
# ANTI-PATTERN: for large files
all_lines = File.readlines("big_log.txt") # loads everything into RAM!
all_lines.each { |l| process(l) }
# CORRECT: one line per iteration
File.foreach("big_log.txt", chomp: true) { |l| process(l) }
# 3. Use Pathname for code with lots of path manipulation
# ANTI-PATTERN: string concatenation for paths
path = base_dir + "/" + sub_dir + "/" + filename
# CORRECT: Pathname with the / operator
path = Pathname.new(base_dir) / sub_dir / filename
# 4. require 'fileutils' for operations not in File
# File doesn't have cp_r, mkdir_p, rm_rf — use FileUtils
# 5. Proper error handling for I/O
def read_config(path)
File.read(path)
rescue Errno::ENOENT
raise "Config file not found: #{path}"
rescue Errno::EACCES
raise "No permission to read: #{path}"
rescue Errno::EISDIR
raise "Path points to a directory, not a file: #{path}"
end
Summary #
putsvsp—putsfor user-friendly output,pfor debugging (shows with inspect),$stdout.sync = truefor real-time output — without it, output may be buffered and not appear immediately.- Always use
File.openwith a block — the file auto-closes even when an exception occurs, no manualcloseneeded.File.foreachfor large files — reads line by line without loading everything into memory; far safer thanFile.readlinesfor large files.File.read,File.write,File.foreachare the most commonly used class-method shortcuts for simple file operations.Pathnamemakes path manipulation more expressive with the/operator and object-oriented methods.FileUtilsfor high-level operations —cp_r,mkdir_p,rm_rfthat plainFileandDirdon’t have."rb"and"wb"file modes for binary — important for non-text files like images, PDFs, or binary data.StringIOfor I/O testing — can capture output or supply input without using the real filesystem.Open3.capture3for safely running external commands — capture stdout, stderr, and the exit status all at once.