Skip to content

Hard and Symbolic Links

On Linux, a file's name is not the data itself. A directory entry maps a name to an inode, and the inode holds the file's metadata and points to its data blocks. Because naming and data are separate, a single inode can have more than one name, or one name can point somewhere else entirely. The first case is a hard link; the second is a symbolic link, also called a symlink.

Understanding this topic depends on the inode model introduced in Disk and File System Basics. If you need a refresher on path resolution, Paths and File Types, later in this module, covers it in more depth. The examples use ln from GNU Coreutils on Ubuntu Server LTS. You will see the internal difference between a hard link and a symbolic link; create and inspect both with ln, stat, readlink, and find; learn where a relative symlink's target is actually resolved from; understand when data survives after a name is deleted; and finish by diagnosing a broken link, a cross-file-system case, and an incorrect release pointer, weighing the risk links introduce during backups and production cutovers.

A Directory Name, an Inode, and Data

A simplified picture of a regular file:

name in a directory ──> inode ──> data blocks

The inode stores the file's type, owner, permissions, timestamps, link count, and pointers to its data blocks. A file's name is normally not stored inside the inode at all — it lives in the directory entry. Because of that, two directory entries can point to the same inode.

report.txt ─┐
            ├──> inode 10542 ──> data
copy.txt ───┘

These are two hard links. There is no technical sense in which one is "the original" and the other "the copy" — both are equally valid names for the same file.

ln creates a hard link by default:

ln <existing-file> <new-name>

For example:

printf 'version=1\n' > settings.conf
ln settings.conf settings.backup

Checking the result:

ls -li settings.conf settings.backup
stat -c 'inode=%i link=%h name=%n' settings.conf settings.backup

A representative result:

10542 -rw-r--r-- 2 user user 10 Jul 29 11:00 settings.backup
10542 -rw-r--r-- 2 user user 10 Jul 29 11:00 settings.conf
inode=10542 link=2 name=settings.conf
inode=10542 link=2 name=settings.backup

Both names share the same inode number, and the link count is 2. Changing the content through one name is visible through the other immediately:

printf 'mode=production\n' >> settings.backup
tail -n 2 settings.conf

This is not a copy. There are not two independent sets of data blocks on disk.

What Happens When a Name Is Removed?

rm normally does not "erase" data blocks directly — it detaches a name from its inode in the containing directory. The inode's link count drops by one. Once the last hard link is removed, and no process is still holding the file open, the kernel is free to reuse its space.

rm -- settings.conf
cat settings.backup
stat -c 'inode=%i link=%h name=%n' settings.backup

settings.backup keeps working, and its link count becomes 1. This same property explains why disk space is not freed immediately after a log file is deleted if a running process still has it open — you can check for that situation later with lsof +L1.

A hard link:

  • can only be created to an existing inode;
  • cannot cross a file system boundary;
  • cannot normally be created to point at a directory as an ordinary user;
  • does not "break" when its target is moved, because it is bound to the inode, not to a path string.

Trying to hard-link across file systems produces an Invalid cross-device link error (EXDEV). Two paths can appear to be on the same disk yet still belong to separate mounted file systems:

findmnt -T <existing-file>
findmnt -T <new-link-directory>

If the file system or device identifier in the two results differ, a hard link cannot be created there — a symbolic link or an actual copy is needed instead.

A symbolic link is a distinct file type that stores its target as a text string:

current ──> "releases/v2" ──> releases/v2

It is created with ln -s:

ln -s <target-path> <link-name>

For example:

ln -s settings.backup settings.current

Checking the result:

ls -li settings.current settings.backup
readlink settings.current
readlink -f settings.current
stat -c 'inode=%i type=%F name=%n' settings.current
stat -Lc 'inode=%i type=%F name=%n' settings.current

readlink prints exactly the text stored inside the link; readlink -f prints the fully resolved canonical path. GNU stat normally reports on the link itself; -L follows the target instead. The two inode numbers in the result will be different.

Where Is a Relative Target Resolved From?

The most important rule: a relative path stored inside a symbolic link is resolved relative to the directory the link itself lives in, not relative to the terminal's current working directory.

Take this layout:

/srv/demo/
├── current -> releases/v2
└── releases/
    └── v2/
        └── app.conf

