Skip to content
shellhacks.dev
Command Line

How to Use realpath and readlink to Resolve Paths Safely

Resolve symlinks and relative paths with realpath and readlink -f, use -e and -m for missing files, and handle the macOS differences in scripts.

6 min read
Summarize in AI
Illustration for "How to Use realpath and readlink to Resolve Paths Safely": a terminal session, a directory tree, a process monitor.

A path in a script can point to the same file in a dozen ways: a relative path, a path through .., a symlink from /usr/local/bin. Comparing, logging or checking containment needs one canonical form. realpath and readlink provide it. The examples use GNU coreutils 9.4 on Linux; the link types themselves are covered in hard versus soft links.

Resolve to an absolute path

Shell
ln -s /opt/app/releases/2026-11-03 /opt/app/current
cd /opt/app
realpath current
realpath ./current/../shared
Text
/opt/app/releases/2026-11-03
/opt/app/releases/shared

The second result shows the order of work: current is resolved first, so .. applies to the release directory, not to /opt/app. That is the behaviour of the kernel and of realpath, and the opposite of purely textual tools such as dirname, covered in basename and dirname.

Control what must exist

Shell
realpath -e /srv/data/missing.txt
realpath -m /srv/data/missing.txt
realpath /srv/data/missing.txt
Text
realpath: /srv/data/missing.txt: No such file or directory
/srv/data/missing.txt
/srv/data/missing.txt

-e demands that every component exists, which makes it a check and a resolver in one. -m accepts anything and just normalises. The default requires all but the last component to exist. For safety checks, such as confirming that a user-supplied path stays inside an upload directory, use -e and compare the prefix, as shown in validating input.

Shell
readlink /opt/app/current
readlink -f /opt/app/current
Text
/opt/app/releases/2026-11-03
/opt/app/releases/2026-11-03

Without options, readlink prints the symlink's stored target text, which may be relative and may be dangling. -f follows the chain completely and is what scripts used before realpath was widespread. When a symlink is replaced during a deploy, readlink shows what it points at right now.

Relative paths

Shell
realpath --relative-to=/opt/app /opt/app/releases/2026-11-03
Text
releases/2026-11-03

This is useful for building relative symlinks, so a tree can be moved without breaking its links, and for shorter log lines.

How resolution works

To resolve a path, the tool walks it one component at a time from the root or the current directory. A symbolic link replaces the current component with its target, restarting the walk from there, and a .. moves to the parent of the directory the walk has reached, which after following a link can be a different directory from the one the text suggests. That is why link/.. is not the same as the directory that contains link. The kernel does this for every system call, with a limit on the number of links followed, around 40 on Linux, to stop loops. A tool that works only with text, such as dirname, cannot know any of this, which is why logical and physical paths can disagree.

Shells keep their own idea of the current directory. After cd link, Bash's pwd prints the logical path through the link, and pwd -P prints the physical one. Scripts that compare paths should use the physical form from realpath on both sides, or the comparison fails for equal places.

Safety checks built on resolution

A containment check is the typical use. To accept a user-supplied name only if it stays inside a base directory, resolve both and compare the prefix with a trailing slash: [[ $(realpath -m -- "$base/$name")/ == "$(realpath -e -- "$base")"/* ]]. The trailing slash prevents /srv/uploads-old from matching the base /srv/uploads. Using -m allows a file that does not exist yet, as when creating an upload. This protects against .. and symlink tricks at the time of the check, but not against a symlink created afterwards, a race known as time of check to time of use. For code that handles hostile input, do the work on an open directory descriptor with a language that supports openat, or create the files with a flag that refuses to follow links.

Build and deploy scripts

The release pattern in the example, a current link that points at a dated directory, relies on atomic switching. Creating the new link beside the old and renaming it over the top, ln -s new tmp-link && mv -T tmp-link current, replaces it in one step, while ln -sfn removes and re-creates it with a short gap. Scripts that read current should resolve it once at start and use the resolved path throughout, so that a deploy happening mid-run does not give them half of one release and half of another. Logs should record the resolved path as well, which answers later which release actually ran.

Differences between systems

The GNU and BSD versions differ in options. GNU readlink -f resolves everything and tolerates a missing last component, BSD added -f in newer releases, and macOS 12.3 and later include realpath in the base system. Busybox has a small realpath without the -m and --relative-to options in some builds. When a script has to run in minimal containers, check for the options it uses at start, or avoid them. A Python one-liner, python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' path, works everywhere Python exists and behaves like the default mode of realpath.

Using the result in scripts

Resolve the path of the script itself with script=$(realpath -e -- "${BASH_SOURCE[0]}") and derive the directory with ${script%/*}, then source helper files from there. This works when the script is run through a symlink, from another directory, or via PATH. It does not work when the script is read from standard input or sourced from a pipe, where there is no file, so guard for that case when the script supports it. Also remember that resolving at start is a snapshot: if the target is replaced during a long run, the script keeps the earlier location.

When the path is on another machine

Resolution happens on the machine that runs the command. A path read from a remote host's output, or a path inside a container image, has to be resolved there, with ssh host realpath path or docker exec, because a link that points to /opt/app/current means something different on each system. Do the same when comparing mount points: a bind mount hides the link structure underneath, so check with findmnt instead of guessing.

Where this does not apply

macOS shipped without realpath for years and still has no readlink -f on older versions; Homebrew's GNU coreutils provides grealpath, as in GNU coreutils on macOS. A portable fallback is cd "$(dirname "$p")" && echo "$PWD/$(basename "$p")", which resolves directories but not a final symlink. Resolution is a point-in-time answer: a symlink can change between the check and the use, a classic time-of-check issue covered in safe temp files and races. The GNU manual describes realpath and readlink options in full.

A practical pattern is to resolve once at the top of a script and use the result everywhere: root=$(realpath -e -- "${1:-.}") fails early on a bad argument and gives later code a stable absolute path. Log the resolved path rather than the argument as typed, so that a report of a problem names the actual file and not an alias that may point elsewhere tomorrow. This also makes comparisons between two paths reliable, since two names for one file resolve to identical strings.

Taggedterminal
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.