Script files and the shebang

A script is a text file with the commands you would otherwise type at the terminal, one after another. By convention it ends in .sh, but the extension is optional. Open an editor, create hello.sh and put in these four lines.

#!/bin/bash
# my first script
echo "Hello from $(hostname)"
echo "Current directory: $PWD"

The first line, #!/bin/bash, is the shebang. When the file is executed directly, it tells the kernel which program should read it. It must be the very first line and start at the first character. Any other line starting with # is a comment and is ignored, as is everything after a # in the middle of a line.

You will also see #!/usr/bin/env bash. It means "use the first bash found on PATH", which helps on systems where bash is not in /bin or when you installed a newer bash with conda. On a cluster either form is fine.

Two ways to run a script

The first is to hand the file to bash. No execute permission is needed.

$ bash hello.sh
Hello from kramers
Current directory: /home/kim/scripts

The second is to run the file itself as a command. That requires execute permission. Without it the shell refuses; after chmod +x it works.

$ ./hello.sh
bash: ./hello.sh: Permission denied
$ chmod +x hello.sh
$ ./hello.sh
Hello from kramers
Current directory: /home/kim/scripts
$ hello.sh
bash: hello.sh: command not found

As the last line shows, dropping the ./ fails. A command without a path is only looked up in the directories listed in PATH, and the current directory is normally not one of them. ./ spells out "the file in this directory". If you collect your scripts in ~/bin and add that directory to PATH, you can call them by name from anywhere. Permissions and PATH are covered in the Linux starter pack chapters on permissions and processes and environment variables.

One more difference: bash hello.sh ignores the shebang and always uses bash. Running sh hello.sh may hand the file to dash instead of bash on some systems, and lines with bash-only syntax then fail.

Variables

Create a variable with name=value. There must be no spaces around =. If you add spaces out of habit from other languages, bash reads something else entirely.

$ mol = water
bash: mol: command not found
$ mol= water
bash: water: command not found
$ mol=water
$ echo $mol
water

mol = water runs a command called mol with the arguments = and water. mol= water sets mol to an empty string for the duration of a command called water.

To read a variable, write $mol or ${mol}. The braces are needed when a letter or underscore follows the name directly.

$ echo "$mol_opt.inp"
.inp
$ echo "${mol}_opt.inp"
water_opt.inp

In the first line bash looks for a variable called mol_opt. There is none, so it expands to nothing and only .inp is left. No error is raised. When building file names, using braces by habit is the safe choice.

bash variables have no types; everything is a string. After x=5; x+=1 the value is 51, not 6. Arithmetic goes through $(( )), shown below.

Single and double quotes

Inside double quotes, $variable and $(command) are replaced by their values. Inside single quotes nothing is replaced.

$ echo 'single: $mol' "double: $mol"
single: $mol double: water

Put double quotes around variables almost every time. An unquoted $f is split into separate words at spaces, and characters like * are expanded as file names. A single space in a file name is enough for cp $f backup/ to look for two wrong files. cp "$f" backup/ does not have this problem. Chapter 04 comes back to this.

Command substitution and arithmetic

$(command) is replaced by the output of the command. The older backtick form `command` does the same but is awkward to nest.

$ n=$(grep -c "^H" water.xyz)
$ echo "H atoms: $n"
H atoms: 2
$ today=$(date +%F)
$ echo "$today"
2026-09-29

Integer arithmetic happens inside $(( )). Within the parentheses variables do not need a $. Division keeps only the quotient, and a decimal point is a syntax error.

$ echo $(( 7 / 2 )) $(( 7 % 2 )) $(( 2**10 ))
3 1 1024
$ nprocs=8; echo $(( nprocs * 2 ))
16
$ echo $(( 1.5 * 2 ))
bash: 1.5 * 2 : arithmetic syntax error: invalid arithmetic operator (error token is ".5 * 2 ")

For anything with decimals, such as energies, use awk or bc. Converting Hartree to kcal/mol:

$ awk 'BEGIN { printf "%.2f\n", -76.426369457144 * 627.509 }'
-47958.23
$ echo "-76.426369457144 * 627.509" | bc -l
-47958.234671682974296

Input and output: read, echo, printf

read takes one line from the keyboard and stores it in a variable. -p sets a prompt; -r keeps backslashes as they are. Use -r unless you have a reason not to.

