Skip to content

Backup Script V2 — Error Handling and Logging


Come here only after completing previous chapter. This is an evolution to our backup.sh script by adding logging, error handling and more.

Evolving v1: Adding A Logging Function

Reusable logic:

log() {
    local level="$1"
    shift
    local ts
    ts=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$ts] [$level] $*" >&2
}
log_info()  { log "INFO"  "$@"; }
log_warn()  { log "WARN"  "$@"; }
log_error() { log "ERROR" "$@"; }

Every message this script produces from here on goes through one of these, giving every line a consistent, parseable shape — genuinely useful the first time this script’s output ends up in a log file being read weeks later, rather than watched live on a terminal.

Meaningful Exit Codes

Each failure gets its own distinct, documented exit code, rather than a single undifferentiated exit 1:

# define exit codes 
radonly EXIT_BACKUP_FAILED=1
radonly EXIT_CP_FAILED=1 # treat as script/backup failure
radonly EXIT_SOURCE_MISSING=2
radonly EXIT_MKDIR_FAILED=3

Anything invoking this script — a cron job’s alerting, a monitoring dashboard, a person checking $? after a manual run — can now tell these three failure modes apart without having to parse the log message text at all.

Explicit Exit Status For Each Step

Rather than relying on set -e to silently halt the script, each step that can fail gets its own explicit check, paired with a specific log message:

# exit if source doesn't exists 
if ! dir_exists "$SOURCE"; then
    printf "Source directory doesn't exists: '$SOURCE'\n"
    exit "$EXIT_SOURCE_MISSING"
fi

# create if backup root does not exists, exit if creation failed 
create_dir_if_not_exists "$BACKUP_ROOT" "backup root" || (log_fatal "Backup root creation failed, Exiting..." && exit "$EXIT_MKDIR_FAILED")

# create backup, exit if failed (script failure exit 1)
create_backup "$SOURCE" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")

Command tested directly by if doesn’t trigger set -e on its own — which is exactly what’s being used deliberately here: wrapping each risky step in if ! command; then ...; fi intentionally opts out of set -e’s abrupt, unexplained stop, in favor of a specific, logged, intentional one. This is a genuine, deliberate application of one of set -e’s documented exceptions, not an oversight. Even if command failure triggers set -e, it’s good to be explicit and || exit <code> is a way to be explicit.

Explicitly Logging To stderr

Instead of normal printf which outputs everything to stdout, we explictly log according to type. For example:

# create dir only if it doesn't exists, takes 1st arg is dir name, 2nd arg is dir identifier to print 
create_dir_if_not_exists() {
    local dir_name="$1"
    local dir_identifier="$2"
    print_dir_check_msg_for "$dir_name" "$dir_identifier"
    if ! dir_exists "$dir_name"; then
        log_warn "Directory doesn't exists: '$dir_name', Creating..."
        if ! make_dir "$dir_name"; then
            log_error "Creating directory failed: '$dir_name'"
            return 1
        else
            log_info "Directory created successfully: '$dir_name'"
        fi
    fi
}

Genuine Output To stdout

Only success of failure output should be redirected to stdout. So, bare printf is left for:

# create backup, takes source and dest 
create_backup() {
    ...
    printf "Backup success: '$source' to '$destination'\n"
}

# create backup, exit if failed (script failure exit 1)
create_backup "$SOURCE" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")

Section Delimiter Removed/Replaced

In version1:

SECTION_END="************************************************************************************************"
# just print section end, no args required
print_section_end() {
    printf "\n$SECTION_END\n$SECTION_END\n\n"
}

With is replaced as:

# just print section end, no args required
print_section_end() {
    printf "\n"
}

Note

Since printf here is not redirected, this is treated as stdout. You can delete these extra newline if you don’t want. Alternatively, in scripts or anywhere, you can:

./backup.sh my_project | tr -d '\n'

The Full v2 Script

#!/usr/bin/env bash

set -euo pipefail

usage() {
    printf '%s\n' \
    "Usage: $0 <source>" \
    "<source> source directory to backup"
}