The link /srv/demo/current stores the string releases/v2. The kernel resolves it relative to /srv/demo, landing on /srv/demo/releases/v2.

ln -s releases/v2 /srv/demo/current

The directory you run this command from does not affect how the target resolves later. But it is easy to get the path wrong while creating a link. GNU ln offers -r to compute the correct relative path automatically, from the target to the link's own location:

ln -sr /srv/demo/releases/v2 /srv/demo/current

-r is a GNU Coreutils extension and is not available in every POSIX environment.

An absolute link, such as /srv/demo/releases/v2, starts from a fixed point in the system. It still points at the same place even if the directory holding the link is moved, but it can break if the whole directory tree is relocated under a different root, chroot, container, or server.

A relative link is convenient for directory trees that move together as a unit. But if the link itself is moved to a different location, the meaning of its target changes. Choose based on the deployment layout you actually have.

If the target does not exist, the result is a dangling (broken) link. Creating one is allowed:

ln -s does-not-exist broken
ls -l broken
readlink broken
realpath -e broken

ls and readlink see the link itself; realpath -e fails because the target cannot be resolved. test -L checks that the link itself exists; test -e checks that its target resolves:

test -L broken && printf 'the link exists\n'
test -e broken || printf 'but the target does not exist\n'
Property Hard Link Symbolic Link
What it binds to An inode on the same file system A string holding a target path
Has its own inode? No, shares the target's inode Yes
Can cross file systems? No Yes
Can point at a directory? Usually not Yes
Can point at a nonexistent target? No Yes
If the target name is removed The other hard link still works The link becomes broken
Type shown by ls -l The target's own type l

The "has its own inode" row describes the symbolic link itself. Many programs automatically follow a link and operate on its target; some operations, such as rm link, act on the link itself instead.

A Safe Lab

This lab only operates inside a directory created by mktemp. Do not reuse the $lab variable shown below across a different terminal session without resetting it.

Starting State

lab=$(mktemp -d)
printf 'Lab: %s\n' "$lab"
printf 'alpha\n' > "$lab/original.txt"
ln "$lab/original.txt" "$lab/hard.txt"
ln -s original.txt "$lab/symlink.txt"

Checking the result:

ls -li "$lab"
stat -c 'dev=%d inode=%i link=%h type=%F name=%n' "$lab/original.txt" "$lab/hard.txt"
readlink "$lab/symlink.txt"

original.txt and hard.txt should show matching dev, inode, and link count. symlink.txt has a different inode and stores the string original.txt.

Content and Name Are Independent

printf 'beta\n' >> "$lab/hard.txt"
cat "$lab/original.txt"
cat "$lab/symlink.txt"

Both commands print alpha and beta. Now remove the original name interactively:

rm -i -- "$lab/original.txt"

After confirming:

cat "$lab/hard.txt"
readlink "$lab/symlink.txt"
test -L "$lab/symlink.txt" && printf 'the symlink still exists\n'
test ! -e "$lab/symlink.txt" && printf 'but its target is gone\n'

The hard link still opens the content; the symbolic link exists but cannot find its target anymore.

find "$lab" -xdev -samefile "$lab/hard.txt" -printf '%i %p\n'

-samefile finds every name bound to the same inode as the given file. -xdev prevents crossing into another file system, which makes sense because inode numbers are only unique within their own file system.

Cleanup

printf 'directory to remove: %q\n' "$lab"
find "$lab" -maxdepth 1 -ls
rm -rI -- "$lab"
test ! -e "$lab" && printf 'cleaned up\n'

Warning

If the path given to rm is the symbolic link itself, rm normally removes the link, not its target. But recursive tools and syntax details such as a trailing / can cause the target to be followed instead. In production, do not run a recursive operation against a symbolic link without first checking its target with readlink, realpath, and stat.

Practical Scenario: A Broken Release Pointer

A deployment points /srv/myapp/current at the active release. After the service restarts, it cannot find its configuration.

Investigation

Analyze the link without changing anything first.

namei -l shows every component of a path individually, which makes it possible to see exactly where resolution breaks down. The difference between readlink -f and namei's path diagnostics is covered in more depth in Paths and File Types, later in this module.

ls -ld /srv/myapp/current
readlink /srv/myapp/current
readlink -f /srv/myapp/current
namei -l /srv/myapp/current/config/app.ini
findmnt -T /srv/myapp/current

