Skip to content
Back to blog

Linux CLI by Experiment: What ln Really Links

Six hands-on experiments reveal how hard links, symbolic links, inodes, unlinking, path resolution, and atomic release switches actually work on Linux.

The shortest explanation of ln is easy to memorize:

ln TARGET LINK_NAME       create a hard link
ln -s TARGET LINK_NAME    create a symbolic linktext

It is also too shallow to be useful when something surprising happens. Why does deleting the "original" leave a hard link intact? Why can a program keep reading a file after every visible name has been removed? Why does replacing a file sometimes break the relationship between two hard links? Why is a relative symbolic link interpreted from a directory you may not expect?

This is the first article in Linux CLI by Experiment, a series about learning command-line tools by observing the operating-system model beneath them. We will use ln, stat, readlink, and a few shell primitives to derive that model from actual behavior.

The commands target GNU/Linux. GNU stat options such as -c, GNU mv -T, and some ln options differ on macOS and BSD systems.

Build a disposable lab

Create a fresh directory under the system temporary directory:

lab_dir=$(mktemp -d --tmpdir ln-lab.XXXXXX)
cd "$lab_dir"
printf 'Working in %s\n' "$PWD"bash

Every destructive command in this article stays inside that directory. Keep the terminal open so that $lab_dir remains available throughout the experiments.

The main inspection command will be GNU stat:

stat -c 'name=%n inode=%i links=%h size=%s type=%F' FILE...bash

Its fields are:

FieldMeaning
%npathname passed to stat
%iinode number within the filesystem
%hhard-link count stored in the inode
%sapparent size in bytes
%Ffile type

The inode number by itself is not globally unique. A file is identified by its filesystem device and inode together. We will return to that boundary later.

The model: a filename is not the file

A useful simplified model of a Unix-like filesystem has three layers:

  1. A directory contains entries that map names to inode numbers.
  2. An inode stores the file type, ownership, permissions, timestamps, link count, size, and information needed to locate the data.
  3. The file's contents live in filesystem-managed data blocks or extents.

The filename belongs to the directory entry. It is not stored in the inode.

A hard link adds another directory entry for the same inode. A symbolic link is a separate file with its own inode; its contents are a pathname that the kernel may follow during path resolution.

That is the whole distinction. The rest of the behavior follows from it.

Experiment 1: two names, one inode

Create a regular file and a hard link:

printf 'version 1\n' > report.txt
ln report.txt report.hard
 
stat -c 'name=%n inode=%i links=%h size=%s' \
  report.txt report.hardbash

A typical result looks like this:

name=report.txt  inode=7477214 links=2 size=10
name=report.hard inode=7477214 links=2 size=10text

Both directory entries resolve to the same inode, and the inode reports two hard links. Neither name is the original. The word "target" in the ln command only describes which existing pathname was used to locate the inode.

Now write through one name and read through the other:

printf 'version 2\n' > report.hard
cat report.txtbash
version 2text

The shell opened the same inode through either name. Permissions and ownership also belong to that shared inode:

chmod 600 report.hard
stat -c 'name=%n mode=%A inode=%i' report.txt report.hardbash

Both names now report the same mode because there is only one underlying file.

To find other hard links when you already know one pathname, GNU find provides -samefile:

find . -xdev -samefile report.txt -printf '%i %p\n'bash

-xdev keeps the search on the current filesystem, which is also the only place where another hard link to this inode can exist.

Experiment 2: rm removes a name, not an open file

Open the file on descriptor 3 before removing either name:

exec 3<report.txtbash

Remove the first directory entry and inspect the second:

rm report.txt
stat -c 'name=%n inode=%i links=%h' report.hardbash

The link count is now one. The data is still reachable through report.hard.

Remove the last visible name:

rm report.hard
lsbash

The directory is empty, but descriptor 3 still refers to the opened file:

cat <&3
readlink /proc/$$/fd/3bash

On Linux, the result is similar to:

version 2
/tmp/ln-lab.A1b2C3/report.txt (deleted)text

Close the descriptor when finished:

exec 3<&-bash

The rm command ultimately asks the kernel to unlink a directory entry. File storage can be reclaimed only after both of these conditions are true:

  1. the inode's hard-link count has reached zero;
  2. no process still holds a kernel reference such as an open file descriptor.

