Basic Input and Output in Ruby: gets, puts, print, and File I/O Operations Explained

Basic input and output in Ruby

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

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:

Best Practices

  1. Always use block form (File.open(path) do |f| ... end) so files close automatically.
  2. Stream large files with each_line or foreach instead of read or readlines.
  3. Chomp your gets calls — nearly every gets should be followed by .chomp.
  4. Send errors to STDERR, not STDOUT, especially in scripts meant to be composed with other command-line tools.
  5. Validate numeric input with Integer()/Float() rather than trusting .to_i/.to_f, which silently default to zero on bad input.
  6. Set explicit encodings when reading files that might not be UTF-8, to avoid Encoding::UndefinedConversionError surprises later.

Common Mistakes

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

Exit mobile version