| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash 5.2.26-6.el10coreutils 9.5-8.el10_2util-linux-core 2.40.2-18.el10systemd 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. |
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:
mkdir -p /tmp/bash-logging-labWhat 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:
cat <<'SCRIPT' > /tmp/bash-logging-lab/split_streams.sh
#!/usr/bin/env bash
printf '{"count":3}\n'
printf 'WARN: slow query\n' >&2
SCRIPTSend each stream to its own file:
bash /tmp/bash-logging-lab/split_streams.sh 2>/tmp/bash-logging-lab/err.log >/tmp/bash-logging-lab/out.jsonStdout stays machine-readable:
cat /tmp/bash-logging-lab/out.jsonSample 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.
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:
log INFO "backup started"Sample output:
[2026-09-11T10:49:31Z] INFO: backup startedCommon level names mirror other ecosystems without pretending Bash enforces them:
DEBUG— verbose detail for troubleshootingINFO— normal progressWARN— something unexpected that did not stop the runERROR— 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.
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:
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"
SCRIPTWith the default INFO, DEBUG is suppressed:
LOG_LEVEL=INFO bash /tmp/bash-logging-lab/log_level.sh 2>&1Sample output:
[2026-09-11T10:49:31Z] INFO: visible lineLower the threshold when you need detail:
LOG_LEVEL=DEBUG bash /tmp/bash-logging-lab/log_level.sh 2>&1Sample output:
[2026-09-11T10:49:31Z] DEBUG: verbose detail
[2026-09-11T10:49:31Z] INFO: visible lineA --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.
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:
LOG_FILE="/tmp/bash-logging-lab/cron.log"
: >"$LOG_FILE"
log INFO "nightly job started"Read it back:
cat /tmp/bash-logging-lab/cron.logSample output:
[2026-09-11T10:50:00Z] INFO: nightly job startedUse >> 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:
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:
echo "stdout line" >>"$LOG_FILE"Append stderr only:
{ printf 'stderr line\n' >&2; } 2>>"$LOG_FILE"Append both stdout and stderr to the same file:
{ echo "stdout line"; echo "stderr line" >&2; } >>"$LOG_FILE" 2>&1Read the combined file:
cat /tmp/bash-logging-lab/cmd.logSample output (three appends in order):
stdout line
stderr line
stdout line
stderr lineUse > 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:
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:
: > /tmp/bash-logging-lab/app.logAppend one line with tee -a:
printf 'result line\n' | tee -a /tmp/bash-logging-lab/app.logConfirm the file received the bytes:
wc -c /tmp/bash-logging-lab/app.logSample output:
12 /tmp/bash-logging-lab/app.logTo log an entire command to a terminal and a file, merge stderr into stdout before tee:
{ echo "stdout line"; echo "stderr line" >&2; } 2>&1 | tee -a /tmp/bash-logging-lab/app.logBoth 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:
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'
SCRIPTRun it in a subshell process so your current terminal stays unchanged:
bash /tmp/bash-logging-lab/exec-log.shRead the last two lines the script appended:
tail -2 /tmp/bash-logging-lab/cron.logSample 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.
logger -t backup-demo -p user.notice "cron backup finished"Read the most recent line with that tag:
journalctl -t backup-demo -n 1 --no-pagerSample output:
Sep 11 16:19:31 vm1.lab.example backup-demo[14408]: cron backup finishedIf 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 injournald.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 -xin 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:
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.
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"
SCRIPTRun it and capture stdout separately from the log file:
bash /tmp/bash-logging-lab/backup.sh 2>/dev/nullSample stdout:
archive.tarThe operational lines landed in the log file:
tail -2 /tmp/bash-logging-lab/backup.logSample output:
[2026-09-11T10:49:36Z] INFO: starting backup
[2026-09-11T10:49:36Z] INFO: backup completeDEBUG 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
- Bash manual — Redirections
- Bash manual — The
execbuiltin - GNU Coreutils —
teeinvocation - systemd.exec(5) —
StandardOutputandStandardError - systemd journald documentation
- journald.conf(5) — journal retention settings
- Linux manual —
logger(1) - Linux manual —
logrotate(8)
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.