This explains a common production mystery: a deleted log file can continue occupying disk space while a long-running process still has it open. Tools such as lsof +L1 can locate open files whose link count has reached zero.

It also explains why a hard link is not a backup. An extra name protects against removing one directory entry, but it does not protect against truncation, corruption, permission changes, or failure of the filesystem holding the inode.

Experiment 3: changing bytes and replacing a name are different operations

Recreate a file and a hard link:

printf 'v1\n' > config
ln config config.hardbash

First update the existing file in place:

printf 'v2\n' > config
cat config config.hardbash
v2
v2text

Shell redirection opened the inode behind config, truncated its contents, and wrote new bytes. The second name still reaches that inode, so it observes the same change.

Now write a new file and rename it over config:

printf 'v3\n' > config.new
mv config.new config
 
stat -c 'name=%n inode=%i links=%h' config config.hard
cat config config.hardbash

A typical result is:

name=config      inode=7477216 links=1
name=config.hard inode=7477215 links=1
v3
v2text

mv did not modify inode 7477215. It changed the directory entry named config so that it refers to a new inode. config.hard still refers to the old one.

This distinction matters in practice. Some editors and deployment tools save by truncating the existing file; others write a temporary file and atomically rename it into place. The first strategy preserves hard-link identity. The second intentionally breaks it for the replaced pathname.

Link-based incremental backup tools rely on this difference: unchanged files can share inodes across snapshots, while changed files must be replaced with new inodes. Blindly editing a hard-linked snapshot in place would mutate every snapshot that shares that inode.

Create a small release directory and point a symbolic link at a file inside it:

mkdir -p releases/v1
printf 'enabled=true\n' > releases/v1/app.conf
ln -s releases/v1/app.conf current.confbash

readlink prints the pathname stored inside the symbolic link:

readlink current.confbash
releases/v1/app.conftext

Compare the link with its target:

stat -c  'link   inode=%i type=%F size=%s' current.conf
stat -Lc 'target inode=%i type=%F size=%s' current.confbash

The first command inspects the symbolic link itself. -L makes GNU stat follow the link and inspect the target. The two inode numbers differ because these are two separate filesystem objects.

The link's reported size is normally the byte length of its stored pathname. It is not the size of the target file.

Create a link in a subdirectory using the same stored text:

mkdir links
ln -s releases/v1/app.conf links/broken.conf
 
readlink links/broken.conf
cat links/broken.confbash

The stored text is still releases/v1/app.conf, but the kernel interprets it relative to the directory containing the link. It therefore looks for:

links/releases/v1/app.conftext

That path does not exist. The symbolic link exists, but its target does not:

test -L links/broken.conf && echo 'the symlink exists'
test -e links/broken.conf || echo 'the resolved target does not exist'bash

Create the intended relative link by walking up from links/ first:

ln -s ../releases/v1/app.conf links/working.conf
cat links/working.confbash
enabled=truetext

This rule is one of the most important facts about ln -s:

A relative symbolic-link target is interpreted relative to the directory that contains the link, not relative to the shell's current directory when the link is later used.

GNU ln -sr TARGET LINK_NAME can calculate a relative target automatically. For portable scripts, calculate and test the desired pathname explicitly instead of assuming every ln implementation supports -r.

Most operations that open, read, or write current.conf follow the symlink and operate on releases/v1/app.conf. Other operations intentionally act on the symlink itself:

rm current.confbash

This removes the link, not releases/v1/app.conf. Likewise, readlink, lstat, rename, and unlink operate on the final symlink rather than following it. Always check a command's documentation when the distinction matters.

Experiment 5: filesystem and directory boundaries

An inode number identifies a record inside one filesystem. A directory on a different filesystem cannot store an entry for that inode.

On many Linux systems, /dev/shm is a separate tmpfs. Compare its device number with the lab directory before trying this experiment:

printf 'payload\n' > cross.txt
stat -c 'path=%n device=%d inode=%i' cross.txt /dev/shmbash

If the device numbers differ, a hard link across the boundary fails:

shm_hard="/dev/shm/ln-lab-hard.$$"
shm_soft="/dev/shm/ln-lab-soft.$$"
 
ln "$lab_dir/cross.txt" "$shm_hard"bash
ln: failed to create hard link ...: Invalid cross-device linktext