For example, if readlink prints releases/v3 but readlink -f prints nothing, then /srv/myapp/releases/v3 either does not exist or some component of the path fails to resolve. namei shows exactly which component is broken.

A Safe Replacement

Before forcibly overwriting the existing current link in production, record the new release and the rollback target:

readlink /srv/myapp/current
test -d /srv/myapp/releases/v2
namei -l /srv/myapp/releases/v2

The actual change below should only run in a test VM with verified permissions and backup policy, or as part of an approved deployment process:

cd /srv/myapp
ln -s releases/v2 current.new
readlink -f current.new
mv -T current.new current

Because current.new is created in the same directory, the final rename is atomic within one file system: an observing process sees either the old name or the new one, never a gap in between. This does not automatically update file descriptors a process already has open, or the configuration of a process that already started.

Rollback:

cd /srv/myapp
ln -s releases/<previous-version> current.rollback
readlink -f current.rollback
mv -T current.rollback current

Replace <previous-version> with the value you recorded during the investigation step. sudo is only needed if the account running the command lacks write access to /srv/myapp, and deployment policy allows using it; it does not make an incorrect target safe.

Danger

Using ln -sfn blindly can silently replace an existing path. Check with stat, readlink, and namei whether the target is a directory, a regular file, or another symbolic link. For any deployment that matters, back up the release directory or take a VM snapshot, and record the old link's value for rollback.

Backups, Copying, and Security

Check your backup tool's own policy on links. GNU cp -a, for example, normally preserves a symbolic link as a symbolic link, while cp -L follows the target instead. Hard links may be restored either as one shared inode or as separate copies, depending on the backup format and the options used.

After a restore, check more than the file count:

find <restored-directory> -type l -ls
stat -c '%d:%i %h %n' <important-file-1> <important-file-2>

In a directory that anyone can write to, another user can replace a link, redirecting a higher-privileged process to an unexpected target. At the programming level, tools such as lstat, openat2 restrictions, or O_NOFOLLOW address this. As an administrator:

  • use mktemp for a safe temporary file;
  • avoid a window between checking a target and using it in a directory an attacker could modify in between;
  • check a recursive copy tool's -H, -L, -P semantics before relying on it;
  • do not ignore a symlink's owner or a directory's sticky-bit policy.

Common Mistakes

  • Treating a hard link as if it were a copy.
  • Trying to find "the original" among several hard links — there is no such distinction.
  • Resolving a relative symlink target against the current working directory instead of the link's own directory.
  • Treating test -e on a dangling symlink as proof the link itself does not exist — test -e checks the resolved target; use test -L for the link itself.
  • Treating an inode number as a globally unique identifier instead of one that is only unique within its own file system.

Exercises

  1. In a temporary directory, create one file and two hard links to it. After each step, record the link count with stat -c '%i %h %n'.
  2. Create two symbolic links, one with an absolute target and one with a relative target. Move the directory tree to another temporary location and determine which link still works, and why.
  3. Create a dangling symlink. Compare the results of ls -l, test -L, test -e, readlink, and realpath -e.
  4. On a test VM with two separate file systems, prove the difference with findmnt -T and stat -c '%d' before attempting a cross-file-system hard link. Note the expected error name; do not experiment with this in production.
  5. In a test deployment tree, replace the current symlink using a temporary name and mv -T. Roll back to the previous value using the recorded target.

Verification criterion: for hard links, you should be able to show matching device and inode values; for symbolic links, you should be able to show both the stored target string and the fully resolved path.

References

  • GNU Coreutils manual: ln invocation: https://www.gnu.org/software/coreutils/manual/html_node/ln-invocation.html
  • Linux man-pages: link(2): https://man7.org/linux/man-pages/man2/link.2.html
  • Linux man-pages: symlink(7): https://man7.org/linux/man-pages/man7/symlink.7.html
  • Linux man-pages: path_resolution(7): https://man7.org/linux/man-pages/man7/path_resolution.7.html
  • GNU Coreutils manual: cp invocation and symbolic links: https://www.gnu.org/software/coreutils/manual/html_node/cp-invocation.html
  • Linux man-pages: openat2(2): https://man7.org/linux/man-pages/man2/openat2.2.html