Logging in Bash Scripts

Tested on RHEL 10.2 (Coughlan)
Package bash 5.2.26-6.el10
coreutils 9.5-8.el10_2
util-linux-core 2.40.2-18.el10
systemd 257-23.el10_2.2
Applies to Linux distributions with Bash installed
Privilege Normal user for Bash and file examples; journal visibility depends on system journal permissions
Scope Bash script logging: stdout for data, stderr for diagnostics, minimal log() with levels and timestamps, logging command output to files, tee, whole-script exec redirects, logger for syslog/journald, systemd service capture, rotation with logrotate/journald, and keeping secrets out of logs. Does not cover full observability stacks or structured JSON logging libraries.
Short on time?Beta Get the gist of this 14-minute read in seconds, then ask follow-up questions

Bash does not ship a logging framework like Python's logging module. A practical Bash logging strategy starts by deciding which output is data, which is a diagnostic, and which needs to survive after the script exits. You build a small log() helper, send each output type to the right destination, and avoid mixing machine-readable stdout with human diagnostics. The patterns below work for cron jobs, CLI tools, and systemd services without managing an ever-growing log file yourself.

Create a lab directory once before the examples:

bash
mkdir -p /tmp/bash-logging-lab

What Should a Bash Script Log?

Log what helps someone answer three questions later:

  • What did the script try to do?
  • Did it succeed or fail?
  • If it failed, what context explains the failure?

Skip noise. A line per trivial step fills disks and hides real errors. Log state changes, external calls, retries, and final outcomes. Leave routine inner-loop progress at DEBUG when you need it at all.

Operational logs are not the same as program output. If another command will pipe your script's stdout into jq, awk, or a database loader, that stream must stay clean. Cron email and systemd journals are for humans and operators reviewing a run — not for the next program in a pipeline.


stdout vs stderr vs Operational Logs

Use each output destination for a different purpose:

Stream / sink Use for Avoid
stdout Requested result or machine-readable data Human status lines and warnings
stderr Diagnostics the operator should see immediately JSON or CSV the next command must parse
Log file Cron or long-run history when nobody watches the terminal Replacing stderr for every short CLI run
syslog / journald Host-wide operational history with tags and priority Duplicating every echo when systemd already captures service output

This script prints JSON on stdout and a warning on stderr:

bash
cat <<'SCRIPT' > /tmp/bash-logging-lab/split_streams.sh
#!/usr/bin/env bash
printf '{"count":3}\n'
printf 'WARN: slow query\n' >&2
SCRIPT

Send each stream to its own file:

bash
bash /tmp/bash-logging-lab/split_streams.sh 2>/tmp/bash-logging-lab/err.log >/tmp/bash-logging-lab/out.json

Stdout stays machine-readable:

bash
cat /tmp/bash-logging-lab/out.json

Sample output:

output
{"count":3}

The warning is in err.log, not in the JSON file. See Bash redirection for file descriptors 1 and 2, and Bash error handling for keeping diagnostics off stdout.


Build a Bash log() Function with Timestamps and Levels

Start with one function that prints a timestamp, level, and message to stderr. Bash has no built-in levels — you define them.

bash
log() {
  local level="$1"
  shift
  printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >&2
}

Call it from a script:

bash
log INFO "backup started"

Sample output:

output
[2026-09-11T10:49:31Z] INFO: backup started

Common level names mirror other ecosystems without pretending Bash enforces them:

  • DEBUG — verbose detail for troubleshooting
  • INFO — normal progress
  • WARN — something unexpected that did not stop the run
  • ERROR — failure the script or caller must react to

Use UTC (date -u) with an ISO-like YYYY-MM-DDTHH:MM:SSZ timestamp so text log files are easy to sort and logs from hosts in different time zones are easier to correlate. Pick one timezone policy per script and document it for operators in other regions.

Extend behavior in one place — file append, color, or logger — instead of scattering echo calls. The Bash function guide covers return values when log should not mask a failing command's exit status.

Keep each log record in a single printf rather than building it with several separate writes. This reduces the chance of fragmented or interleaved messages, but it does not guarantee atomic writes when multiple processes share a sink.


Filter Logs with LOG_LEVEL

Add a threshold with LOG_LEVEL so production runs stay quiet while lab runs stay verbose.

bash
LOG_LEVEL="${LOG_LEVEL:-INFO}"
case "$LOG_LEVEL" in
  DEBUG|INFO|WARN|ERROR) ;;
  *)
    printf 'ERROR: invalid LOG_LEVEL: %s\n' "$LOG_LEVEL" >&2
    exit 2
    ;;