A symbolic link can cross the boundary because it stores a pathname instead of a foreign inode number:

ln -s "$lab_dir/cross.txt" "$shm_soft"
cat "$shm_soft"
rm -- "$shm_soft"bash

Only run this part if /dev/shm exists and is a different filesystem. The exact mount layout depends on the machine or container.

Try both forms:

mkdir data
ln data data.hard
ln -s data data.softbash

The hard-link attempt fails; the symbolic link succeeds. Allowing arbitrary hard links to directories would let users create cycles and give one directory multiple parents, breaking assumptions made by pathname traversal, recursive tools, and filesystem consistency checks.

Linux also restricts some hard links between files owned by different users via the fs.protected_hardlinks policy. Even when two paths are on the same filesystem, permissions and security policy can make link(2) fail.

Experiment 6: switch releases with one pathname change

Symbolic links are useful when consumers need a stable name while deployments move between versioned directories.

mkdir -p releases/v1 releases/v2
printf 'v1\n' > releases/v1/version.txt
printf 'v2\n' > releases/v2/version.txt
 
ln -s releases/v1 current
cat current/version.txtbash

Build the replacement link under a temporary name:

ln -s releases/v2 current.next
readlink current.nextbash

Then rename it over the public name:

mv -T current.next current
readlink current
cat current/version.txtbash
releases/v2
v2text

On GNU/Linux, mv -T treats current as the destination entry rather than as a directory to copy into. When source and destination are on the same filesystem, the underlying rename replaces the directory entry atomically: a process opening current observes either the old link or the new link, without a half-written symlink in between.

This does not migrate processes that already opened files below current; their file descriptors continue to refer to the old inodes. That is often desirable during a deployment, but it is a separate lifecycle decision from switching the public pathname.

Use ln safely in scripts

The two-operand form follows the same order as cp: the existing target comes first, and the new name comes second.

ln TARGET LINK_NAME
ln -s TARGET_TEXT LINK_NAMEbash

For a symbolic link, TARGET_TEXT does not need to resolve when the link is created. This makes dangling links possible and is why ln -s cannot validate your intended relative-path base for you.

The most important GNU options for scripts are:

OptionEffect
-s, --symboliccreate a symbolic link instead of a hard link
-f, --forcereplace an existing destination
-n, --no-dereferencedo not treat a destination symlink to a directory as that directory
-T, --no-target-directoryalways treat the final operand as the link name
-r, --relativecalculate a relative symbolic-link target
-v, --verboseprint each link after creating it

-T and -r are GNU extensions. If a script must run beyond GNU/Linux, start with the POSIX ln interface and verify every additional option on the target systems.

Avoid examples that replace system-managed executables, such as pointing /usr/bin/python at a hand-selected interpreter. Distribution packages, alternatives systems, virtual environments, and shebangs all have their own ownership rules. Practice inside a disposable directory, then use the platform's supported mechanism for real interpreter selection.

A compact diagnostic routine

When a link behaves unexpectedly, ask these questions in order:

  1. What text does the directory entry contain or reference?
  2. Am I inspecting the link or following it to the target?
  3. Are the two paths on the same filesystem?
  4. Did a program mutate an inode or replace a pathname with a new inode?
  5. Does an open process still hold a deleted inode alive?

These commands answer most of them:

# Show inode and hard-link count.
stat -c 'device=%d inode=%i links=%h type=%F name=%n' PATH
 
# Inspect the target after following a symbolic link.
stat -Lc 'device=%d inode=%i links=%h type=%F name=%n' PATH
 
# Print the pathname stored in a symbolic link.
readlink PATH
 
# Resolve the whole path when every component currently exists.
readlink -f PATH
 
# Find names for the same inode on this filesystem.
find SEARCH_ROOT -xdev -samefile PATH -print
 
# Find open files whose visible link count is zero.
lsof +L1bash

The durable mental model is small:

hard link      another directory entry for the same inode
symbolic link  another inode containing a pathname
unlink         remove one directory entry
open file      a kernel reference that can outlive every pathname
rename         change which inode a pathname selectstext

Once those statements feel concrete, ln stops being a command with two modes to memorize. It becomes a tool for controlling names, identity, and path resolution deliberately.

References

Discussion

Sign in with GitHub to leave a comment.