Where a script lives
Finding the script's own directory, and $0's limits
A script that ships with a config file, a template or a helper beside it has to find that file, and the obvious way does not work. A script runs in the directory its caller was standing in, which has nothing to do with where the script itself is.
$0 is what the caller typed
Not a path to the script. Whatever appeared on the command line:
cat > /srv/deploy/tools/whereami.sh <<'EOF'
#!/usr/bin/env bash
echo "invoked as: $0"
echo "running in: $PWD"
EOF
chmod +x /srv/deploy/tools/whereami.sh
cd /srv/deploy
./tools/whereami.sh
cd /
/srv/deploy/tools/whereami.sh
invoked as: ./tools/whereami.sh
running in: /srv/deploy
invoked as: /srv/deploy/tools/whereami.sh
running in: /
One script, two runs, two different values in $0, and $PWD following the caller rather than
the file. Neither run is unusual: the first is how you run a script you are sitting next to, the
second is how cron and systemd run everything.
A relative path breaks the moment you move
settings.conf sits in tools/ beside the script. Reading it by name works from one directory
and one only:
cat > /srv/deploy/tools/backup.sh <<'EOF'
#!/usr/bin/env bash
cat settings.conf
EOF
chmod +x /srv/deploy/tools/backup.sh
cd /srv/deploy/tools
./backup.sh
cd /srv/deploy
./tools/backup.sh
retain=7
cat: settings.conf: No such file or directory
Nothing about the script changed between those two runs except the directory it was called from.
Every relative path inside a script is resolved against $PWD, so one written and tested from
its own directory works there and nowhere else, which is why this tends to break first in cron
rather than in testing.
dirname "$0" gets you most of the way
$0 holds the path the caller used, so its directory part is the script's directory as reached
from here, which is enough to build a path to a sibling:
cat > /srv/deploy/tools/backup.sh <<'EOF'
#!/usr/bin/env bash
here=$(dirname "$0")
echo "here = $here"
cat "$here/settings.conf"
EOF
chmod +x /srv/deploy/tools/backup.sh
cd /srv/deploy
./tools/backup.sh
here = ./tools
retain=7
dirname is doing the same job as ${0%/*} from
Parameter expansion, and this is one of the cases where the
command is the better choice: ${0%/*} returns the whole string when $0 has no slash in it,
which is exactly what happens for a script found on PATH.
Now put a symlink to the script somewhere convenient, the way /usr/local/bin normally holds
one:
cat > /srv/deploy/tools/backup.sh <<'EOF'
#!/usr/bin/env bash
here=$(dirname "$0")
echo "here = $here"
cat "$here/settings.conf"
EOF
chmod +x /srv/deploy/tools/backup.sh
ln -s ../tools/backup.sh /srv/deploy/bin/run-backup
cd /srv/deploy
./bin/run-backup
here = ./bin
cat: ./bin/settings.conf: No such file or directory
$0 is the link, so dirname gives the directory the link is in. Nothing has resolved anything.
The line that works
cat > /srv/deploy/tools/backup.sh <<'EOF'
#!/usr/bin/env bash
here=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")
echo "here = $here"
cat "$here/settings.conf"
EOF
chmod +x /srv/deploy/tools/backup.sh
ln -s ../tools/backup.sh /srv/deploy/bin/run-backup
cd /srv/deploy
./bin/run-backup
cd /
/srv/deploy/bin/run-backup
here = /srv/deploy/tools
retain=7
here = /srv/deploy/tools
retain=7
Three things are happening, and each fixes one of the failures above. readlink -f follows every
symlink in the path and returns an absolute one, so the bin/ link resolves to the real file.
dirname then takes the directory. BASH_SOURCE[0] is $0 for a script run normally, and
something better when it is not. realpath is interchangeable with readlink -f here and reads
more clearly; both are in coreutils, so both are on every Debian system.
You will also see this spelling, and it is not equivalent:
cat > /srv/deploy/tools/backup.sh <<'EOF'
#!/usr/bin/env bash
echo "cd + pwd = $(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "readlink -f = $(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
EOF
chmod +x /srv/deploy/tools/backup.sh
ln -s ../tools/backup.sh /srv/deploy/bin/run-backup
cd /srv/deploy
./bin/run-backup
cd + pwd = /srv/deploy/bin
readlink -f = /srv/deploy/tools
cd into the directory and ask where you are: that makes the path absolute, and stops there. It
is the right answer when the script is never symlinked, and the wrong one the day somebody links
it into /usr/local/bin.
BASH_SOURCE against $0
They differ when the file being run is not the file that was invoked. A sourced file has no
invocation of its own, so $0 still belongs to the script that sourced it:
cat > /srv/deploy/tools/lib.sh <<'EOF'
echo "zero = $0"
echo "BASH_SOURCE = ${BASH_SOURCE[0]}"
EOF
cat > /srv/deploy/tools/main.sh <<'EOF'
#!/usr/bin/env bash
source "$(dirname "$0")/lib.sh"
EOF
chmod +x /srv/deploy/tools/main.sh
cd /srv/deploy
./tools/main.sh
zero = ./tools/main.sh
BASH_SOURCE = ./tools/lib.sh
A library that locates its own data with $0 finds the caller's directory instead. It works for
as long as everything that sources it happens to sit in the same place. BASH_SOURCE[0] names
the file the line is written in, whichever script did the sourcing.
The cost is portability: BASH_SOURCE is a bash array and dash does not have it, so a
#!/bin/sh script is stuck with $0. See
sh vs bash vs dash for where that line falls on Debian.
Exercises
-
Why does a script that works when you run it by hand fail under cron with "No such file or directory", even though the file it is looking for has not moved?
Answer
Because cron runs it from
$HOMErather than from the script's directory, and every relative path in the script is resolved against that. The script was only ever tested from the one directory where its relative paths happened to be correct. Resolve paths from${BASH_SOURCE[0]}instead of trusting$PWD. -
readlink -f "${BASH_SOURCE[0]}"andcd "$(dirname "${BASH_SOURCE[0]}")" && pwdreturn the same answer most of the time. When do they differ, and which would you write?Answer
They differ when the script was reached through a symlink:
readlink -fresolves it to the real file, and thecdform reports the directory the link sits in. They also differ on a path containing a symlinked directory, for the same reason. Write thereadlink -fform, since a script that is worth putting on someone'sPATHis a script somebody will symlink. -
Make a script print its own directory in a way that survives being sourced.
Answer
here=$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")$0would name the sourcing script, which is a different file in a possibly different directory.BASH_SOURCE[0]is the file the line is written in either way.
What's next
The pattern in this lesson is one line at the top of a script, and every path below it is built
from $here. Once a script has a directory, a config file and arguments, the next thing it needs
is to clean up after itself when it stops: trap, mktemp and why set -e is not enough on its
own. Exit codes and error handling is the groundwork
for that.