esac
should_log() {
  case "$LOG_LEVEL" in
    DEBUG) return 0 ;;
    INFO) [[ "$1" != DEBUG ]] ;;
    WARN) [[ "$1" == WARN || "$1" == ERROR ]] ;;
    ERROR) [[ "$1" == ERROR ]] ;;
  esac
}
log() {
  local level="$1"
  shift
  should_log "$level" || return 0
  printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >&2
}

Save the helper and threshold logic to a file:

bash
cat <<'SCRIPT' > /tmp/bash-logging-lab/log_level.sh
#!/usr/bin/env bash
LOG_LEVEL="${LOG_LEVEL:-INFO}"
case "$LOG_LEVEL" in
  DEBUG|INFO|WARN|ERROR) ;;
  *)
    printf 'ERROR: invalid LOG_LEVEL: %s\n' "$LOG_LEVEL" >&2
    exit 2
    ;;
esac
should_log() {
  case "$LOG_LEVEL" in
    DEBUG) return 0 ;;
    INFO) [[ "$1" != DEBUG ]] ;;
    WARN) [[ "$1" == WARN || "$1" == ERROR ]] ;;
    ERROR) [[ "$1" == ERROR ]] ;;
  esac
}
log() {
  local level="$1"
  shift
  should_log "$level" || return 0
  printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >&2
}
log DEBUG "verbose detail"
log INFO "visible line"
SCRIPT

With the default INFO, DEBUG is suppressed:

bash
LOG_LEVEL=INFO bash /tmp/bash-logging-lab/log_level.sh 2>&1

Sample output:

output
[2026-09-11T10:49:31Z] INFO: visible line

Lower the threshold when you need detail:

bash
LOG_LEVEL=DEBUG bash /tmp/bash-logging-lab/log_level.sh 2>&1

Sample output:

output
[2026-09-11T10:49:31Z] DEBUG: verbose detail
[2026-09-11T10:49:31Z] INFO: visible line

A --debug flag can set LOG_LEVEL=DEBUG before the main loop runs.


Log to a File

Cron and unattended jobs often append diagnostics to a file under /var/log or the script's state directory.

bash
LOG_FILE="/tmp/bash-logging-lab/cron.log"
log() {
  local level="$1"
  shift
  printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >>"$LOG_FILE"
}

Truncate the file for a reproducible demo, then write the first entry:

bash
LOG_FILE="/tmp/bash-logging-lab/cron.log"
: >"$LOG_FILE"
log INFO "nightly job started"

Read it back:

bash
cat /tmp/bash-logging-lab/cron.log

Sample output:

output
[2026-09-11T10:50:00Z] INFO: nightly job started

Use >> to append across runs. Ensure the parent directory exists and the cron user can write to the path before the first night run — a missing directory should fail loudly in your wrapper, not silently drop lines.

You can also append a command's output directly — you do not need log() for every external program:

bash
LOG_FILE="/tmp/bash-logging-lab/cmd.log"
: >"$LOG_FILE"

Truncate the file first so rerunning this demo gives the same four-line sample output. Production jobs skip that step and use >> when they want history across runs.

Append stdout only:

bash
echo "stdout line" >>"$LOG_FILE"

Append stderr only:

bash
{ printf 'stderr line\n' >&2; } 2>>"$LOG_FILE"

Append both stdout and stderr to the same file:

bash
{ echo "stdout line"; echo "stderr line" >&2; } >>"$LOG_FILE" 2>&1

Read the combined file:

bash
cat /tmp/bash-logging-lab/cmd.log

Sample output (three appends in order):

output
stdout line
stderr line
stdout line
stderr line

Use > when you want to overwrite the file at the start of a run; use >> when cron or a loop should keep history across invocations.

Open the file once with exec >>"$LOG_FILE" only when the whole script should share one combined stream — see the next sections for tradeoffs.


Log to Terminal and File with tee

When an operator watches the run and you still want a file, duplicate stderr (or a pipe) with tee:

bash
log() {
  local level="$1"
  shift
  printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" \
    | tee -a /tmp/bash-logging-lab/app.log >&2
}

tee writes the same line to the file and passes it through to stderr. Start with an empty file so a rerun gives the same byte count:

bash
: > /tmp/bash-logging-lab/app.log

Append one line with tee -a:

bash
printf 'result line\n' | tee -a /tmp/bash-logging-lab/app.log

Confirm the file received the bytes:

bash
wc -c /tmp/bash-logging-lab/app.log

Sample output:

output
12 /tmp/bash-logging-lab/app.log

To log an entire command to a terminal and a file, merge stderr into stdout before tee:

bash
{ echo "stdout line"; echo "stderr line" >&2; } 2>&1 | tee -a /tmp/bash-logging-lab/app.log

