Shell Scripting Practical - Functions, Arrays, and File Operation Techniques
What you'll be able to do
- Split work into functions with local variables and arrays you can reuse
- Write scripts that stop on failure using `set -euo pipefail` and `trap`
- Run backup, log-monitoring, and file-organizing scripts without destroying data
Prerequisites (read these first)
Once you've mastered shell scripting basics, it's time to build practical advanced skills. This guide covers functions, arrays, file operations, and error handling. It closes with three scripts you can put straight to work.
Terms used in this article (defined up front)
- Function: a named block of work, so you do not write the same steps twice
- Local variable: a variable that exists only inside a function. Declare it with
local. Withoutlocalit becomes a global variable shared by the whole script - Array: several values held in one variable, retrieved by index as in
${arr[0]} - Associative array: an array keyed by strings instead of numbers. Also called a hash or dictionary. Available from Bash 4.0
- Exit code: the number a command returns when it finishes.
0means success, anything else means failure. Also called exit status - Strict mode: shorthand for
set -euo pipefail, the settings that stop a script instead of letting a failure slip past trap: the mechanism for registering work to run when something happens, such as an error or the script exiting- Subshell: a separate shell process branched off the parent. Variables changed inside it do not carry back out
What You'll Learn
- How to define functions with local variables, return values, and error handling
- How to create and manipulate indexed and associative arrays
- Techniques for reading files line-by-line, writing output, and processing CSV data
- How
set -euo pipefailandtrapbuild robust error handling into scripts - Three real-world automation scripts: system backup, log monitoring, and file organizer
Functions
Conclusion: In functions: local for scope, echo to return values, return 1 to signal errors.
Defining and Calling Functions
Basic Function
$1 is the first argument passed to the function; $2 is the second.
#!/bin/bash
greet() {
echo "Hello, $1!"
}
greet "Alice"
greet "Bob"Hello, Alice! Hello, Bob!
Function with Return Value
#!/bin/bash
square() {
local num=$1
local result=$((num * num))
echo $result
}
number=5
result=$(square $number)
echo "Square of $number is $result"Function with Multiple Arguments
#!/bin/bash
file_info() {
local filepath=$1
if [ -f "$filepath" ]; then
echo "File: $filepath"
echo "Size: $(wc -c < "$filepath") bytes"
echo "Lines: $(wc -l < "$filepath") lines"
echo "Last modified: $(stat -c %y "$filepath")"
else
echo "File $filepath does not exist"
return 1
fi
}
file_info "/etc/passwd"
file_info "nonexistent.txt"Advanced Functions
Local vs Global Variables
#!/bin/bash
global_var="global"
demo_scope() {
local local_var="local"
global_var="modified global"
echo "Inside function: local_var = $local_var"
echo "Inside function: global_var = $global_var"
}
echo "Before call: global_var = $global_var"
demo_scope
echo "After call: global_var = $global_var"
echo "Outside function: local_var = $local_var" # emptyRecursive Function
#!/bin/bash
factorial() {
local n=$1
if [ $n -le 1 ]; then
echo 1
else
local prev=$(factorial $((n - 1)))
echo $((n * prev))
fi
}
for i in {1..5}; do
result=$(factorial $i)
echo "$i! = $result"
doneFunction with Error Handling
#!/bin/bash
safe_mkdir() {
local dir_path=$1
if [ -z "$dir_path" ]; then
echo "Error: no directory path specified" >&2
return 1
fi
if [ -d "$dir_path" ]; then
echo "Directory $dir_path already exists"
return 0
fi
if mkdir -p "$dir_path" 2>/dev/null; then
echo "Created directory $dir_path"
return 0
else
echo "Error: failed to create directory $dir_path" >&2
return 1
fi
}
safe_mkdir "/tmp/test_dir"
safe_mkdir "/root/forbidden" # permission error exampleArrays
Conclusion: "${arr[@]}" for safe expansion; declare -A for associative arrays (Bash 4.0+).
Basic Array Operations
Creating Arrays and Accessing Elements
#!/bin/bash
fruits=("apple" "banana" "orange" "grape")
numbers=(1 2 3 4 5)
echo "First fruit: ${fruits[0]}"
echo "Third number: ${numbers[2]}"
echo "All fruits: ${fruits[@]}"
echo "Number of fruits: ${#fruits[@]}"Adding and Removing Elements
#!/bin/bash
colors=("red" "green" "blue")
echo "Initial array: ${colors[@]}"
colors+=("yellow")
echo "After add: ${colors[@]}"
unset 'colors[1]' # remove "green"
echo "After delete: ${colors[@]}"
echo "Indices: ${!colors[@]}"Initial array: red green blue After add: red green blue yellow After delete: red blue yellow Indices: 0 2 3
After unset, the indices are sparse — 1 is gone. ${#colors[@]} is 3, but ${colors[1]} is empty. Looping by number skips the gap, so iterate with "${colors[@]}" or "${!colors[@]}".
Associative Arrays (Bash 4.0+)
#!/bin/bash
declare -A person
person["name"]="John Smith"
person["age"]="30"
person["city"]="New York"
echo "Name: ${person["name"]}"
echo "Age: ${person["age"]}"
echo "All keys: ${!person[@]}"Name: John Smith Age: 30 All keys: city age name
The key order of an associative array is not guaranteed. It has nothing to do with the order you defined them in. When order matters, keep a separate array of keys or pipe through sort.
Practical Array Usage
#!/bin/bash
log_patterns=("/var/log/*.log" "/tmp/*.log" "$HOME/*.log")
for pattern in "${log_patterns[@]}"; do
echo "Pattern: $pattern"
files=($pattern)
if [ ${#files[@]} -gt 0 ] && [ -f "${files[0]}" ]; then
for file in "${files[@]}"; do
if [ -f "$file" ]; then
size=$(wc -c < "$file")
echo " - $file ($size bytes)"
fi
done
else
echo " - No matching files"
fi
doneFile Operations
Conclusion: IFS= read -r + redirection not pipe — loop variables survive outside the loop.
Reading and Writing Files
Various File Reading Methods
#!/bin/bash
filename="data.txt"
# Method 1: using while read
while IFS= read -r line || [ -n "$line" ]; do
echo "Line: $line"
done < "$filename"
# Method 2: read into array
mapfile -t lines < "$filename"
for i in "${!lines[@]}"; do
echo "Line $((i+1)): ${lines[i]}"
doneWriting to Files
#!/bin/bash output_file="output.txt" echo "New file content" > "$output_file" echo "Additional line 1" >> "$output_file" cat << EOF >> "$output_file" Multi-line text Line 2 Line 3 EOF cat "$output_file"
Processing CSV Files
#!/bin/bash
csv_file="employees.csv"
cat << EOF > "$csv_file"
Name,Age,Department
Alice,30,Engineering
Bob,25,Sales
Carol,35,Management
EOF
tail -n +2 "$csv_file" | while IFS=',' read -r name age dept; do
echo "Employee: $name (age $age) - $dept"
doneEmployee: Alice (age 30) - Engineering Employee: Bob (age 25) - Sales Employee: Carol (age 35) - Management
This example does not modify variables on the right-hand side of the pipe, so the subshell problem does not apply. Switch to a redirect if you need to carry a total out of the loop.
Advanced File Operation Example
#!/bin/bash
source_dir="/path/to/source"
backup_dir="/path/to/backup"
backup_files() {
local src="$1"
local dest="$2"
if [ ! -d "$src" ]; then
echo "Error: source directory does not exist: $src"
return 1
fi
mkdir -p "$dest"
rsync -av --delete "$src/" "$dest/"
echo "Backup complete: $src -> $dest"
}
log_file="/tmp/backup.log"
{
echo "Backup started: $(date)"
backup_files "$source_dir" "$backup_dir"
echo "Backup finished: $(date)"
} >> "$log_file" 2>&1rsync --delete deletes files at the destination
--delete removes anything at the destination that is not at the source. Point it at the wrong source and the data at the destination is gone. An empty source directory empties the destination.
rsync also changes meaning with a trailing slash. rsync -a src/ dest/ puts the contents of src into dest. rsync -a src dest/ puts the src directory itself inside dest.
The safe way
- Run it with
--dry-runfirst and read what would be deleted:rsync -av --delete --dry-run "$src/" "$dest/" - Confirm the source directory exists and is not empty before the real run
- To keep replaced files, use
--backup --backup-dir=instead of--delete
Error Handling
Conclusion: Open production scripts with set -euo pipefail and trap on ERR to fail fast.
Error Handling Best Practices
Strict Error Handling with set Options
set -euo pipefail turns on three settings at once. All three exist to stop a script from carrying on past a failure nobody noticed.
#!/bin/bash set -euo pipefail # set -e: exit immediately when a command fails # set -u: error on undefined variables # set -o pipefail: error if any command in a pipeline fails trap 'echo "Error occurred: line $LINENO" >&2' ERR echo "Normal processing 1"
Manual Error Checking
#!/bin/bash
process_file() {
local filename="$1"
if [ ! -f "$filename" ]; then
echo "Error: file '$filename' not found" >&2
return 1
fi
if [ ! -r "$filename" ]; then
echo "Error: no read permission for '$filename'" >&2
return 1
fi
local line_count
if ! line_count=$(wc -l < "$filename" 2>/dev/null); then
echo "Error: failed to count lines" >&2
return 1
fi
echo "Line count for '$filename': $line_count"
return 0
}
if process_file "test.txt"; then
echo "Processing completed successfully"
else
echo "Processing failed"
exit 1
fiError Handling with Logging
#!/bin/bash
LOG_FILE="/tmp/script.log"
log() {
local level="$1"
shift
local message="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}
handle_error() {
local exit_code=$?
local line_no=$1
log "ERROR" "Script exited with error (exit code: $exit_code, line: $line_no)"
exit $exit_code
}
trap 'handle_error $LINENO' ERR
log "INFO" "Script started"
log "INFO" "Script finished"Real-World Script Examples
Conclusion: Backup, log monitor, organizer — three scripts on functions, arrays, and errors.
Example 1: System Backup Script
#!/bin/bash
set -euo pipefail
BACKUP_ROOT="/backup"
LOG_FILE="/var/log/backup.log"
RETENTION_DAYS=7
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIRS=(
"/etc"
"/home"
"/var/www"
"/usr/local/bin"
)
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
cleanup() {
log "Removing old backups"
find "$BACKUP_ROOT" -type f -name "backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete
log "Old backups removed"
}
main_backup() {
local backup_file="$BACKUP_ROOT/backup_$DATE.tar.gz"
log "Starting backup: $backup_file"
mkdir -p "$BACKUP_ROOT"
if tar -czf "$backup_file" "${BACKUP_DIRS[@]}" 2>/dev/null; then
local size=$(du -h "$backup_file" | cut -f1)
log "Backup successful: $backup_file (size: $size)"
else
log "Error: backup creation failed"
exit 1
fi
}
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root"
exit 1
fi
log "System backup started"
main_backup
cleanup
log "System backup completed"Run a deleting script once with the deletion turned off
find ... -delete inside cleanup() removes files without asking. Get BACKUP_ROOT wrong and it removes files from an unrelated directory.
How to introduce it safely
- Replace
-deletewith-printand read the list of targets - Switch back to
-deleteonly once that list is what you intended - Keep the
-name "backup_*.tar.gz"filter. Removing it widens the target set dramatically - Start with a generous
RETENTION_DAYS(90, say), confirm the behaviour, then shorten it
find evaluates its expression left to right. Put -delete before the conditions and the conditions never apply — everything under the starting point is deleted. Always write -name and friends before -delete.
Example 2: Log Monitoring Script
#!/bin/bash
set -euo pipefail
LOG_FILE="/var/log/syslog"
ALERT_PATTERNS=("ERROR" "CRITICAL" "FAILED")
CHECK_INTERVAL=10
LAST_CHECK_FILE="$HOME/.log_monitor_last_check" # home dir is safer than predictable /tmp path
send_alert() {
local message="$1"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] ALERT: $message" >> "/var/log/alerts.log"
logger "LOG_MONITOR_ALERT: $message"
}
monitor_logs() {
local last_position=0
if [ -f "$LAST_CHECK_FILE" ]; then
last_position=$(cat "$LAST_CHECK_FILE")
fi
local current_size=$(wc -c < "$LOG_FILE")
if [ "$current_size" -gt "$last_position" ]; then
local new_lines=$(tail -c +$((last_position + 1)) "$LOG_FILE")
for pattern in "${ALERT_PATTERNS[@]}"; do
if echo "$new_lines" | grep -q "$pattern"; then
local matches=$(echo "$new_lines" | grep "$pattern")
send_alert "Detected pattern '$pattern': $matches"
fi
done
echo "$current_size" > "$LAST_CHECK_FILE"
fi
}
echo "Starting log monitor: $LOG_FILE"
while true; do
monitor_logs
sleep "$CHECK_INTERVAL"
doneExample 3: File Organizer Script
#!/bin/bash
set -euo pipefail
SOURCE_DIR="$HOME/Downloads"
ORGANIZE_ROOT="$HOME/Organized"
LOG_FILE="/tmp/file_organizer.log"
declare -A FILE_TYPES=(
["pdf"]="Documents/PDF"
["doc,docx"]="Documents/Word"
["jpg,jpeg,png,gif"]="Images"
["mp4,avi,mov"]="Videos"
["zip,rar,7z,tar,gz"]="Archives"
)
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
get_file_type() {
local filename="$1"
local extension="${filename##*.}"
extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]')
for types in "${!FILE_TYPES[@]}"; do
if [[ ",$types," == *",$extension,"* ]]; then
echo "${FILE_TYPES[$types]}"
return 0
fi
done
echo "Others"
}
organize_files() {
log "Starting file organization: $SOURCE_DIR"
if [ ! -d "$SOURCE_DIR" ]; then
log "Error: source directory does not exist: $SOURCE_DIR"
exit 1
fi
local file_count=0
while IFS= read -r -d '' file; do
if [ -f "$file" ]; then
local file_type=$(get_file_type "$(basename "$file")")
local dest_dir="$ORGANIZE_ROOT/$file_type"
mkdir -p "$dest_dir"
if [ -e "$dest_dir/$(basename "$file")" ]; then
log "Skipped (name already exists): $(basename "$file")"
continue
fi
mv "$file" "$dest_dir/"
log "Moved: $(basename "$file") -> $file_type/"
file_count=$((file_count + 1))
fi
done < <(find "$SOURCE_DIR" -maxdepth 1 -type f -print0)
log "Organization complete: processed $file_count files"
}
organize_filesmv overwrites a same-named file without a word
If a file with the same name already exists at the destination, mv overwrites it silently. The script above checks with [ -e ... ] before moving, skips the file, and logs that it did.
mv -n (--no-clobber) also prevents the overwrite, but be careful putting it in a script: recent GNU coreutils (9.x) return exit code 1 when they skip, so under set -e a single name collision stops the whole run. To keep both copies instead, drop -n and use mv --backup=numbered — --backup and -n are mutually exclusive.
On the first run, replace mv with echo mv and read where each file would go. Once files have been reorganized, putting them back where they were is hard.
Common Mistakes and Pitfalls
Conclusion: Five pitfalls — expansion, scope, subshell, errors, security — each preventable.
Mistake 1: Incorrect Array Usage
NG (split by spaces)
files=("file1.txt" "file 2.txt" "file3.txt")
for file in $files; do # split by spaces
echo $file
doneOK (correct expansion)
files=("file1.txt" "file 2.txt" "file3.txt")
for file in "${files[@]}"; do # expand as array
echo "$file"
doneUse "${array[@]}" to safely expand the full array.
Mistake 2: Misunderstanding Function Scope
NG (global variable unintentionally modified)
counter=0
increment() {
counter=$((counter + 1)) # modifies global variable
local result=$counter
echo $result
}
increment
echo "Global counter: $counter" # unintended changeOK (proper scope management)
global_counter=0
increment() {
local local_counter=$1
local_counter=$((local_counter + 1))
echo $local_counter
}
result=$(increment $global_counter)
global_counter=$resultMistake 3: Pipeline Variable Modification Trap
NG (variable changes not reflected)
The right-hand side of a pipe runs in a subshell, a separate process. Variables changed there revert as soon as the pipeline ends.
count=0
cat file.txt | while IFS= read -r line; do
count=$((count + 1))
done
echo "Lines: $count" # stays 0 (runs in subshell)OK (use redirection)
count=0
while IFS= read -r line; do
count=$((count + 1))
done < file.txt
echo "Lines: $count" # counted correctly
# or
count=$(wc -l < file.txt)Mistake 4: Missing Error Handling
NG (errors cascade)
cp source.txt backup.txt rm source.txt # deletes even if copy failed curl -o data.json http://api.example.com/data process_data data.json # continues even if download failed
OK (robust error handling)
set -euo pipefail
if cp source.txt backup.txt; then
echo "Backup successful"
rm source.txt
else
echo "Error: backup failed" >&2
exit 1
fiMistake 5: Security-Unaware Implementation
NG (security issues)
eval runs whatever string it is handed as a command. If the user types rm -rf ~, that is exactly what runs. A temp file with a predictable name leaves room for someone else to create it first (a symlink attack). A password written on the command line shows up in ps output and in shell history.
read -p "Enter command: " user_command eval $user_command # arbitrary command execution (dangerous) temp_file="/tmp/script_data.txt" # predictable filename echo "sensitive data" > $temp_file mysql -u user -p'password123' -e "SELECT * FROM users" # visible in ps output and shell history
OK (secure implementation)
# validate input
read -p "Enter filename: " filename
if [[ "$filename" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "Processing: $filename"
else
echo "Error: invalid filename" >&2
exit 1
fi
# safe temp file creation
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT
# keep credentials in a config file, off the command line
# ~/.my.cnf (protect with chmod 600)
# [client]
# user=appuser
# password=...
mysql --defaults-extra-file="$HOME/.my.cnf" -e "SELECT * FROM users"Mistake 6: Performance-Unaware Implementation
NG (inefficient processing)
for file in /path/to/large/directory/*; do
wc -l "$file" # spawns a new process per file
done
for i in {1..1000}; do
current_time=$(date +%s) # runs date command every iteration
echo "Processing $i at $current_time"
doneOK (efficient implementation)
# batch processing for efficiency
find /path/to/large/directory -name "*" -type f -exec wc -l {} +
# fetch once, reuse
start_time=$(date +%s)
for i in {1..1000}; do
current_time=$((start_time + i))
echo "Processing $i at $current_time"
doneBest Practices
Conclusion: Production Bash: clear names, validated input, mktemp, set -x, and ShellCheck.
Coding Standards
- Use meaningful variable names
- Keep functions focused on a single responsibility
- Comment to clarify intent, not mechanics
- Use consistent indentation (2 spaces recommended)
Security
- Always validate user input
- Use
mktempfor temp files. Its names cannot be predicted or claimed in advance - Set minimal required permissions
- Never write passwords or tokens directly in a script
Debugging
set -xprints each command as it runs- Implement appropriate logging
- Develop and test incrementally
- Use static analysis tools such as ShellCheck. They catch accident-prone patterns, like a missing quote, before you run anything
Summary
Mastering practical shell scripting techniques enables efficient and reliable automation. For any script that destroys something, confirm the targets with --dry-run or -print before the real run.
- Functions improve code reusability and maintainability
- Arrays streamline complex data processing
- Proper error handling creates robust scripts
- Real-world examples provide patterns you can adapt for production