Every useful script needs to accept input — either interactively from a user typing at the terminal or programmatically from arguments passed on the command line. Handling both correctly, with proper validation, is what makes a script safe to run in production.
The read builtin reads a line from stdin into a variable. It has many flags for prompts, timeouts, silent input (passwords), and character limits.
# Basic read
read -r name
echo "You entered: ${name}"
# -p : inline prompt (no newline before input)
read -rp "Enter your name: " name
# -s : silent mode (for passwords — input not echoed)
read -rsp "Password: " password
echo # Add newline after silent input
# -t : timeout in seconds
if ! read -rt 10 -p "Continue? [y/N]: " answer; then
echo "Timed out — defaulting to No"
answer="n"
fi
# -n : read exactly N characters (no Enter needed)
read -rn1 -p "Press any key to continue..."
echo
# -a : read into an array
read -ra fruits -p "Enter fruits (space-separated): "
for f in "${fruits[@]}"; do
echo " Fruit: ${f}"
done
# -d : custom delimiter (read until that character)
read -rd $'\0' content < file.txt # Read entire file into var
Never trust input. Validate argument count, type, and range before any processing. Exit early with a clear error message and non-zero exit code.
#!/usr/bin/env bash
# backup.sh — validate all inputs before proceeding
set -euo pipefail
usage() {
echo "Usage: $(basename "$0") <source_dir> <dest_dir> [--compress]"
echo " source_dir : directory to back up"
echo " dest_dir : where to store the backup"
echo " --compress : gzip the backup (optional)"
exit 1
}
# Check argument count
[[ $# -lt 2 ]] && { echo "ERROR: Missing required arguments"; usage; }
SOURCE="${1}"
DEST="${2}"
COMPRESS="false"
[[ "${3:-}" == "--compress" ]] && COMPRESS="true"
# Validate source exists and is a directory
[[ -d "${SOURCE}" ]] || { echo "ERROR: Source is not a directory: ${SOURCE}"; exit 1; }
# Validate source is readable
[[ -r "${SOURCE}" ]] || { echo "ERROR: Cannot read source: ${SOURCE}"; exit 1; }
# Create dest if it doesn't exist
mkdir -p "${DEST}" || { echo "ERROR: Cannot create destination: ${DEST}"; exit 1; }
echo "✔ Source : ${SOURCE}"
echo "✔ Dest : ${DEST}"
echo "✔ Compress : ${COMPRESS}"
echo "Starting backup..."
shift removes the first positional argument, shifting all remaining ones down by one position. It is the traditional way to process arguments in a loop without getopts.
#!/usr/bin/env bash
# deploy.sh — manual argument parsing with shift
ENV="development"
VERSION=""
DRY_RUN="false"
VERBOSE="false"
while [[ $# -gt 0 ]]; do
case "${1}" in
-e|--env)
ENV="${2}"
shift 2 # Consume flag AND its value
;;
-v|--version)
VERSION="${2}"
shift 2
;;
--dry-run)
DRY_RUN="true"
shift # Boolean flag — no value follows
;;
--verbose|-V)
VERBOSE="true"
shift
;;
--help|-h)
echo "Usage: deploy.sh [-e env] [-v version] [--dry-run] [--verbose]"
exit 0
;;
--)
shift # End of options
break
;;
-*)
echo "Unknown option: ${1}" >&2
exit 1
;;
*)
break # Positional argument — stop parsing flags
;;
esac
done
echo "ENV = ${ENV}"
echo "VERSION = ${VERSION:-latest}"
echo "DRY_RUN = ${DRY_RUN}"
echo "VERBOSE = ${VERBOSE}"
getopts is the POSIX-standard builtin for parsing short options like -e, -v, -h. It handles combined flags like -eV automatically. For long options like --env, use the while/case/shift pattern from Section 3.
#!/usr/bin/env bash
# monitor.sh — getopts for short option parsing
CPU_THRESHOLD=80
MEM_THRESHOLD=90
VERBOSE="false"
LOG_FILE="/var/log/monitor.log"
# Option string: c: = -c requires argument, v = flag only
while getopts ":c:m:l:vh" opt; do
case "${opt}" in
c) CPU_THRESHOLD="${OPTARG}" ;;
m) MEM_THRESHOLD="${OPTARG}" ;;
l) LOG_FILE="${OPTARG}" ;;
v) VERBOSE="true" ;;
h) echo "Usage: monitor.sh [-c cpu%] [-m mem%] [-l logfile] [-v]"; exit 0 ;;
:) echo "Option -${OPTARG} requires an argument" >&2; exit 1 ;;
\?) echo "Invalid option: -${OPTARG}" >&2; exit 1 ;;
esac
done
# Skip past parsed options to reach positional args
shift $(( OPTIND - 1 ))
echo "CPU threshold : ${CPU_THRESHOLD}%"
echo "MEM threshold : ${MEM_THRESHOLD}%"
echo "Log file : ${LOG_FILE}"
echo "Verbose : ${VERBOSE}"
[[ $# -gt 0 ]] && echo "Remaining args: $*"
#!/usr/bin/env bash
# menu.sh — interactive menu with select
PS3="Select an action: " # Prompt for select
options=("Start service" "Stop service" "Check status" "View logs" "Quit")
select choice in "${options[@]}"; do
case "${choice}" in
"Start service")
echo "Starting myapp service..."
systemctl start myapp
;;
"Stop service")
echo "Stopping myapp service..."
systemctl stop myapp
;;
"Check status")
systemctl status myapp
;;
"View logs")
journalctl -u myapp -n 50
;;
"Quit")
echo "Goodbye!"
break
;;
*)
echo "Invalid option: ${REPLY}. Choose 1-${#options[@]}"
;;
esac
done
# Read line by line from a file
while IFS= read -r line; do
echo "Processing: ${line}"
done < /etc/hosts
# Read from a command's output
while IFS= read -r user; do
echo "User: ${user}"
done < <(getent passwd | cut -d: -f1)
# Read CSV — split on comma
while IFS=',' read -r host port service; do
echo "Checking ${service} at ${host}:${port}"
done < services.csv
# Check if script is receiving piped input
if [[ ! -t 0 ]]; then
echo "Reading from pipe or file..."
while IFS= read -r line; do
echo " ${line}"
done
else
echo "Running interactively"
fi
read -r (raw mode prevents backslash interpretation). Always use IFS= before read when reading lines to preserve leading/trailing whitespace. Always validate every argument before using it. These three rules prevent the most common input-handling bugs.getopts for simple short options (-c -v -h). Use the manual while/case/shift pattern when you need long options (--cpu --verbose --help) or a mix of both. For very complex CLIs, consider a dedicated argument parsing library.