if [[ $# -ne 1 ]]; then
    usage
    exit 1
fi

# ---------------------------
# Constants & Readonly Vars
# ---------------------------
SOURCE="$1"
BACKUP_ROOT="$HOME/backup"

# define exit codes 
readonly EXIT_BACKUP_FAILED=1
readonly EXIT_CP_FAILED=1 # treat as script/backup failure
readonly EXIT_SOURCE_MISSING=2
readonly EXIT_MKDIR_FAILED=3

# ---------------------------
# User Defined Functions
# ---------------------------

# reusable log function
log() {
    local level="$1"
    shift
    local ts
    ts=$(date '+%Y-%m-%d %H:%M:%S')
    printf "[$ts] [$level] $*\n" >&2
}

# log function by type which uses log() under the hood, takes log message as argument
log_info()  { log "INFO"  "$@"; }
log_warn()  { log "WARN"  "$@"; }
log_error() { log "ERROR" "$@"; }
log_fatal() { log "FATAL" "$@"; }

# check if dir exists, takes 1st arg as dir name
dir_exists() {
    local dir_name="$1"
    [[ -d "$dir_name" ]] && return 0
}

# create dir (including missing parent), 1st arg is dir name
make_dir() {
    local dir_name="$1"
    mkdir -p "$dir_name" || return 1
}

# print check msg for dir, 1st arg is dir name, 2nd arg is identifier of dir
print_dir_check_msg_for () {
    local dir_name="$1"
    local dir_identifier="$2"
    log_info "Checking $dir_identifier directory: '$dir_name'"
}

# create dir only if it doesn't exists, takes 1st arg is dir name, 2nd arg is dir identifier to print 
create_dir_if_not_exists() {
    local dir_name="$1"
    local dir_identifier="$2"
    print_dir_check_msg_for "$dir_name" "$dir_identifier"
    if ! dir_exists "$dir_name"; then
        log_warn "Directory doesn't exists: '$dir_name', Creating..."
        if ! make_dir "$dir_name"; then
            log_error "Creating directory failed: '$dir_name'"
            return 1
        else
            log_info "Directory created successfully: '$dir_name'"
        fi
    fi
}

# create backup, takes source and dest 
create_backup() {
    local source="$1"
    local destination="$2"
    log_info "Backup process start: '$source' to '$destination'"
    cp_output=$(cp -r "$source" "$destination" 2>&1)
    cp_status=$?
    if [[ "$cp_status" -ne 0 ]]; then
        log_warn "$cp_output"
        log_fatal "Either all or some files failed to backup, could be permission issues."
        return 1
    fi
    printf "Backup success: '$source' to '$destination'\n"
}

# just print section end, no args required
print_section_end() {
    printf "\n"
}

# ---------------------------
# Driver Code
# ---------------------------

# exit if source doesn't exists 
if ! dir_exists "$SOURCE"; then
    log_fatal "Source directory doesn't exists: '$SOURCE', Exiting..."
    exit "$EXIT_SOURCE_MISSING"
fi

# create if backup root does not exists, exit if creation failed 
create_dir_if_not_exists "$BACKUP_ROOT" "backup root" || (log_fatal "Backup root creation failed, Exiting..." && exit "$EXIT_MKDIR_FAILED")
print_section_end

# create destination, exit if failed
timestamp=$(date +%Y%m%d_%H%M%S)
destination="$BACKUP_ROOT/$SOURCE/$timestamp"
create_dir_if_not_exists "$destination" "destination" || (log_fatal "Creating backup destination failed, Exiting..." && exit "$EXIT_MKDIR_FAILED")
print_section_end

# create backup, exit if failed (script failure exit 1)
create_backup "$SOURCE" "$destination" || (printf "Backup failed: partial or full, Exiting...\n" && exit "$EXIT_BACKUP_FAILED")

What Changed From v1

  • Distinct exit codes per failure type, replacing the complete absence of exit-code handling in v1.
  • A logging function, replacing v1’s single bare printf with log types.

Summary And Testing

We have now nice output — structured with logs:

./backup.sh database_project
[2026-08-30 22:54:05] [INFO] Checking backup root directory: '/home/alice/backup'
[2026-08-30 22:54:05] [WARN] Directory doesn't exists: '/home/alice/backup', Creating...
[2026-08-30 22:54:05] [INFO] Directory created successfully: '/home/alice/backup'

[2026-08-30 22:54:05] [INFO] Checking destination directory: '/home/alice/backup/database_project/20260830_225405'
[2026-08-30 22:54:05] [WARN] Directory doesn't exists: '/home/alice/backup/database_project/20260830_225405', Creating...
[2026-08-30 22:54:05] [INFO] Directory created successfully: '/home/alice/backup/database_project/20260830_225405'

[2026-08-30 22:54:05] [INFO] Backup process start: 'database_project' to '/home/alice/backup/database_project/20260830_225405'
Backup success: 'database_project' to '/home/alice/backup/database_project/20260830_225405'

Only this line lands in stdout:

Backup success: 'database_project' to '/home/alice/backup/database_project/20260830_225405'

To check the behavior, you can redirect stderr to /dev/null and remove those extra newlines:

./backup.sh database_project 2>/dev/null | tr -d '\n'
Backup success: 'database_project' to '/home/alice/backup/database_project/20260830_225405'

And if something fails:

./backup.sh root_owned_project 2>/dev/null | tr -d '\n'
Backup failed: partial or full, Exiting...

Full logs on failure:

[2026-08-30 23:06:59] [INFO] Checking backup root directory: '/home/alice/backup'

[2026-08-30 23:06:59] [INFO] Checking destination directory: '/home/alice/backup/root_owned_project//20260830_230659'
[2026-08-30 23:06:59] [WARN] Directory doesn't exists: '/home/alice/backup/root_owned_project//20260830_230659', Creating...
[2026-08-30 23:06:59] [INFO] Directory created successfully: '/home/alice/backup/root_owned_project//20260830_230659'

[2026-08-30 23:06:59] [INFO] Backup process start: 'root_owned_project/' to '/home/alice/backup/root_owned_project//20260830_230659'
[2026-08-30 23:06:59] [WARN] cp: cannot access 'root_owned_project/': Permission denied
[2026-08-30 23:06:59] [FATAL] Either all or some files failed to backup, could be permission issues.
Backup failed: partial or full, Exiting...

We will go more in depth in the next version. Are you ready yet?

Last updated on