12 Writing scripts#
What this notebook is about#
In Notebook 11 you made a file runnable: a shebang, the executable bit, ./.
But that script did one fixed thing. A real tool is different: you hand it an
input, it names its data in readable variables, organizes its work into functions,
prints clean output, and, when something is wrong, fails early and loudly instead
of limping on with garbage.
This notebook grows exactly that. We start from “a few commands in a file” and, one
idea at a time, build up to analyze.sh: a script that takes a trajectory file
and a flag, extracts and averages its energies, prints a formatted result, and
writes a summary file, all under a header that makes it fail safely. Everything you
have learned in Part III converges here.
One boundary, stated up front. This notebook’s script is linear: one input →
process → output. Making a script decide (with if) and repeat (loop over many
files) is the next notebook. So our capstone processes one trajectory; Notebook
13 will make it sweep a whole directory of them. (As ever, the data is a
playground, no physics required.)
A. Anatomy: a script is commands in a file, with a safe header#
A script is nothing mysterious: it is the same commands you type at the prompt, saved in a file so you can re-run them (Notebook 11). From the very first one, though, we give every script the same two-line header:
#!/usr/bin/env bash
set -euo pipefail
The first line is the shebang from Notebook 11. The second, set -euo pipefail, is
the single most valuable habit in script-writing: it makes the script fail
safely, stopping the moment something goes wrong rather than barreling on. We unpack
exactly what it does in §H; for now, treat it as boilerplate that every script
wears, and we will model it throughout. Here is a first script under that header:
#!/usr/bin/env bash
set -euo pipefail
system="lj38"
echo "analysis script for: $system"
(cd scratch && ./first.sh)
analysis script for: lj38
That is the shape of every script below: the header, then the work.
B. Variables: naming your data#
A variable gives a name to a value, so the script reads like a description of what
it does instead of a wall of literals. Assignment has one famous trap: no spaces
around the =:
system="lj38"; echo "running analysis for $system"
running analysis for lj38
Put a space around the = and the shell reads the first word as a command name,
not an assignment: a confusing error the first time you meet it:
system = "lj38" 2>/dev/null || echo "with spaces, bash thinks 'system' is a command to run — assignment needs NONE around ="
with spaces, bash thinks 'system' is a command to run — assignment needs NONE around =
You use a variable by writing $name, and, per Notebook 10, you quote it on
use: "$name". The everyday forms:
=.
| name=value | assign: no spaces around the = |
| "$name" | use the value: always quote on use (Notebook 10) |
| "${name:-default}" | use default if name is unset or empty (Notebook 10) |
| local name=value | a variable confined to its function (§E) |
| name="$(cmd)" | capture a command's output into the value (Notebook 10) |
(These are script-local variables. Making a variable part of the environment,
so other programs see it, via export, belongs to Notebook 14.)
C. Positional parameters: acting on what you pass#
A tool earns its keep by working on whatever you give it. The arguments after the
script’s name arrive as the positional parameters $1, $2, and so on, with
$# counting them and "$@" standing for all of them:
| $0 | the script's own name |
| $1 $2 … | the first, second, … argument |
| $# | how many arguments were passed |
| "$@" | all arguments, each kept as a separate word: quote it (Notebook 10) |
| $* | all arguments mashed into one word: rarely what you want |
| shift | drop $1; $2 becomes $1, and so on |
Here is a script that simply reports what it received:
#!/usr/bin/env bash
set -euo pipefail
echo "script name: $0"
echo "first arg: $1"
echo "arg count: $#"
echo "all args: $@"
(cd scratch && ./args.sh lj38.xyz pt-slab.xyz)
script name: ./args.sh
first arg: lj38.xyz
arg count: 2
all args: lj38.xyz pt-slab.xyz
The script now does different work depending on its input: the difference between a one-off and a tool.
D. Flags via getopts: options, the standard way#
Arguments are positional; flags (-v, -n 5) let a caller switch behaviour on
and off by name. Bash has a dedicated helper, getopts, and it is almost always
written as the same copy-me skeleton:
verbose=0
n=3
while getopts "vn:" opt; do
case "$opt" in
v) verbose=1 ;; # a plain flag
n) n="$OPTARG" ;; # a flag that takes a value
*) echo "usage: show.sh [-v] [-n N] FILE" >&2; exit 1 ;;
esac
done
shift $(( OPTIND - 1 )) # drop the parsed flags; "$1" is now the first real argument
The option string "vn:" says: accept -v (no value) and -n (the trailing
: means it takes a value). For each flag, getopts puts the letter in opt,
and any value in OPTARG; OPTIND tracks how far it got, so the final
shift discards the flags and leaves your real arguments as $1, $2, …
On the while/case machinery
The while … do … done loop and the case … esac choice are control flow,
the subject of Notebook 13. Here, treat the skeleton above as boilerplate to copy
and fill in; you will understand its mechanics fully next notebook. One curation
note: getopts handles short options only; long ones like --verbose need
more than it offers, and we leave them out of scope.
Here that skeleton is, wired into a small script that shows the first N lines of a
file, with -v relabelling the output:
(cd scratch && ./show.sh -v -n 2 notes.txt)
[verbose] first 2 lines of notes.txt:
line one
line two
Both flags took effect: -v switched the label to verbose, and -n 2 showed two
lines instead of the default three.
E. Functions: naming a piece of work#
When a script repeats a step (or just grows long enough to need sections) you give
that piece of work a name. A function is a mini-script inside your script, with
its own $1, $2, and its own local variables:
| name() { …; } | define a function |
| name arg1 arg2 | call it: inside, $1 $2 are its arguments |
| local v=… | a variable local to the function, not leaking out |
| return N | set the function's exit code (0–255), not a value |
| echo … → "$(name)" | to hand back data, echo it and capture with $( ) |
The one thing that trips newcomers: a function’s return is an exit code, not a
return value. return 0 means “success,” not “the answer is zero”:
is_even() { local n="$1"; return $(( n % 2 )); }
is_even 4; echo "exit code from is_even 4: $? (0 = success = yes, even)"
exit code from is_even 4: 0 (0 = success = yes, even)
So to hand data back from a function, you echo it and capture it with
$(…) at the call site: exactly the command-substitution idea from Notebook 10.
Here is a function that averages a log’s energies (the grep/awk from Notebooks 6
and 8) and returns the number:
energy_mean() {
local file="$1"
grep 'Total FORCE_EVAL' "$file" | grep -oE '\-[0-9]+\.[0-9]+' | awk '{ s += $1; n++ } END { printf "%.4f\n", s/n }'
}
m="$(energy_mean data/logs/gr2hno3-nvt.log)"
echo "captured mean = $m eV"
captured mean = -143.4444 eV
The local file stays inside the function; the result comes back through echo +
$(…). That is the pattern for every “compute something and give it back.”
F. printf: clean, formatted output#
Scripts report results, and for that printf beats echo. It takes a format
string with placeholders, then the values to drop in, so columns line up and
numbers carry the precision you choose.
| %s | insert a string argument as-is (e.g. a name or a path) |
| %d / %.3f | an integer / a float to 3 decimals — %.Nf sets the precision, %-8s / %8.3f set a field width |
| \n / \t | a newline / a tab in the template — printf adds NO trailing newline of its own (unlike echo), so you write \n yourself |
help printf (bash has its own builtin) · man printfprintf '%s\n' a b c prints three lines, not one; surprising until you expect it, then handy. Prefer printf over echo in scripts: no -e/-n portability ambiguityprintf '%-8s %8.3f eV\n' "lj38" 3.14159
lj38 3.142 eV
%-8s is a string left-justified in an 8-wide field; %8.3f is a float right-
justified in 8 columns, three decimals; \n is the newline you add yourself. The
one behaviour that surprises everyone: the format string is reused for any
extra arguments, which makes printing a list a one-liner:
printf '%s\n' alpha beta gamma
alpha
beta
gamma
Three arguments, one %s\n template, three lines. (This is why printf is the
scripting choice over echo: no -e/-n portability guesswork: the format string
says exactly what you get.)
G. Heredocs: generating multi-line text and files#
Sometimes a script needs to emit a whole block: a config file, a report, a submission script. A here-document feeds an inline block to a command, and is the cleanest way to write a multi-line file:
| cmd <<EOF … EOF | feed the block to cmd; $vars and $(…) are expanded |
| cmd <<'EOF' … EOF | quoted delimiter: the block is literal, no expansion |
| cmd <<-EOF … EOF | the - strips leading tabs, so you can indent the block |
| cmd <<< "text" | here-string: feed a single line as input |
With a plain EOF, the block behaves like a double-quoted string: variables and
command substitutions expand:
proj="lj38"; cat <<EOF
project: $proj
built: $(date +%F)
EOF
project: lj38
built: 2026-07-20
Quote the delimiter (<<'EOF') and the block is taken literally, expanding
nothing (just like single quotes, Notebook 10): what you want when the text itself
contains $:
cat <<'EOF'
literal: $proj and $(date +%F) are NOT expanded here
EOF
literal: $proj and $(date +%F) are NOT expanded here
The “killer app” is writing a file: redirect the heredoc and you have generated a config in place. This is exactly how you will produce input decks and (Notebook 16) cluster submission scripts:
proj="lj38"; cat > scratch/run.inp <<EOF
&GLOBAL
PROJECT $proj
RUN_TYPE ENERGY
&END
EOF
cat scratch/run.inp
&GLOBAL
PROJECT lj38
RUN_TYPE ENERGY
&END
And the one-line cousin, the here-string <<<, feeds a single string to a
command’s input: handy for piping a variable in without an echo:
wc -w <<< "one two three four"
4
H. Robustness: failing early and loudly#
Now we can open up the header we have used all along. set -euo pipefail is three
switches that turn silent, limping failure into an immediate, visible stop:
set -euo pipefail at the top of every script.
| set -e | exit immediately if any command fails |
| set -u | treat use of an unset variable as an error (pair with "${v:-default}") |
| set -o pipefail | a pipeline fails if any stage fails, not only the last |
| set -euo pipefail | the standard one-line header: all three at once |
| bash -x script / set -x | trace each command as it runs (debugging) |
set -u is the one with a direct line back to Notebook 10: a typo’d or never-set
variable is no longer silently empty; it stops the script. Watch a script die the
instant it reaches an unset variable (the || echo is only so this page keeps
running):
(cd scratch && ./strict.sh) 2>&1 || echo "↳ set -u stopped the script: 'label' was never set (note 'finishing' never printed)"
starting
./strict.sh: line 4: label: unbound variable
↳ set -u stopped the script: 'label' was never set (note 'finishing' never printed)
It printed starting, hit the unset label, and stopped; it never reached
finishing. That is the whole point: a broken run ends where it broke, loudly,
instead of producing a wrong answer. The fix is the Notebook-10 default value:
(cd scratch && ./strict-ok.sh)
starting
label is: default
finishing
And when a script misbehaves and you want to see what it is doing, bash -x
traces every line (each + is a command as the shell runs it):
bash -x scratch/strict-ok.sh 2>&1 | head -n 7
+ set -euo pipefail
+ echo starting
starting
+ echo 'label is: default'
label is: default
+ echo finishing
finishing
The principle behind the whole header: fail early and loudly. A script that stops at the first sign of trouble is far kinder than one that quietly writes a corrupt file you discover three weeks later.
Exercises#
One script grows across this set, from a fixed greeting to a real analysis tool.
Everything is created and run in a fresh scratch/; data/ stays read-only. (On
this page the scripts are written with here-documents so they are reproducible; in
your own terminal you would type them into Vim from Notebook 9, and the result is the
same file.)
Warm-up 1 (worked) — A first script with variables#
Under the safe header, assign a couple of variables and use them, then run with
./.
system: lj38 (binding-energy run)
✓ the script runs under ./ and prints the variables it was given
Warm-up 2 (your turn) — Act on an argument#
Write a script that takes a filename as $1, and reports both how many arguments it
got ($#) and the name it was handed ("$1").
you passed 1 argument(s)
the file is: sample.xyz
✓ the script acted on its argument: one passed, and its name reported
Applied 1 (your turn) — Add a flag with getopts#
Extend a script with the getopts skeleton: a -v flag that changes a label, and a
-n N flag that sets how many lines of the file to show. Run it on a scratch file
with -v -n 2.
[verbose] first 2 lines of notes.txt:
line one
line two
✓ both flags took effect: -v relabelled the output and -n 2 showed two lines
Applied 2 (your turn) — Factor a function#
Move the energy-averaging step into a function with a local variable, and have
it return the number via echo + $(…). The script takes the log as $1.
mean energy: -143.4444
✓ the function computed the mean and handed it back through echo + $( )
Applied 3 (worked) — printf and a heredoc#
Format a result line with printf, then generate a small summary file with a
heredoc (note the inner delimiter is unquoted, so the variables expand into the
file).
lj38 mean = -143.4444 eV
cat scratch/summary.txt
system: lj38
mean energy: -143.4444 eV
✓ printf formatted the line and the heredoc wrote the summary file with the values expanded
Composite — putting it together (a complete analysis tool)#
The capstone, and the payoff of all of Part III. Write analyze.sh that takes a
trajectory log as $1 and an optional -v flag; uses a function to extract and
average its energies (grep/awk); prints a printf-formatted result; writes a
heredoc summary file; and runs under the set -euo pipefail header, invoked
with ./. It composes Notebooks 9–11 (write, quote, run) with 6 and 8 (extract) and
everything in this notebook. It still processes one file; looping over many is
the next notebook.
verbose result mean energy = -143.4444 eV
cat scratch/summary.txt
file: run.log
mean energy (eV): -143.4444
✓ analyze.sh ran end to end: flag, function, printf result, and a written summary file
Optional stretch (your turn) — Make it fail loudly#
Feel the safety net. Write a script that references an unset variable through a
"${var:-default}", so it survives set -u; then trace a run with bash -x to
watch each step.
+ set -euo pipefail
+ echo 'temperature: 300 K'
temperature: 300 K
✓ the default value filled in for the unset variable, so the script survived set -u
Outlook#
Your script now takes inputs, names its data, factors work into functions, prints
clean output, generates files, and fails safely: a real, reusable tool. But it
still does one thing, once. Next (Notebook 13): control flow (if/case, exit
codes and &&/||, and loops), so a script can decide and repeat. That is
what turns analyze.sh from a one-file tool into one that sweeps an entire directory
of trajectories in a single run.
printf— print formatted output from a template — the dependable, portable way a script reports its results.
See the full Compendium Scriptorum for every command met so far, and where to find it again.