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 #

  • puts vs print vs pputs for user-friendly output, p for debugging (shows with inspect), print for output without a newline.
  • $stdout.sync = true for real-time output — without it, output may be buffered and not appear immediately.
  • Always use File.open with a block — the file auto-closes even when an exception occurs, no manual close needed.
  • File.foreach for large files — reads line by line without loading everything into memory; far safer than File.readlines for large files.
  • File.read, File.write, File.foreach are the most commonly used class-method shortcuts for simple file operations.
  • Pathname makes path manipulation more expressive with the / operator and object-oriented methods.
  • FileUtils for high-level operations — cp_r, mkdir_p, rm_rf that plain File and Dir don’t have.
  • "rb" and "wb" file modes for binary — important for non-text files like images, PDFs, or binary data.
  • StringIO for I/O testing — can capture output or supply input without using the real filesystem.
  • Open3.capture3 for safely running external commands — capture stdout, stderr, and the exit status all at once.

← Previous: Multithreading   Next: Sockets →

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