Both lines land in the file and on the terminal, but stdout and stderr are no longer separate streams after the merge — plan accordingly if downstream tools need stderr for diagnostics only.

In a pipeline, tee is a stage like any other. Enable pipefail when a failure in tee or an upstream command should fail the pipeline — see Bash pipes for pipefail and PIPESTATUS.


Redirect an Entire Script with exec — Pros and Cons

exec can send every subsequent stdout and stderr write to one file. Run this redirection inside a script, not directly in an interactive shell — there it would redirect that shell's stdout and stderr until you restore the descriptors or exit. When stdout must remain available for structured results, the Bash file-descriptor guide shows how to open a separate log FD with exec, write through it, and close it safely.

Write a small script that applies exec and then prints both streams to the same file:

bash
cat <<'SCRIPT' > /tmp/bash-logging-lab/exec-log.sh
#!/usr/bin/env bash
exec >>/tmp/bash-logging-lab/cron.log 2>&1
echo "diagnostic on combined stream"
printf '{"status":"ok"}\n'
SCRIPT

Run it in a subshell process so your current terminal stays unchanged:

bash
bash /tmp/bash-logging-lab/exec-log.sh

Read the last two lines the script appended:

bash
tail -2 /tmp/bash-logging-lab/cron.log

Sample output:

output
diagnostic on combined stream
{"status":"ok"}

After exec, both echo and JSON payloads share the same stream. That is fine for a cron wrapper where nobody pipes stdout. It breaks composability: callers can no longer treat stdout as a pure data API. Prefer explicit log() to stderr and intentional stdout for results when the script must work in pipelines.


Send Logs to syslog/journald with logger

logger writes one line to the system log using the host's configured logging path. On many systemd systems, messages sent through the local logging socket are collected by journald. util-linux also supports logger --journald for direct journal entries with structured fields.

bash
logger -t backup-demo -p user.notice "cron backup finished"

Read the most recent line with that tag:

bash
journalctl -t backup-demo -n 1 --no-pager

Sample output:

output
Sep 11 16:19:31 vm1.lab.example backup-demo[14408]: cron backup finished

If the entry is not visible as your user, journal access may be restricted by the host's permissions.

Use -t for a stable tag (journalctl -t), and -p for priority (user.notice, user.err, etc.). user.notice is already logger's default priority; the example keeps -p explicit for clarity. One logger call per event — do not fork logger inside a tight loop if volume matters.


Logging Under systemd

By default, systemd services send stdout to the journal, while stderr inherits stdout's destination. Unless the unit overrides StandardOutput= or StandardError=, both streams are therefore available through journalctl -u your-service.

You usually do not need a separate log file inside the script unless compliance or offline analysis requires it. Let the unit file own restart policy; let journald own retention. When you need field filters and follow mode, use view logs with journalctl.

Override StandardOutput or StandardError only when you deliberately want a file path or null sink. If the script also writes its own file, you may duplicate every line. Pick one primary sink for services: journal via captured stdout/stderr, or logger, or an explicit file — not all three by default.


Rotate Logs Instead of Growing Forever

Do not invent delete-old-logs logic in Bash. Use host tools:

  • logrotate for files under /var/log — size or time triggers, compression, and retention policies
  • journald retention settings for journal storage — SystemMaxUse, MaxRetentionSec, and related options in journald.conf

Your script appends lines; the platform rotates or vacuums them. A typical logrotate stanza names the file, rotates weekly or by size, compresses older files, and keeps a fixed number of generations — you do not reimplement that loop in Bash.

For short-running cron scripts that reopen the file on each invocation, normal rotation is straightforward. A long-running process that keeps the log file open with exec >>"$LOG_FILE" may continue writing to the renamed file after rotation. Such programs need a way to reopen their log file, or a logrotate strategy designed for an always-open file. copytruncate is available for programs that cannot reopen logs, but it has a small window where log data can be lost.

Document the log path in the cron entry or unit file so operators know which file logrotate manages and which unit tag to pass to journalctl.


Never Leak Secrets

Tracing and logging can expose passwords, tokens, and API keys.

  • Do not log environment variables that hold secrets.
  • Avoid set -x / bash -x in production when commands include credentials — xtrace prints expanded commands to stderr.
  • Prefer not logging secrets at all. Log the operation, field name, request ID, or account identifier without the credential value:
bash
TOKEN='sekret123'

# Do this
log INFO "authentication request started"

# Not this
log INFO "Authorization: Bearer $TOKEN"

