A hard link adds another directory entry referring to an existing file. Neither name is intrinsically the original: both identify the same inode within its filesystem. File content, ownership, permissions, and link count belong to that shared object. A filename is a way to reach it, so changes to directory entries must be distinguished from changes to the file itself.
When it breaks
Treating a hard link as an independent backup creates a false recovery boundary. Writing or truncating the shared inode through either name changes what the other name reads. Changing its mode likewise changes the same object’s permissions. A second pathname can survive removal of the first, but it does not preserve an independent historical version against ordinary in-place modifications.
Many editors and deployment tools update differently: they create a replacement file and rename it over one pathname. That directory entry now reaches a new inode, while another hard link still reaches the old one. Whether both names show the update depends on the update method. The mere fact that they were linked yesterday does not establish their identity today.
Hard links also stay within the applicable filesystem mount boundary. link cannot join arbitrary files from different mounts, even when directories appear next to each other in one pathname tree. Ordinary directory hard links are prohibited on Linux. These restrictions prevent using hard links as a general replacement for symbolic links across a directory hierarchy.
Check it quickly
Save the script as hardlinks.py and run python3 hardlinks.py as root in a disposable Linux container. It requires writable /tmp and /run on different filesystems and checks that condition. The recorded environment used separate tmpfs mounts, Linux 6.12.76, and Python 3.11.2. All created entries are confined to temporary directories:
from pathlib import Path
from tempfile import TemporaryDirectory
import errno,os
with TemporaryDirectory(prefix='xbcatm-hardlinks-',dir='/tmp') as directory, TemporaryDirectory(prefix='xbcatm-crosslink-',dir='/run') as other:
root=Path(directory);source=root/'source';alias=root/'alias'
source.write_bytes(b'original');os.link(source,alias)
before=source.stat();linked=alias.stat()
assert (before.st_dev,before.st_ino)==(linked.st_dev,linked.st_ino)
assert before.st_nlink==linked.st_nlink==2 and before.st_size==linked.st_size==8
alias.write_bytes(b'changed')
assert source.read_bytes()==b'changed'
print('two names share device/inode, size, and link count; in-place write changes both views')
replacement=root/'replacement';replacement.write_bytes(b'new file');replacement.replace(source)
assert source.stat().st_ino!=alias.stat().st_ino
assert source.read_bytes()==b'new file' and alias.read_bytes()==b'changed'
assert source.stat().st_nlink==alias.stat().st_nlink==1
print('atomic replacement changes one name to a new inode; the other name retains the old file')
with alias.open('rb') as opened:
alias.unlink()
assert os.fstat(opened.fileno()).st_nlink==0 and opened.read()==b'changed'
print('last unlink removes the final name but an existing open descriptor still reads the old data')
assert root.stat().st_dev!=Path(other).stat().st_dev,'exercise requires two filesystem mounts'
try:os.link(source,Path(other)/'cross-filesystem')
except OSError as error:assert error.errno==errno.EXDEV,error
else:raise AssertionError('cross-filesystem hard link unexpectedly succeeded')
child=root/'directory';child.mkdir()
try:os.link(child,root/'directory-link')
except OSError as error:assert error.errno==errno.EPERM,error
else:raise AssertionError('directory hard link unexpectedly succeeded')
print('cross-filesystem link fails with EXDEV; directory hard link fails with EPERM on Linux')
The fixture creates source, writes eight bytes, and adds alias with os.link. Both names report identical device/inode pairs, size eight, and link count two. Comparing the device as well as the inode matters because inode numbers are not globally unique across filesystems. Matching file contents alone would also be insufficient evidence of shared identity.
Writing changed through alias updates the existing inode, so reading source returns the same changed bytes. The operation uses an ordinary truncating write, not replacement. This establishes why the second name cannot serve as a separate copy protecting the prior contents from a writer with access to the shared file.
Next, a separate file containing new file replaces source by rename. The two names now have different inode numbers and each has link count one. source reads the replacement, while alias retains changed. Replacement affects the selected directory entry; it neither searches for other names nor rewrites every hard link to the old inode.
The fixture opens alias and removes its final directory entry. File status through the open descriptor reports link count zero, yet that descriptor still reads changed. The open reference preserves access until it is closed. A zero link count means no hard-link names remain, not that every process has immediately lost its existing reference.
Finally, linking into the second filesystem fails with EXDEV, and attempting to hard-link a directory fails with EPERM. The script checks the specific errors. If its mount precondition is absent, it fails explicitly rather than reporting that a cross-filesystem operation was tested when both directories actually used the same filesystem.
Inspect identity and update behavior
Before relying on shared storage, inspect the device/inode pair and link count of the actual paths. Link count tells how many hard-link directory entries refer to the inode; it does not list where those names are. Traversing directories to find matching inodes is a separate operation, affected by permissions and changes happening during the search.
For snapshot-like directory trees built with hard links, ensure updates replace files instead of modifying shared inodes, and control all writers that can reach them. Filesystem snapshots and independent copies have different contracts. A space-saving tree of links should be described by its actual update and retention behavior rather than simply being called a backup.
Unlinking and renaming concern namespace visibility. This experiment verifies identity and open-reference behavior, not persistence across power loss. If durable publication matters, separately verify the filesystem’s synchronization requirements and the application’s recovery procedure. Keeping another name or observing an atomic rename does not by itself prove the intended data will survive a crash.