Every programmer’s first real program usually involves printing something to the screen or reading something typed by a user, and Ruby makes that step almost deceptively easy. But I remember being surprised, once I started building actual tools with Ruby — CLI utilities, log parsers, config readers — at how much depth was hiding behind puts and gets. In this article I want to walk through Ruby’s input and output model properly: the console methods everyone learns first, the differences between them that actually matter, and then the file I/O operations you’ll need the moment you move past toy scripts.
The Console Basics: puts, print, and p
Ruby gives you three main ways to write to the standard output, and each behaves differently in ways that matter more than people expect.
puts
puts writes its argument followed by a newline. If the argument already ends in a newline, it won’t add a second one. If you pass an array, puts prints each element on its own line.
puts "Hello, Ruby!"
puts ["apple", "banana", "cherry"]
Output:
Hello, Ruby!
apple
banana
cherry
print writes its argument with no trailing newline and no automatic array flattening the way puts does — array elements are just joined without separators.
print "Loading"
print "."
print "."
print "."
puts # just to move to a new line after
Output:
Loading...
p
p is the one people forget about, but it’s the one I use most while debugging. It calls .inspect on its argument instead of .to_s, which means it shows you the literal Ruby representation of an object — quotes around strings, nil shown explicitly, and so on.
name = "Ruby"
puts name # Ruby
p name # "Ruby"
value = nil
puts value # (prints nothing, blank line)
p value # nil
This difference matters a lot in practice. If you’re debugging why a string has trailing whitespace, puts will hide it from you — p won’t.
mystery = "hello "
puts mystery # hello (you can't see the trailing spaces)
p mystery # "hello " (now you can)
There’s also pp (pretty print), useful for deeply nested hashes and arrays, and print combined with $stdout.flush when you need output to appear immediately without buffering delays — important in long-running scripts with progress indicators.
Reading Input: gets
gets reads a line of text from standard input, including the trailing newline character, which is why you’ll almost always see it chained with .chomp:
print "What's your name? "
name = gets.chomp
puts "Nice to meet you, #{name}!"
Running this interactively:
What's your name? Ada
Nice to meet you, Ada!
Without .chomp, name would actually contain "Ada\n", and any string comparison or concatenation downstream would behave unexpectedly — this is one of the most common beginner bugs in Ruby CLI scripts.
If you need a number instead of a string, gets still returns a String, so you have to convert it explicitly:
print "Enter your age: "
age = gets.chomp.to_i
puts "In 10 years you'll be #{age + 10}."
One quirk worth knowing: to_i silently returns 0 if it can’t parse a number, rather than raising an error. So "abc".to_i is 0, not an exception. If you need strict validation, use Integer("abc") instead, which raises ArgumentError on bad input — genuinely useful when you want to catch malformed user input rather than silently treating it as zero.
begin
age = Integer(gets.chomp)
rescue ArgumentError
puts "That's not a valid number."
end
STDIN, STDOUT, and STDERR
Under the hood, gets and puts are just convenience methods that operate on the global $stdin and $stdout streams (technically they’re defined on Kernel, and delegate to these streams). You can address them directly:
STDOUT.puts "This goes to standard output"
STDERR.puts "This goes to standard error"
line = STDIN.gets
This distinction matters the moment you write a script meant to be piped or redirected in a shell. Errors and diagnostic messages belong on STDERR so they don’t pollute output that might be piped into another program:
def process(data)
raise "empty input" if data.nil?
data.upcase
end
begin
result = process(nil)
rescue => e
STDERR.puts "Error: #{e.message}"
exit 1
end
Running ruby script.rb > output.txt will send the error to your terminal while output.txt stays clean, because STDERR was never redirected.
File I/O: Reading and Writing Files
Once you move beyond talking to a human at a terminal, you’ll spend most of your I/O time reading and writing files. Ruby’s File class (a subclass of IO) covers this comprehensively.
Writing to a File
File.open("notes.txt", "w") do |file|
file.puts "First line"
file.puts "Second line"
end
The "w" mode truncates the file if it exists, or creates it if it doesn’t. Using the block form is important — Ruby automatically closes the file handle when the block ends, even if an exception is raised inside it. This is the single most important habit to build with file I/O in Ruby.
Appending to a File
File.open("notes.txt", "a") do |file|
file.puts "Third line, appended later"
end
Reading an Entire File
content = File.read("notes.txt")
puts content
Output:
First line
Second line
Third line, appended later
Reading Line by Line
For large files, reading the whole thing into memory with File.read is wasteful. Use each_line or File.foreach to stream through it:
File.foreach("notes.txt") do |line|
puts "Line: #{line.chomp}"
end
Or, using an open file handle:
File.open("notes.txt", "r") do |file|
file.each_line do |line|
puts line.chomp.upcase
end
end
readlines
If you genuinely need every line as an array (say, to process it out of order), readlines gives you that, at the cost of loading the whole file into memory:
lines = File.readlines("notes.txt")
puts lines.length
puts lines.first
Checking Existence and Metadata
if File.exist?("notes.txt")
puts "Size: #{File.size("notes.txt")} bytes"
puts "Last modified: #{File.mtime("notes.txt")}"
else
puts "File not found"
end
Internal Working: How Ruby’s IO Actually Behaves
File inherits from IO, and understanding IO explains a lot of behavior you’ll bump into. Ruby’s IO objects wrap a file descriptor provided by the operating system, and reads/writes are buffered in userspace by default — this is why you sometimes need $stdout.sync = true or explicit .flush calls in long-running scripts that print progress: without flushing, Ruby may hold output in a buffer rather than sending it to the terminal immediately, especially when output is redirected to a file or pipe rather than an interactive terminal (which uses line-buffering by default, while piped output uses full buffering).
File modes matter for how Ruby opens the underlying OS file descriptor: "r" (read-only, error if the file doesn’t exist), "w" (write, truncate or create), "a" (append, create if missing), "r+" (read/write, must exist), "w+" (read/write, truncate or create), and "a+" (read/append). Adding "b" to any of these (e.g. "rb") opens the file in binary mode, important on Windows where text mode does newline translation that can corrupt binary data like images.
Encoding is another subtlety. Ruby strings carry an encoding tag, and File.read uses the default external encoding (usually UTF-8) unless told otherwise:
File.open("data.txt", "r:UTF-8") do |file|
puts file.read.encoding
end
Real-World Applications
I reach for these patterns constantly:
- CLI tools that prompt the user for input and validate it before proceeding.
- Log file parsers that stream through gigabyte-sized log files line by line rather than loading them entirely into memory.
- Configuration readers that load a YAML or JSON file at startup (
File.readcombined withJSON.parseorYAML.load). - Report generators that write structured output to disk for later use, using
File.open(path, "w")blocks. - Interactive scripts for onboarding or setup wizards that combine
gets, validation loops, and file writes.
Best Practices
- Always use block form (
File.open(path) do |f| ... end) so files close automatically. - Stream large files with
each_lineorforeachinstead ofreadorreadlines. - Chomp your gets calls — nearly every
getsshould be followed by.chomp. - Send errors to STDERR, not STDOUT, especially in scripts meant to be composed with other command-line tools.
- Validate numeric input with
Integer()/Float()rather than trusting.to_i/.to_f, which silently default to zero on bad input. - Set explicit encodings when reading files that might not be UTF-8, to avoid
Encoding::UndefinedConversionErrorsurprises later.
Common Mistakes
- Forgetting
.chompand being confused why string comparisons fail. - Opening a file without the block form and forgetting to call
.close, leaking file handles in long-running processes. - Using
"w"mode when you meant"a", silently wiping out an existing file’s contents. - Reading massive files fully into memory with
File.readwhen the task only needed to scan line by line. - Assuming
getsreturnsnilgracefully on EOF in all contexts — in some environments (like piped input)getsreturnsnilat end-of-file, and calling.chomponnilraises aNoMethodError. Always guard withgets&.chompor check fornilexplicitly in scripts that might read piped input.
Debugging Tips
When output isn’t showing up when you expect it to, check buffering first — try $stdout.sync = true at the top of your script to force immediate flushing. When file content looks wrong, use p instead of puts to reveal hidden whitespace or encoding artifacts. And when a script behaves differently in a pipeline than it does interactively, remember that STDIN.tty? tells you whether input is coming from an actual terminal or being redirected — useful for scripts that need to behave differently in both cases.
Summary
Ruby’s I/O methods look simple on the surface — puts, print, gets — but each has real behavioral differences worth knowing precisely, and the File/IO class hierarchy underneath gives you fine control once you need it: buffering, encoding, binary vs. text mode, and streaming large files efficiently. Building the habit of using block-form file handling, chomping your input, and sending errors to STDERR will save you from the vast majority of I/O-related bugs you’ll otherwise hit as your scripts grow from toy examples into real tools.
References
- Ruby’s official
IOdocumentation: https://docs.ruby-lang.org/en/master/IO.html - Ruby’s official
Filedocumentation: https://docs.ruby-lang.org/en/master/File.html - Ruby’s
Kernel#gets,#puts,#print,#pdocumentation: https://docs.ruby-lang.org/en/master/Kernel.html - Ruby style guide on I/O practices: https://rubystyle.guide/