#!/bin/bash
read -r -p "Molecule name: " name
echo "Writing ${name}_opt.inp"
$ ./ask.sh
Molecule name: benzene
Writing benzene_opt.inp

In practice, scripts that take arguments are easier to use than scripts that ask questions, not least because a job running under the scheduler has no keyboard. Arguments are covered in chapter 03.

echo is fine for most output, but its handling of options like -n and -e varies between systems. When you need aligned columns, tabs or exact newlines, use printf. It does not add a newline on its own, so include \n.

$ printf '%-10s %12.6f\n' water -76.426369457144 ammonia -56.55
water        -76.426369
ammonia      -56.550000

If there are more arguments than the format uses, the format is applied again, which is handy for tables.

Writing input files with a here-document

A here-document passes the text between <<EOF and a line containing only EOF to a command as its input. With cat > file <<EOF that text becomes a file. Variables and command substitutions inside are expanded, so it works well as an input template.

#!/bin/bash
# write an ORCA input file from a single coordinate file such as water.xyz

xyz=water.xyz
mol=${xyz%.xyz}
method="r2SCAN-3c"
nprocs=8
maxcore=3000
charge=0
mult=1

cat > "${mol}_opt.inp" <<EOF
# ${mol}: geometry optimisation + frequencies
! ${method} Opt Freq TightSCF
%pal nprocs ${nprocs} end
%maxcore ${maxcore}

* xyz ${charge} ${mult}
$(tail -n +3 "$xyz")
*
EOF

echo "wrote ${mol}_opt.inp"

tail -n +3 prints from line 3 to the end, which drops the atom count and comment line of the xyz file and keeps only the coordinates.

$ ./make_inp.sh
wrote water_opt.inp
$ cat water_opt.inp
# water: geometry optimisation + frequencies
! r2SCAN-3c Opt Freq TightSCF
%pal nprocs 8 end
%maxcore 3000

* xyz 0 1
O   0.000000   0.000000   0.119262
H   0.000000   0.763239  -0.477047
H   0.000000  -0.763239  -0.477047
*

The closing EOF must start at the beginning of the line with nothing after it, not even a space. Any word works as the marker.

Quoting the marker, <<'EOF', turns expansion off and the text is written exactly as it is. Use it when the content must keep its $ signs, for example when a script writes a job script. To expand some variables and keep others, use an unquoted <<EOF and write \$ for the ones to keep.

$ cat <<'EOF'
! ${method} Opt
cd $PBS_O_WORKDIR
EOF
! ${method} Opt
cd $PBS_O_WORKDIR
$ method=B3LYP
$ cat <<EOF
! ${method} Opt
cd \$PBS_O_WORKDIR
EOF
! B3LYP Opt
cd $PBS_O_WORKDIR
Inputs without coordinates

If the file name in $(tail -n +3 "$xyz") is wrong, an error message goes to the screen and the input file is still written, just without coordinates. Get into the habit of looking at the input with cat before running ORCA. Chapter 02 shows how to check that a file exists first.

Parameter expansion

Adding operators inside ${...} lets you trim a variable's value. It strips extensions and directories from file names without calling basename or sed, which is why it shows up in loops all the time.

SyntaxMeaningWith f=conformers/ethanol_conf03.xyz
${f%.xyz}remove .xyz from the endconformers/ethanol_conf03
${f##*/}remove everything up to the last / (file name only)ethanol_conf03.xyz
${f%/*}remove the last / and what follows (directory only)conformers
${f#*_}remove up to the first _conf03.xyz
${f%%_*}remove the first _ and everything afterconformers/ethanol
${f/.xyz/.inp}replace the first .xyz with .inpconformers/ethanol_conf03.inp
${n:-4}4 if n is unset or empty(default value)

# trims from the front, % from the back. A single symbol removes the shortest match, a double one the longest. To get the bare name without directory or extension, do it in two steps.

$ f=conformers/ethanol_conf03.xyz
$ base=${f##*/}
$ echo "${base%.xyz}"
ethanol_conf03
$ unset nprocs
$ echo "${nprocs:-4}"
4
$ nprocs=16
$ echo "${nprocs:-4}"
16

${nprocs:-4} is a common way to give a script a default. Writing nprocs=${NCPUS:-4} uses the requested core count inside a PBS job and 4 when you test on the login node.