Home / Alt manpages / bash(1)

  • bash(1)
  • User command
  • linux

Write a Bash Script That Fails Loudly, Not Silently

A script that swallows its own errors is the one that quietly corrupts data at 3am while the cron log says nothing went wrong. This guide builds a small Bash script that checks its inputs, propagates failures, can be traced when it misbehaves, and tells you where Linux is configured to write core dumps. The examples match Bash 5.2, the version installed on the machine used for this guide.

Allow about fifteen minutes. You need Bash and a terminal. The workflow is read-only apart from the script file you create in a test directory, and it does not need elevated privileges. Do not run a script copied from an untrusted source with sudo; inspect it first.

1. Confirm the shell you are using

Bash has several modes that look similar from the prompt but read different startup files and handle input differently. A script should normally invoke Bash explicitly rather than trust whichever shell happens to launch it.

$ bash --version | head -1
GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
$ bash -c 'printf "interactive=%s\n" "$-"'
interactive=hBc

Your version string will vary. The -c command runs non-interactively, so it does not read your normal interactive configuration the way a terminal shell does.

  • Interactive Bash reads /etc/bash.bashrc and ~/.bashrc when those files exist.
  • A login shell instead reads /etc/profile, then the first readable file from ~/.bash_profile, ~/.bash_login and ~/.profile.

That split is a common trap: an alias or exported variable that works at your prompt may not exist in a scheduled script. A non-interactive Bash can also source the file named by BASH_ENV. Treat that variable as part of the execution environment, not a convenient hidden configuration channel.

2. Make failures visible

Create a test script in a directory you control. The shebang selects Bash, -u rejects unset variables, and pipefail makes a pipeline fail when a command other than its last member fails. -e exits after an unhandled failure, but it has documented exceptions, including commands used as the test in if, while and until. It is a guardrail, not a substitute for checking important results.

#!/usr/bin/env bash
set -Eeuo pipefail

trap 'rc=$?; printf "failed: line %s, status %s\n" "$LINENO" "$rc" >&2' ERR
trap 'rc=$?; printf "finished with status %s\n" "$rc" >&2' EXIT

usage() {
    printf 'usage: %s FILE\n' "$0" >&2
}

if (( $# != 1 )); then
    usage
    exit 2
fi

input=$1
if [[ ! -r $input ]]; then
    printf 'cannot read: %s\n' "$input" >&2
    exit 1
fi

printf 'first line: '
IFS= read -r first_line < "$input" || true
printf '%s\n' "$first_line"

Save it as check-file, then make it executable if you want to invoke it directly; this changes only the mode of that one file, so undo it with chmod u-x check-file. Or run it as bash ./check-file FILE and skip the mode change entirely.

$ bash -n ./check-file
$ bash ./check-file /etc/hostname
first line: <the-hostname-on-this-machine>
finished with status 0

bash -n parses the script without executing commands. read returns a failure at end-of-file if a final line has no newline, so the example deliberately handles that expected condition with || true. Without that exception, strict mode would turn an ordinary short file into a failed run.

3. Verify exit status instead of output

Every simple Bash command has an exit status. Zero conventionally means success, and a script invoked from a file exits with the status of its last command unless it exits earlier. Use status values for automation; printed text is for people and may change.

$ bash ./check-file /path/that/does/not/exist
cannot read: /path/that/does/not/exist
failed: line 18, status 1
finished with status 1
$ printf 'status=%s\n' "$?"
status=1

The ERR trap prints a diagnostic, the EXIT trap reports the final status. The line number is useful but not a full stack trace, and traps and set -e interact with functions, conditional commands and subshells in ways worth reading up on. If a failure is expected, write that expectation explicitly:

if grep -q '^enabled=yes$' "$input"; then
    printf 'feature enabled\n'
else
    printf 'feature not enabled\n'
fi

Tip

Do not write grep ... || true just to silence a failure unless every non-zero result really is safe to ignore. In a pipeline, add pipefail as above and test the whole operation. For a command whose non-zero result is meaningful, capture it in an if or assign and test it deliberately.

4. Trace one run, carefully

Use -x to make Bash print commands as it executes them. This is often the fastest way to find a wrong path, expansion or branch:

$ bash -x ./check-file /etc/hostname
+ input=/etc/hostname
+ [[ ! -r /etc/hostname ]]
first line: <the-hostname-on-this-machine>
finished with status 0

The exact trace includes a prompt prefix and varies with the script and hostname.

Warning

Tracing can expose passwords, tokens, private paths and command arguments in the terminal or CI log. Do not enable it around secrets, and do not paste a trace into a public issue without reviewing it. Once you have found the cause, remove the debug invocation rather than leaving a permanent set -x in a production script.

5. Check whether Linux can write a core dump

A core dump is a memory image produced when a process terminates because of a signal whose default action is to dump core. It is useful with a debugger, but it can contain credentials and other sensitive process memory. Bash is usually just the orchestrator here; a native child process is the more likely crash source.

First inspect the current shell's soft limit and the kernel naming policy. These commands only read state:

$ ulimit -c
0
$ cat /proc/sys/kernel/core_pattern
|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -F%F -- %E
  • 0 means this shell and its children have a zero soft RLIMIT_CORE, so an ordinary file core dump is capped at zero bytes.
  • unlimited removes the per-process size cap, but does not guarantee a file: the destination must be writable, the filesystem needs space, and the kernel or service manager may route dumps elsewhere.
  • The pattern above is this machine's apport configuration; yours may say core, name a different handler, or be empty. On systemd systems, the core manpage says systemd may determine the destination instead.

For a temporary diagnostic run, raise the limit in a child shell without touching the login configuration:

$ bash -c 'ulimit -c unlimited; ulimit -c; exec ./program-under-test --safe-input'
unlimited

This requires that the caller is permitted to raise its soft limit up to the hard limit, and it does not alter the parent shell. Warning: treat any resulting dump as sensitive: restrict access, collect only what you need, and remove it through your normal approved retention process after analysis. Do not change /proc/sys/kernel/core_pattern or service-wide limits casually; those are system-level changes that can affect every process.

Done means

  • The script starts with Bash and passes bash -n.
  • Missing input produces a non-zero status and a useful diagnostic.
  • Expected non-zero commands are tested explicitly rather than hidden with a blanket true.
  • You can trace one run with bash -x without leaking its arguments or secrets.
  • You checked both ulimit -c and /proc/sys/kernel/core_pattern before expecting a core file.