Input and Output
📡 Topic 3 — Input and Output
Almost everything a script does involves reading something in or writing something out. This chapter covers the full toolkit: echo and printf for output, read for interactive user input, the redirection operators that send data to files, and pipes that chain commands together. By the end you will also understand how here-documents let you embed multi-line input directly inside a script.
1 — Standard Streams
Every process on Linux has three standard data streams automatically connected when it starts. Understanding them is the key to understanding redirection and pipes.
Script
| Stream | FD | Default connection | Used for |
|---|---|---|---|
stdin | 0 | Keyboard | Input that the script reads |
stdout | 1 | Terminal screen | Normal output (echo, printf) |
stderr | 2 | Terminal screen | Error messages — separate from stdout so errors can be handled independently |
Redirection operators change where these streams connect — to files, to other streams, or to other commands via pipes.
2 — Output with echo
echo is the simplest way to print to stdout. It outputs its arguments followed by a newline.
# Basic output — adds a newline at the end
echo "Hello, World!"
Hello, World!
# -n suppresses the trailing newline
echo -n "Enter your name: "
# Cursor stays on the same line — useful before read
# -e enables interpretation of escape sequences
echo -e "Line one\nLine two\nLine three"
Line one
Line two
Line three
echo -e "Column 1\tColumn 2\tColumn 3"
Column 1 Column 2 Column 3
# Useful escape sequences with -e
# \n — newline \t — tab \\ — backslash
# \a — alert bell \b — backspace
echo with no flags varies slightly between systems. For consistent formatted output across platforms, use printf (see section 3).Writing to stderr
By convention, error messages should go to stderr (file descriptor 2), not stdout. This lets the caller separate normal output from errors.
# Redirect echo's output to stderr using >&2
echo "ERROR: File not found." >&2
# Practical pattern: write a reusable error function
error() {
echo "ERROR: $1" >&2
}
error "Could not read config file."
ERROR: Could not read config file. # printed to stderr
3 — Formatted Output with printf
printf gives you precise control over formatting. It works like C's printf: a format string with placeholders, followed by the values to insert. Unlike echo, it does not add a newline automatically — you must include \n explicitly.
# %s — string, %d — integer, %f — floating point
printf "Hello, %s!\n" "Philip"
Hello, Philip!
printf "You have %d messages.\n" 42
You have 42 messages.
printf "Price: %.2f\n" 9.5
Price: 9.50
# Width and alignment — great for building tables
# %-20s — left-align in a 20-char column
# %8d — right-align integer in 8-char column
printf "%-20s %8s %10s\n" "Name" "Age" "City"
printf "%-20s %8d %10s\n" "Philip" 32 "London"
printf "%-20s %8d %10s\n" "Anna" 28 "Budapest"
printf "%-20s %8d %10s\n" "Kenji" 35 "Tokyo"
Name Age City
Philip 32 London
Anna 28 Budapest
Kenji 35 Tokyo
# Store formatted output in a variable
line=$(printf "%-20s %5d" "count" 99)
echo "$line"
| Specifier | Type | Example |
|---|---|---|
%s | String | printf "%s" "hello" → hello |
%d | Integer (decimal) | printf "%d" 42 → 42 |
%f | Floating point | printf "%.2f" 3.14159 → 3.14 |
%05d | Zero-padded integer | printf "%05d" 7 → 00007 |
%-10s | Left-aligned string, 10 chars wide | printf "%-10s|" "hi" → hi | |
%10s | Right-aligned string, 10 chars wide | printf "%10s|" "hi" → hi| |
\n | Newline | Must be explicit — printf does not add one automatically |
\t | Tab |
4 — Reading User Input with read
The read built-in reads a line from stdin and stores it in one or more variables. It is the standard way to make an interactive script that prompts the user for information.
#!/bin/bash
# Basic: prompt then read
echo -n "What is your name? "
read name
echo "Hello, $name!"
# -p: inline prompt (cleaner — no need for a separate echo)
read -p "Enter your city: " city
echo "You live in $city."
# Read multiple variables — words split on whitespace
read -p "Enter first and last name: " first last
echo "First: $first Last: $last"
# If more words than variables, the last variable gets the remainder
# read first last → "John Paul Jones" gives first=John last="Paul Jones"
Useful read Options
# -s: silent mode — input is not echoed (for passwords)
read -s -p "Password: " password
echo # print newline after hidden input
echo "Password stored (not shown)."
# -n: read exactly N characters (no Enter needed)
read -n 1 -p "Press any key to continue..."
echo
# -t: timeout in seconds — returns non-zero exit if time expires
read -t 5 -p "You have 5 seconds to answer: " answer
if [ "$?" -ne 0 ]; then
echo "\nTime's up!"
fi
# -r: raw mode — backslash is NOT treated as an escape character
# Always use -r when reading file paths or arbitrary input
read -r -p "Enter a file path: " filepath
# -a: read words into an array
read -r -a colours -p "Enter colours: "
echo "First colour: ${colours[0]}"
read -r as the default — without it, a backslash at the end of a line acts as a line continuation, which can cause silent data loss.read beyond interactive input is reading a file line by line in a loop: while IFS= read -r line; do echo "$line"; done < file.txt. The IFS= prevents leading/trailing whitespace from being stripped. This pattern is covered in depth in Topic 6 (Loops).
5 — Redirection
Redirection operators change where a command's stdin, stdout, or stderr is connected. Instead of the terminal, you can send output to a file, read input from a file, or route error messages separately.
Output Redirection
# > creates (or overwrites) a file with stdout
echo "Hello" > output.txt
cat output.txt
Hello
# >> appends to a file (does not overwrite)
echo "World" >> output.txt
cat output.txt
Hello
World
# 2> redirects stderr to a file
ls /nonexistent 2> errors.log
cat errors.log
ls: cannot access '/nonexistent': No such file or directory
# 2>> appends stderr to a file
ls /another_bad_path 2>> errors.log
# &> (or >&) redirects both stdout AND stderr to a file
./my_script.sh &> all_output.log
Input Redirection
# < feeds a file into a command's stdin
sort < names.txt
# same as: sort names.txt (for commands that accept file arguments)
# but < works universally for any command that reads stdin
# Useful when a command does not accept a filename argument
while read -r line; do
echo "Line: $line"
done < data.txt
Combining Redirections
# Send stdout to one file, stderr to another
./script.sh > output.log 2> errors.log
# Redirect stderr to the same place as stdout (order matters!)
./script.sh > all.log 2>&1
# Read as: stdout → all.log, then stderr → wherever stdout now points
# Common mistake — reversed order sends stderr to the OLD stdout (terminal)
./script.sh 2>&1 > all.log # WRONG: stderr still goes to terminal
# Discard all output (send to /dev/null — the black hole)
./script.sh >/dev/null 2>&1
# Discard only errors
./script.sh 2>/dev/null
/dev/null is a special device that discards anything written to it and returns EOF when read. It is the standard way to suppress output you don't care about.| Operator | Effect |
|---|---|
cmd > file | Write stdout to file (overwrite) |
cmd >> file | Append stdout to file |
cmd < file | Read stdin from file |
cmd 2> file | Write stderr to file (overwrite) |
cmd 2>> file | Append stderr to file |
cmd &> file | Write both stdout and stderr to file |
cmd > file 2>&1 | Write both to file (POSIX-compatible form) |
cmd 2>/dev/null | Discard all error output |
cmd >/dev/null 2>&1 | Discard all output entirely |
6 — Pipes
A pipe | connects the stdout of one command directly to the stdin of the next, letting you chain commands together into a processing pipeline. No intermediate file is needed — data flows in memory.
# Count lines in a file
cat names.txt | wc -l
# Sort a file, remove duplicates, show the first 5
cat names.txt | sort | uniq | head -5
# Find all running bash processes
ps aux | grep "bash" | grep -v "grep"
# Count how many lines contain the word "error" (case-insensitive)
cat app.log | grep -i "error" | wc -l
# Convert a list of filenames to uppercase
ls | tr '[:lower:]' '[:upper:]'
set -o pipefail (covered in Topic 11).tee — Branch a Pipeline
The tee command reads stdin and writes it to both stdout and a file simultaneously — like a T-junction in a pipe. Useful when you want to log output and still see it on screen.
# Display output on screen AND save to a file
./build.sh | tee build.log
# Append to the file instead of overwriting
./test.sh | tee -a test.log
# Capture both stdout and stderr, display and log
./script.sh 2>&1 | tee all.log
7 — Here-Documents
A here-document (heredoc) lets you embed a block of multi-line text directly in a script and feed it as stdin to a command. This is far cleaner than running many echo statements in a row.
#!/bin/bash
# The delimiter (EOF here, but any word works) marks the start and end
cat <<EOF
This is line one.
This is line two.
Today is $(date +%Y-%m-%d) and the user is $USER.
EOF
This is line one.
This is line two.
Today is 2026-06-09 and the user is philip.
# Write a multi-line file in one block
cat <<EOF > config.txt
host=localhost
port=8080
debug=false
EOF
# Suppress variable expansion with a quoted delimiter
cat <<'EOF'
The variable $USER will not be expanded here.
This is printed literally.
EOF
The variable $USER will not be expanded here.
# Indent the closing delimiter with <<- (strips leading TABS, not spaces)
if true; then
cat <<-EOF
This heredoc is indented with tabs.
The leading tabs are stripped from output.
EOF
fi
<<- with tabs). A common source of "unexpected EOF" errors.#!/bin/bash
report_file="report_$(date +%Y%m%d).txt"
cat <<EOF > "$report_file"
========================================
System Report — $(date)
========================================
Host : $(hostname)
User : $USER
Uptime : $(uptime -p)
Disk : $(df -h / | tail -1 | awk '{print $5 " used"}')
========================================
EOF
echo "Report saved to $report_file"
Here-Strings
A here-string <<< is a compact way to pass a single string as stdin to a command — without a file or a full heredoc.
# Feed a string to grep without needing echo | grep
grep "World" <<< "Hello, World!"
Hello, World!
# Useful with read to parse a string into variables
csv_line="Philip,32,London"
IFS=',' read -r name age city <<< "$csv_line"
echo "Name: $name Age: $age City: $city"
Name: Philip Age: 32 City: London
8 — Quick Reference
| Command / Syntax | What it does |
|---|---|
echo "text" | Print text with a trailing newline |
echo -n "text" | Print without trailing newline |
echo -e "a\nb" | Print with escape sequences interpreted |
echo "msg" >&2 | Print to stderr |
printf "%s\n" "text" | Formatted print (no automatic newline) |
read -r var | Read a line from stdin into var |
read -r -p "prompt" var | Prompt then read |
read -r -s -p "pw: " pw | Read silently (password) |
read -r -t 5 var | Read with 5-second timeout |
cmd > file | Redirect stdout to file (overwrite) |
cmd >> file | Append stdout to file |
cmd 2> file | Redirect stderr to file |
cmd > f 2>&1 | Redirect stdout and stderr to file |
cmd >/dev/null | Discard output |
cmd1 | cmd2 | Pipe stdout of cmd1 to stdin of cmd2 |
cmd | tee file | Display output AND save to file |
cmd <<EOF … EOF | Here-document: feed block of text as stdin |
cmd <<< "string" | Here-string: feed single string as stdin |
✏️ Exercises
Apply what you have learned in this chapter. Try each exercise yourself before looking at the sample solution.
register.sh that asks the user for their first name, last name, and age (each on a separate prompt), then prints a formatted summary. The age prompt should use read -t 10 — if the user doesn't respond in 10 seconds, print "No age given" and continue.read -r -p for the name prompts, read -r -t 10 -p for the age prompt, and check $? after the age read to detect a timeout.#!/bin/bash
# register.sh
read -r -p "First name: " first
read -r -p "Last name: " last
read -r -t 10 -p "Age (10 sec): " age
if [ "$?" -ne 0 ]; then
echo
age="No age given"
fi
printf "\n--- Registration Summary ---\n"
printf "Full name : %s %s\n" "$first" "$last"
printf "Age : %s\n" "$age"
logger.sh that accepts a message as a command-line argument, writes it (with a timestamp) to a file called app.log, and also prints it to the screen. If no argument is given, write "ERROR: no message provided" to stderr and exit. Run it several times to verify it appends rather than overwrites.${1:?...} or an explicit if [ -z "$1" ] check, >> to append to the log file, and tee -a to show and log simultaneously.#!/bin/bash
# logger.sh
if [ -z "$1" ]; then
echo "ERROR: no message provided" >&2
exit 1
fi
timestamp=$(date +"%Y-%m-%d %H:%M:%S")
entry="[$timestamp] $1"
echo "$entry" | tee -a app.log
table.sh that uses printf to print a neatly aligned table of at least four items with three columns: Name, Price (formatted to 2 decimal places), and In Stock (Yes/No). Include a header row with a separator line made of dashes.printf "%-20s %8s %10s\n" for the header and printf "%-20s %8.2f %10s\n" for the data rows. Generate the separator line with printf '%0.s-' {1..42} or a hardcoded string.#!/bin/bash
# table.sh
printf "%-20s %10s %10s\n" "Name" "Price" "In Stock"
printf '%.0s-' {1..44}; echo
printf "%-20s %10.2f %10s\n" "Raspberry Pi 5" 74.99 "Yes"
printf "%-20s %10.2f %10s\n" "USB-C Cable" 8.5 "Yes"
printf "%-20s %10.2f %10s\n" "HDMI Adapter" 12.0 "No"
printf "%-20s %10.2f %10s\n" "MicroSD 64GB" 11.99 "Yes"
printf '%.0s-' {1..44}; echo
gen_config.sh that uses a here-document to generate a configuration file called server.conf. The file should include the current hostname, current date, and a fixed set of configuration values. Also redirect any errors from the file-write to a file called gen_config.err.cat <<EOF > server.conf 2> gen_config.err. Include at least one $(command) substitution inside the heredoc to embed live system values.#!/bin/bash
# gen_config.sh
cat <<EOF > server.conf 2> gen_config.err
# server.conf — generated by gen_config.sh
# Generated : $(date)
# Host : $(hostname)
listen_address = 0.0.0.0
listen_port = 8080
max_connections = 100
log_level = info
log_file = /var/log/server.log
EOF
if [ "$?" -eq 0 ]; then
echo "Config written to server.conf"
else
echo "Failed to write config — see gen_config.err" >&2
fi