Most admins learn links as a one-liner: ln -s makes a shortcut, plain ln makes "a copy that isn't a copy". That is enough until a deploy script nests a symlink inside the directory it was meant to replace, a backup restores twelve copies of the same 4 GB file, or sed -i silently splits a hard link in two.
The fix for all of these is the same: understand what each kind of link actually is on disk. Once you think in inodes and directory entries instead of "files", the behaviour of rm, cp, tar, rsync and your editor stops being surprising.
Inodes, Names and the Link Count
On ext4, XFS and Btrfs, a file's data and metadata (owner, mode, timestamps, block pointers) live in an inode. A directory is just a table mapping names to inode numbers. A hard link is a second entry in that table pointing at the same inode. A symbolic link is a separate, tiny inode whose content is a path string.
$ echo hello > file.txt
$ ln file.txt hard.txt
$ ln -s file.txt soft.txt
$ ls -li
total 8
1835012 -rw-r--r-- 2 alice alice 6 Oct 2 10:14 file.txt
1835012 -rw-r--r-- 2 alice alice 6 Oct 2 10:14 hard.txt
1835019 lrwxrwxrwx 1 alice alice 8 Oct 2 10:14 soft.txt -> file.txt
$ stat -c '%i links=%h %F %N' file.txt hard.txt soft.txt
1835012 links=2 regular file 'file.txt'
1835012 links=2 regular file 'hard.txt'
1835019 links=1 symbolic link 'soft.txt' -> 'file.txt'
Three things to read from that output. file.txt and hard.txt share inode 1835012 and a link count of 2: neither is the "original", they are two equal names. The symlink has its own inode, type l, and a size of 8 bytes, which is the length of the string file.txt. The kernel frees the data only when the link count reaches zero and no process still has the file open.
| Property | Hard link | Symbolic link |
|---|---|---|
| Own inode | No, shares the target's inode | Yes, type l |
| Crosses filesystems | No (EXDEV) | Yes, it is just a path |
| Can point at a directory | No | Yes |
| Target deleted | Data survives under the other names | Link dangles, access gives ENOENT |
| Permissions | Shared with every other name | Always lrwxrwxrwx; the target's mode applies |
| Size reported by ls -l | Size of the file | Length of the target path |
| Relative target resolved from | Not applicable | The directory containing the link |
| How to detect | stat -c %h | test -L or find -type l |
Symlink Paths: Relative, Absolute, Dangling
The single most common symlink bug is a relative target written as if it were resolved from your current directory. It is not: the kernel resolves a relative target from the directory the link lives in.
$ cd /srv/app
$ ln -s shared/config.yml releases/v2/config.yml # wrong: looks for releases/v2/shared/config.yml
$ ls -l releases/v2/config.yml
lrwxrwxrwx 1 deploy deploy 17 Oct 2 10:20 releases/v2/config.yml -> shared/config.yml
$ cat releases/v2/config.yml
cat: releases/v2/config.yml: No such file or directory
# GNU ln can compute the relative path for you
$ ln -sfr shared/config.yml releases/v2/config.yml
$ readlink releases/v2/config.yml
../../shared/config.yml
# readlink prints the stored string; -f and realpath resolve the whole chain
$ readlink -f releases/v2/config.yml
/srv/app/shared/config.yml
$ realpath releases/v2/config.yml
/srv/app/shared/config.yml
Absolute targets survive moving the link but break when the tree is relocated, mounted elsewhere, or copied into a container image at a different prefix. Relative targets survive relocating the whole tree. For anything that ships as a unit (a release directory, a chroot, an image layer), prefer relative links made with ln -sr.
# Find dangling symlinks (GNU find): -xtype l matches links whose target is missing
$ find /etc /usr/local -xtype l
/etc/nginx/sites-enabled/old-site.conf
# Portable version for BSD/macOS or busybox find
$ find /etc -type l ! -exec test -e {} \; -print
# Show every hop of a symlink chain
$ namei -l /usr/bin/python3
f: /usr/bin/python3
drwxr-xr-x root root /
drwxr-xr-x root root usr
drwxr-xr-x root root bin
lrwxrwxrwx root root python3 -> python3.12
-rwxr-xr-x root root python3.12
Loops are detected, not prevented β a link pointing at itself, or two links pointing at each other, is legal to create. Opening it fails with ELOOP ("Too many levels of symbolic links") once the kernel hits its 40-hop limit.
namei is in util-linux β it is installed by default on Debian, Ubuntu, RHEL and Fedora, and the -l flag shows owner and mode for every component, which also helps when a permission error is really a parent directory.
Broken links are not always garbage β a link into an unmounted filesystem, or a systemd unit masked to /dev/null on a minimal container, can look broken from one context and be fine in another. Check before deleting in bulk.
Hard Link Rules and Kernel Protections
Hard links have two hard limits. They cannot span filesystems, because an inode number only means something inside one filesystem, and they cannot point at directories, because that would allow cycles in the tree that fsck and every recursive tool assume cannot exist.
$ ln /var/log/syslog /home/alice/syslog
ln: failed to create hard link '/home/alice/syslog' => '/var/log/syslog': Invalid cross-device link
$ ln /etc /tmp/etc-link
ln: /etc: hard link not allowed for directory
# Find every name for the same inode on one filesystem
$ find / -xdev -samefile /srv/data/report.csv 2>/dev/null
/srv/data/report.csv
/backup/2026-10-01/report.csv
/backup/2026-10-02/report.csv
# Or by inode number
$ find /srv -xdev -inum 1835012
Directories do have a link count, it just isn't created with ln. On ext4 and XFS a directory starts at 2 (its entry in the parent plus its own .) and gains one for each subdirectory's ... That is why ls -ld on a directory with three subdirectories shows 5.
Linux also restricts links in shared directories, controlled by two sysctls that are on by default in every mainstream distribution:
| Sysctl | Default | Effect |
|---|---|---|
fs.protected_hardlinks | 1 | You can only hard-link a file you own, or one you can both read and write. Blocks linking a setuid binary or /etc/shadow into your home directory. |
fs.protected_symlinks | 1 | In sticky world-writable directories like /tmp, a symlink is only followed if its owner matches the follower or the directory owner. Blocks classic /tmp symlink races. |
fs.protected_regular | 1 (set by systemd) | Restricts O_CREAT opens of existing files you do not own in sticky directories. Related, not link-specific. |
Check them β sysctl fs.protected_hardlinks fs.protected_symlinks. If either reads 0 on a multi-user host, something turned it off, and it belongs on your hardening checklist.
Permissions are shared β because every name shares one inode, chmod through any name changes them all. See our file permissions guide for how mode bits are evaluated.
The Gotchas: ln -sfn, sed -i, rm and Editors
The bugs that actually cause incidents are less about the links themselves and more about tools that quietly follow, replace or ignore them.
# Gotcha 1: ln -sf onto a symlink that points to a directory
$ ls -l current
lrwxrwxrwx 1 deploy deploy 11 Oct 1 09:00 current -> releases/v1
$ ln -sf releases/v2 current
$ ls -l current releases/v1/v2
lrwxrwxrwx 1 deploy deploy 11 Oct 1 09:00 current -> releases/v1
lrwxrwxrwx 1 deploy deploy 11 Oct 2 10:30 releases/v1/v2 -> releases/v2
# ln followed 'current' into v1 and created a new link inside it.
# -n (--no-dereference) treats the existing link as a plain file:
$ ln -sfn releases/v2 current
# Even better for zero-downtime deploys: build the new link, then rename over the old one.
# rename(2) is atomic, so readers never see a missing 'current'.
$ ln -s releases/v2 current.tmp && mv -T current.tmp current
# Gotcha 2: sed -i writes a new file and renames it into place
$ ln app.conf app-hardlink.conf
$ sed -i 's/debug=true/debug=false/' app.conf
$ stat -c '%i %h %n' app.conf app-hardlink.conf
1835101 1 app.conf
1835044 1 app-hardlink.conf # the hard link now holds the OLD content
# Against a symlink, GNU sed -i replaces the link with a regular file unless told otherwise
$ sed -i --follow-symlinks 's/a/b/' /etc/nginx/sites-enabled/site.conf
| Command | What happens with links |
|---|---|
rm link | Removes the symlink only. The target is untouched. |
rm -r dirlink/ | The trailing slash resolves through the link, so rm descends into the target directory. Drop the slash or use unlink. |
chmod 600 link | Changes the target. Linux has no way to change a symlink's own mode. |
chown -h user link | Changes the symlink's owner. Without -h, chown changes the target. |
ls -l / ls -lL | -l shows the link; -L shows the target's metadata instead. |
find -L | Follows symlinks while walking. Without it, find reports links as type l and does not descend. |
Editors vary β vim's backupcopy option and many GUI editors write a temp file and rename it, which breaks hard links the same way sed -i does. If a file must stay hard-linked, edit it in place with something that truncates and rewrites, or redirect with cat new > file.
Config management β Ansible's template and copy modules and most package managers also replace files by rename. Treat hard links to config files as fragile and prefer symlinks there.
cp, tar and rsync: Preserving or Following Links
Copy and archive tools each have their own defaults, and the defaults differ. Getting them wrong either loses links (a restore that is several times larger than the source) or follows them (a backup that pulls in half of /usr through a single link).
| Tool | Symlinks by default | Hard links by default | Flags to change it |
|---|---|---|---|
| cp file | Follows a symlink given as source | Copies data, link lost | -P keep link, -l make hard link instead |
| cp -r | Copies symlinks as symlinks | Each name copied separately | -L follow, -a preserve both |
| cp -a | Preserved | Preserved within the copied set | Implies -dR --preserve=all |
| tar (GNU) | Stored as symlinks | Stored once, as links in the archive | -h / --dereference follows symlinks; --hard-dereference stores copies |
| rsync -a | Preserved (-l is in -a) | NOT preserved | -H preserve hard links, -L follow symlinks |
| du | Not followed | Counted once per invocation | -L follow, -l count every name |
# Snapshot backups: unchanged files become hard links to yesterday's copy,
# so each daily directory looks complete but only changed files use new space
$ rsync -aH --delete --link-dest=/backup/2026-10-01 /srv/data/ /backup/2026-10-02/
$ du -sh /backup/2026-10-01 /backup/2026-10-02
48G /backup/2026-10-01
1.2G /backup/2026-10-02 # du counts shared inodes once, in the first dir it sees
# Moving that backup tree elsewhere? Without -H every snapshot becomes a full copy.
$ rsync -aH /backup/ newhost:/backup/
rsync -H costs memory β rsync has to track every multiply-linked inode to rebuild the links. On trees with millions of hard links, expect noticeably higher memory use on both sides.
Hard links are not backups β a hard link shares the data, so corruption, ransomware or an accidental truncate through one name hits every name. Snapshot schemes work because each day's changed files are new inodes, not because links protect anything.
Measuring real usage β when du totals look wrong after a restore, compare du -sh on the parent against the sum of children. Our disk usage analyzer helps when the numbers still do not add up.
When to Use Which
In practice, symlinks are the default and hard links are a specialist tool. Symlinks are visible, work across mounts, can point at directories, and make intent obvious in ls -l. Hard links are invisible by design, which is exactly what you want for deduplication and exactly what you do not want for configuration.
| Use case | Pick | Real example |
|---|---|---|
| Switch the active release atomically | Symlink | /srv/app/current -> releases/v42 |
| Enable a site or unit | Symlink | nginx sites-enabled, systemd *.wants/ directories |
| Choose between installed versions | Symlink | /etc/alternatives, /usr/bin/python3 -> python3.12 |
| Point into another filesystem | Symlink | /var/lib/docker -> /data/docker |
| Space-efficient daily snapshots | Hard link | rsync --link-dest, rsnapshot |
| Deduplicate identical files | Hard link | hardlink (util-linux), local git clone object store |
| Keep a file alive after the original name is removed | Hard link | Staging a large log for upload while logrotate moves on |
Two last notes. git clone /path/to/repo on the same filesystem hard-links the object files rather than copying them, which is why local clones are instant. And moving /var/lib/docker with a symlink works, but a bind mount or the daemon's data-root setting is cleaner, as covered in our Docker for Linux admins guide. For a quick reference to ln, stat and find flags, see the Linux commands reference.
Frequently Asked Questions
What is the difference between a hard link and a symbolic link?
A hard link is an additional directory entry pointing at the same inode, so it is indistinguishable from the original name and keeps the data alive if the original is deleted. A symbolic link is a separate small file containing a path; if that path stops existing, the symlink dangles. Symlinks can cross filesystems and point at directories, hard links cannot.
Why does ln -sf not replace my symlink to a directory?
Because ln dereferences the existing link and treats it as the destination directory, so it creates the new link inside the target directory. Add -n (--no-dereference) so the existing symlink is treated as a plain file and replaced: ln -sfn new_target link. For zero-downtime switches, create a temporary link and rename it over the old one with mv -T.
How do I find broken symlinks on Linux?
With GNU find, run find /path -xtype l, which matches symlinks whose target does not exist. On systems without -xtype, use find /path -type l ! -exec test -e {} \; -print. Review the list before deleting, since links into unmounted filesystems can look broken temporarily.
Does rsync -a preserve hard links?
No. The -a flag preserves symlinks, permissions, times, owners and groups, but not hard links. Add -H to preserve them. This matters most when moving snapshot backups made with --link-dest, which otherwise expand into full copies at the destination.
Why can't I create a hard link to a directory?
Allowing it would let the directory tree contain cycles, and fsck, find, du, rm -r and every other recursive tool assume it cannot. The kernel only creates directory links itself, for the . and .. entries. Use a symlink or a bind mount when you need a directory to appear in two places.