00
Introduction to GNU Bash
What Bash is, what a shell is, and a first log-summary script
- What is a shell vs Bash
- Read a small script: variables, quoting, if, for
- GNU Bash Reference Manual
Must-know cold
- Bash is a shell: command interpreter and programming language (GNU’s default).
- Interactive = keyboard. Non-interactive = commands from a file (a script).
- A builtin is implemented inside the shell (
cd,printf,exit).grepis not. $nameis a parameter. Quote it:"$dir". Unquoted words split on blanks.man bashlocally.
What is Bash?
Bash is the shell, or command language interpreter, for the GNU operating system. The name is an acronym for the ‘Bourne-Again SHell’, a pun on Stephen Bourne, the author of the direct ancestor of the current Unix shell sh, which appeared in the Seventh Edition Bell Labs Research version of Unix.
Bash is largely compatible with sh and incorporates useful features from the Korn shell ksh and the C shell csh. It is intended to be a conformant implementation of the IEEE POSIX Shell and Tools portion of the IEEE POSIX specification (IEEE Standard 1003.1). It offers functional improvements over sh for both interactive and programming use.
While the GNU operating system provides other shells, including a version of csh, Bash is the default shell. Like other GNU software, Bash is quite portable. It currently runs on nearly every version of Unix and a few other operating systems — independently-supported ports exist for Windows and other platforms.
What is a shell?
At its base, a shell is simply a macro processor that executes commands. The term macro processor means functionality where text and symbols are expanded to create larger expressions.
A Unix shell is both a command interpreter and a programming language. As a command interpreter, the shell provides the user interface to the rich set of GNU utilities. The programming language features allow these utilities to be combined. Users can create files containing commands, and these become commands themselves. These new commands have the same status as system commands in directories such as /bin, allowing users or groups to establish custom environments to automate their common tasks.
Shells may be used interactively or non-interactively. In interactive mode, they accept input typed from the keyboard. When executing non-interactively, shells execute commands read from a file or a string.
A shell allows execution of GNU commands, both synchronously and asynchronously. The shell waits for synchronous commands to complete before accepting more input; asynchronous commands continue to execute in parallel with the shell while it reads and executes additional commands. The redirection constructs permit fine-grained control of the input and output of those commands. Moreover, the shell allows control over the contents of commands’ environments.
Shells also provide a small set of built-in commands (builtins) implementing functionality impossible or inconvenient to obtain via separate utilities. For example, cd, break, continue, and exec cannot be implemented outside of the shell because they directly manipulate the shell itself. The history, getopts, kill, or pwd builtins, among others, could be implemented in separate utilities, but they are more convenient to use as builtin commands. All of the shell builtins are described in subsequent sections.
While executing commands is essential, most of the power (and complexity) of shells is due to their embedded programming languages. Like any high-level language, the shell provides variables, flow control constructs, quoting, and functions.
Shells offer features geared specifically for interactive use rather than to augment the programming language. These interactive features include job control, command line editing, command history and aliases. This manual describes how Bash provides all of these features.
Definitions
These definitions are used throughout the remainder of this manual.
| Term | Meaning |
|---|---|
POSIX | A family of open system standards based on Unix. Bash is primarily concerned with the Shell and Utilities portion of the POSIX 1003.1 standard. |
blank | A space or tab character. |
whitespace | A character belonging to the space character class in the current locale, or for which isspace() returns true. |
builtin | A command that is implemented internally by the shell itself, rather than by an executable program somewhere in the file system. |
control operator | A token that performs a control function. It is a newline or one of: ||, &&, &, ;, ;;, ;&, ;;&, |, |&, (, ). |
exit status | The value returned by a command to its caller. Restricted to eight bits, so the maximum value is 255. |
field | A unit of text that is the result of one of the shell expansions. After expansion, fields become the command name and arguments. |
filename | A string of characters used to identify a file. |
job | A set of processes comprising a pipeline, and any processes descended from it, that are all in the same process group. |
job control | A mechanism by which users can selectively stop (suspend) and restart (resume) execution of processes. |
metacharacter | A character that, when unquoted, separates words. A space, tab, newline, or one of: |, &, ;, (, ), <, >. |
name | A word of letters, numbers, and underscores, beginning with a letter or underscore. Used as variable and function names. Also called an identifier. |
operator | A control operator or a redirection operator. Operators contain at least one unquoted metacharacter. |
process group | A collection of related processes each having the same process group ID. |
process group ID | A unique identifier that represents a process group during its lifetime. |
reserved word | A word with special meaning to the shell. Most introduce flow-control constructs, such as for and while. |
return status | A synonym for exit status. |
signal | A mechanism by which a process may be notified by the kernel of an event occurring in the system. |
special builtin | A shell builtin classified as special by the POSIX standard. |
token | A sequence of characters considered a single unit by the shell. Either a word or an operator. |
word | A sequence of characters treated as a unit by the shell. Words may not include unquoted metacharacters. |
A first script
Task: you have a directory of *.log files. You want a one-page count of ERROR and WARN lines, skip unreadable files, write a report, and fail the script if any errors exist (handy in CI). No banners. No color.
Save as log-summary.sh, then:
chmod +x log-summary.sh
./log-summary.sh /var/log ./report.txt
log-summary.sh
#!/usr/bin/env bash
# log-summary.sh — count ERROR and WARN lines in *.log under a directory.
# Usage: ./log-summary.sh [directory] [outfile]
usage() {
printf 'usage: %s [directory] [outfile]\n' "$0"
}
if [[ ${1:-} == -h || ${1:-} == --help ]]; then
usage
exit 0
fi
if ! command -v grep >/dev/null 2>&1; then
printf 'grep not found on PATH\n' >&2
exit 127
fi
dir=${1:-.}
out=${2:-log-summary.txt}
if [[ ! -d $dir ]]; then
printf 'not a directory: %s\n' "$dir" >&2
exit 1
fi
# Relative outfile is vs the directory we started in, not vs $dir after cd.
if [[ $out != /* ]]; then
out="$(pwd)/$out"
fi
cd -- "$dir" || exit 1
here=$(pwd)
total_err=0
total_warn=0
file_count=0
: > "$out"
{
printf 'dir=%s\n' "$here"
printf '\n'
} >> "$out"
for f in *.log; do
if [[ $f == '*.log' && ! -e $f ]]; then
printf 'no *.log files in %s\n' "$here"
exit 0
fi
if [[ ! -r $f ]]; then
printf 'skip unreadable: %s\n' "$f" >&2
continue
fi
file_count=$((file_count + 1))
err=$(grep -c 'ERROR' -- "$f" || true)
warn=$(grep -c 'WARN' -- "$f" || true)
total_err=$((total_err + err))
total_warn=$((total_warn + warn))
printf '%s error=%s warn=%s\n' "$f" "$err" "$warn" >> "$out"
done
{
printf '\nfiles=%s\n' "$file_count"
printf 'total_error=%s total_warn=%s\n' "$total_err" "$total_warn"
} >> "$out"
if [[ $total_err -gt 0 ]]; then
printf 'report written: %s (errors present)\n' "$out"
exit 2
fi
printf 'report written: %s\n' "$out"
exit 0
Syntax and mechanics
This is a non-interactive shell: Bash reads the file top to bottom, not the keyboard. For each command it roughly: splits into words and operators using quoting → parses a simple or compound command → expands → applies redirections → executes → keeps an exit status. Interactive extras (job control, history, aliases, line editing) are off the table here.
Worked invocation used below: you are in /home/you/work, the script is executable, and you run:
./log-summary.sh /var/log ./report.txt
Then: $0 is ./log-summary.sh, $1 is /var/log, $2 is ./report.txt. $# is 2 (not used in the file, but that is the count of positional parameters).
How one line is actually run
Take cd -- "$dir" || exit 1.
- Quote:
"$dir"is one word. After parameter expansion it is still one word even if$dirhas spaces. - Simple command: words
cd,--,/var/log.cdis a builtin (it must change this process’s cwd; an external/usr/bin/cdcould not). --: end of options. A directory named-Pis notcd -P.- List:
||is a control operator.exit 1runs only ifcd’s status is non-zero. - Synchronous: the shell waits for
cdbefore the next line. No&, so not a job in the background.
Same seven-step engine on every later line. The rest of this section is that engine applied to the file, in order.
Shebang and comments
#!/usr/bin/env bash
# log-summary.sh — count ERROR and WARN lines in *.log under a directory.
#! is not Bash. The kernel reads the first line and execs that interpreter with the script path as an argument. env walks PATH and finds bash (so you are not hard-coding /bin/bash). After chmod +x, the file is a command.
# starts a comment: that word and the rest of the line are discarded. True for every non-interactive shell.
Function, $0, printf
usage() {
printf 'usage: %s [directory] [outfile]\n' "$0"
}
- A name (
usage) bound to a compound command. Defining it does not run it. { list; }is brace grouping: current shell, not a subshell. (( list )would fork; assignments inside would vanish.)printfis a builtin. The format is in single quotes: every character is literal, so%sand\nare forprintf, not for Bash.\nis a newline in the format, not a Bash escape."$0"is a special parameter: the invocation name (./log-summary.shhere, or a full path if you used one). Double quotes keep it one word.- Calling
usagelater is a simple command whose first word is a function name, not a file onPATH.
Parameter expansion
if / [[ / ||
if [[ ${1:-} == -h || ${1:-} == --help ]]; then
usage
exit 0
fi
ifthenfiare reserved words. The exit status of the test list decides the branch. Zero = true.[[ ... ]]is a Bash conditional construct (reserved words), not POSIX[and not an externaltest. No word-splitting and no globbing of the words between[[and]].${1:-}is parameter expansion: positional$1, or empty if$1is unset or null. Bare$1 == -hwith no$1can become a syntax error (==with a missing operand). The:-form makes a missing arg safe.==inside[[is pattern match (withextglobrules).-hhas no glob characters, so it is exact.--helplikewise.||here is inside[[: it is the conditional or, with short-circuit. It is the same idea as the list operator||but it is not spawning two pipelines; it is one[[expression.!is not on this line. Later,[[ ! -d $dir ]]—!negates the primary.
exit 0 is a builtin. Status 0 = success. The caller sees it in $? (eight bits, 0–255).
command -v, redirections, !, 127
if ! command -v grep >/dev/null 2>&1; then
printf 'grep not found on PATH\n' >&2
exit 127
fi
commandis a builtin. It skips functions/aliases and tells you how the shell would rungrep.-vprints the path (or nothing + non-zero if missing).!as a reserved word in front of a pipeline negates the status. Missing grep →command -vfails →!makes theiftrue.>/dev/nullredirects stdout (fd 1) to the bit bucket.2>&1then copies stderr (fd 2) onto wherever fd 1 now points. Order matters:2>&1 >/dev/nullwould first copy stderr to the terminal, then send only stdout to/dev/null.printf ... >&2writes the error on fd 2 so it does not land in the report file.127is the conventional “command not found” status (same family as a missing binary).
grep is not a builtin. It is found by command search: function → builtin → PATH (unless command bypasses the first).
Assignment vs simple command
dir=${1:-.}
out=${2:-log-summary.txt}
This is not dir with an argument. A parameter assignment is name=value with no spaces around =. name = letters, digits, underscore; cannot start with a digit.
${1:-.} means: if $1 is unset or empty, use . (current directory). With the worked invocation, $1 is /var/log, so dir becomes /var/log. $2 is ./report.txt, so out is that.
Unquoted $dir later would split on blanks (space/tab/newline, plus IFS). "$dir" does not split.
File tests
if [[ ! -d $dir ]]; then
-d is a conditional primary: true if the word names a directory. -r (later) is readable; -e is exists. Inside [[, $dir is not split, so a directory with a space still works. Outside [[, always quote.
printf 'not a directory: %s\n' "$dir" >&2 — %s consumes the next argument as a string; a directory named -v will not be taken as a printf flag.
Relative paths and cd
if [[ $out != /* ]]; then
out="$(pwd)/$out"
fi
cd -- "$dir" || exit 1
here=$(pwd)
!=inside[[is also pattern match./*as a pattern means “starts with/.” A relative./report.txtdoes not match, so we prefix."$(pwd)/$out": command substitution$(pwd)plus/plus$out, all in double quotes → one word.$(...)runspwdin a subshell, strips trailing newlines, inserts the text.- Why before
cd: aftercd /var/log, a relativeoutwould mean/var/log/report.txt. The script writes the report in the directory you started in unless you pass an absolute path. cdchanges the script’s cwd. The parent shell that launched./log-summary.shis unchanged (the script is another process).here=$(pwd)is the directory we will glob. Command substitution captures stdout only.pwd’s stderr, if any, still goes to the terminal.
$(pwd) vs $PWD: PWD is a shell variable the shell updates on cd. pwd is the builtin. Either is fine here.
: > truncate, { } group, >> append
total_err=0
total_warn=0
file_count=0
: > "$out"
{
printf 'dir=%s\n' "$here"
printf '\n'
} >> "$out"
0is a word assigned to a parameter. Later arithmetic treats it as an integer.:is a special builtin: no-op, status 0.: > "$out"opens$outfor writing and truncates it (creates if missing). Same idea as> "$out"on a no-op.>vs>>: truncate vs append. Both are redirection operators, applied before the command runs. They are not arguments toprintf.{ printf ...; printf ...; } >> "$out": one redirect on the compound command. Both prints go to the file. The semicolon (or newline) before}is required;{}are reserved words and need blanks.
Fd map in this script: 0 stdin (unused), 1 stdout (human one-liners), 2 stderr (skips / errors), $out (the report).
for, glob, empty match, continue
for f in *.log; do
if [[ $f == '*.log' && ! -e $f ]]; then
printf 'no *.log files in %s\n' "$here"
exit 0
fi
if [[ ! -r $f ]]; then
printf 'skip unreadable: %s\n' "$f" >&2
continue
fi
forindodoneare reserved words. Bash expands*.log(filename expansion) once, then bindsfto each resulting word in turn.- Globbing happens in
$here, becausecdalready ran.*.logis a pattern, not a regex:*any string,?one character,[...]a set. - Empty directory: by default Bash does not expand to nothing. It leaves the literal word
*.log. The test== '*.log'(quoted pattern = literal) and! -e(no such file) is the empty-dir trap. Thenexit 0: no logs is success, not a crash. - If there is a file actually named
*.log,-eis true and we process it. Rare, but that is why both tests are there. &&inside[[is conditional and, short-circuit: if$fis not the literal glob,-eis not needed.-r: unreadable → message on stderr (so it does not pollute the report) →continue(builtin) starts the nextforiteration.breakwould leave the loop; we still want the other files.
f is a name in the current shell. No local (that exists only in functions). After done, $f is still the last filename.
Arithmetic, grep, $( ), || true
file_count=$((file_count + 1))
err=$(grep -c 'ERROR' -- "$f" || true)
warn=$(grep -c 'WARN' -- "$f" || true)
total_err=$((total_err + err))
total_warn=$((total_warn + warn))
printf '%s error=%s warn=%s\n' "$f" "$err" "$warn" >> "$out"
$(( ))is arithmetic expansion.file_count + 1is evaluated; the result is a word (e.g.3). C-style++is available in Bash arithmetic; this file uses the obvious form.grepis external.-ccounts matching lines.'ERROR'is single-quoted so the shell does not touch it.--means “options are over”: a log named-cis a file, not a flag.$(grep ...): command substitution. The shell forks, runs the list, takes stdout, strips trailing newlines, inserts it.grep -cwith no matches prints0and exits 1. That 1 is a status, not the text.|| true: list operator. Ifgrepis 1 (no matches) or otherwise non-zero,true(builtin, always 0) runs. The substitution still captured0. Without this,set -e(not on in this file, but common) would abort the script on “zero matches.”- A pipeline is
cmd | cmd. This line is a list, not a pipe:grep ... || true. Status of the list is the last command that actually ran. "$f" "$err" "$warn": three separate fields after expansion.printfconsumes them in order for three%s.
Do not write err=$(grep -c ERROR $f). Unquoted $f splits; ERROR unquoted is still fine here but quoting the pattern is the habit.
Closing if, statuses
if [[ $total_err -gt 0 ]]; then
printf 'report written: %s (errors present)\n' "$out"
exit 2
fi
printf 'report written: %s\n' "$out"
exit 0
-gtis integer compare inside[[(also-eq -ne -lt -le -ge). This is not string>(locale sort).- Human messages stay on stdout. The report is already in
$out. - Status contract:
| Code | Meaning |
|---|---|
| 0 | ran; no ERROR lines (including “no *.log files”) |
| 1 | bad directory / cd failed |
| 2 | report written, but ERROR count > 0 (CI can fail the job) |
| 127 | grep not on PATH |
The parent sees only that number (echo $?). Values wrap at 256.
Expansion order (only what this file uses)
GNU Bash runs expansions in a fixed order. This script hits:
- Parameter expansion —
$0$1$2$dir$out$f${1:-.}… - Command substitution —
$(pwd)$(grep …) - Arithmetic expansion —
$((file_count + 1)) - Word splitting — on unquoted expansions (we avoided this by quoting)
- Filename expansion —
*.logaftercd - Quote removal — the quotes themselves are not passed to
cd/printf/grep
Not used here: brace expansion {a,b}, tilde ~ (we used . and pwd), process substitution <( ).
Quoting cheat-sheet from the file
| Syntax | What happens to $dir / * / spaces |
|---|---|
"$dir" | Expand; do not split; do not glob |
'$dir' | Literal dollar-d-i-r |
$dir | Expand, then split on IFS, then glob |
'*.log' inside [[ == ]] | Literal asterisk-dot-log |
*.log after in | Glob in the current directory |
'ERROR' | Literal pattern for grep |
Surface map
| Kind | In the script |
|---|---|
| Reserved words | if then fi for in do done [[ ]] ! { } |
| Builtins | command cd pwd printf exit : continue true |
| Special / positional | $0 $1 $2 |
| Names we set | dir out f here err warn total_err total_warn file_count |
| Control operators | || ; newline |
| Redirection | > >> >&2 >/dev/null 2>&1 |
| Expansion | ${p:-word} $( ) $(( )) *.log |
[[ primaries | -d -r -e -gt == != ! && || |
| External | grep (chmod and env only when you install/launch the file) |
Not in this script (on purpose): set -euo pipefail, arrays, local, getopts, case, while/until, select, coprocesses, process substitution, job control, aliases, history. Those are in the GNU manual.
What each builtin is doing to the shell itself
| Builtin | Why it cannot just be /usr/bin/... here |
|---|---|
cd | Must change this process’s cwd |
exit | Must end this shell with a status |
continue | Must skip to the next iteration of this for |
command | Must look at this shell’s command search |
: true | Trivial, but they are in-process no-ops (status 0) |
printf pwd | Could be external; builtin avoids a fork and sees the same cwd |
What the script is doing
It is a non-interactive Bash program. It takes an optional directory (default: current) and optional report path (default: log-summary.txt, resolved against the starting directory before cd). It refuses a missing grep, refuses a non-directory, cds into the target, then for each *.log file it can read, counts lines containing ERROR and WARN, appends one line per file to the report, writes totals, and exits 2 if any errors were counted so a CI job can go red. Unreadable files are skipped on stderr. No *.log files is a clean exit 0, not a crash.
That is the whole job: look at logs, write a count, signal success or “errors found.”
GNU Bash Reference
This page is a doorway, not a replacement.
The sections are verbatim from the GNU Bash Reference Manual, edition 5.3 (18 May 2025), © Free Software Foundation, under the GNU Free Documentation License.
- Check subsequent sections and full manual (HTML): GNU Bash Reference
- Local:
man bash·help cd·help [[·help printf
If the live manual and this page ever disagree, the GNU page wins.
For more details, try man <command> in your terminal.