A safe log line might read auth header: Bearer <redacted> — but that is the goal, not something to derive with a home-grown Bash sanitizer. Secrets can also appear in command-line arguments, environment variables, or set -x output before your logging helper runs. If an application genuinely requires automatic redaction, do not assume Bash parameter substitution is a general-purpose sanitizer.


Practical Production Logging Example

This backup-style script keeps archive names on stdout, duplicates log() lines to a file and stderr, and respects LOG_LEVEL. It deliberately mirrors operational messages to both stderr and a file — a reasonable choice for cron or an operator watching a terminal. In a systemd service, you would normally write them to stderr and let journald retain them instead of also setting LOG_FILE.

bash
cat <<'SCRIPT' > /tmp/bash-logging-lab/backup.sh
#!/usr/bin/env bash
set -euo pipefail
LOG_LEVEL="${LOG_LEVEL:-INFO}"
LOG_FILE="${LOG_FILE:-/tmp/bash-logging-lab/backup.log}"
case "$LOG_LEVEL" in
  DEBUG|INFO|WARN|ERROR) ;;
  *)
    printf 'ERROR: invalid LOG_LEVEL: %s\n' "$LOG_LEVEL" >&2
    exit 2
    ;;
esac
log() {
  local level="$1"
  shift
  case "$LOG_LEVEL" in
    DEBUG) ;;
    INFO) [[ "$level" == DEBUG ]] && return 0 ;;
    WARN) [[ "$level" == DEBUG || "$level" == INFO ]] && return 0 ;;
    ERROR) [[ "$level" != ERROR ]] && return 0 ;;
  esac
  printf '[%s] %s: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" \
    | tee -a "$LOG_FILE" >&2
}
log INFO "starting backup"
log DEBUG "listing source"
printf 'archive.tar\n'
log INFO "backup complete"
SCRIPT

Run it and capture stdout separately from the log file:

bash
bash /tmp/bash-logging-lab/backup.sh 2>/dev/null

Sample stdout:

output
archive.tar

The operational lines landed in the log file:

bash
tail -2 /tmp/bash-logging-lab/backup.log

Sample output:

output
[2026-09-11T10:49:36Z] INFO: starting backup
[2026-09-11T10:49:36Z] INFO: backup complete

DEBUG stayed hidden at the default level while the data line remained parseable on stdout. A wrapper script can still run archive=$(bash backup.sh) and receive only archive.tar on stdout while operators inspect backup.log for timing and errors.


Common Logging Anti-Patterns

Anti-pattern Why it hurts Better approach
Status echo on stdout in a CLI that prints data Breaks pipes and $(...) capture Log to stderr or a log() helper
exec >>log 2>&1 on a composable tool Mixes JSON and prose in one stream stderr for diagnostics; stdout for data only
No timestamp or timezone policy Operators cannot correlate events ISO-like UTC stamps in every line
set -x with secrets in commands xtrace writes credentials to stderr Do not log secrets; trace only in isolated debug runs
Custom log deletion in the script Racey and incomplete logrotate or journald retention
logger in a tight loop Spam and overhead Batch or threshold at WARN/ERROR
Duplicate file + journal + syslog for every line Noise and cost One primary operational sink per deployment

Summary

Bash script logging starts with a simple split: stdout carries the result when composability matters, stderr carries diagnostics, and log files or journald carry operational history when someone must review a run later. A small log() function with timestamps, levels, and an optional LOG_LEVEL threshold beats scattered echo calls.

Use tee when operators need a terminal and a file; use exec redirects only when the whole script owns its output and nobody pipes stdout. Send host-wide events with logger and rely on systemd's journal for services before adding another log file. Rotate with logrotate or journald settings, not hand-rolled deletion, and never log secrets — treat set -x as unsafe around credentials.


References


Frequently Asked Questions

Should logs go to stdout or stderr?

Put machine-readable results on stdout when another command or script must pipe or parse them. Put human diagnostics, warnings, and progress on stderr or into an explicit log() function that writes to stderr. Operational history for cron or audits belongs in a file or journal, not mixed into stdout.

How do I log to terminal and file?

Duplicate the stream with tee. For log helpers, pipe the formatted line: printf '...' | tee -a /path/to/log >&2 sends the same text to the file and the terminal's stderr.

Should a systemd script write its own logfile?

Usually no. systemd captures service stdout and stderr to the journal by default. Add a separate file only when regulation or tooling requires a path on disk — avoid duplicating every line in both places without reason.

How do I rotate logs?

Use logrotate for files under /var/log and journald vacuum or retention settings for the journal. Do not delete old lines from inside the Bash script.

Is set -x safe in logs?

No when commands contain secrets. xtrace prints expanded commands to stderr before they run, which can expose passwords and tokens. Use it only in isolated debug sessions.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)