ln: Hard Links vs Symbolic Links, and Avoiding Circular Symlinks

1 min readSystems & Networking

Hard links create a second directory entry pointing to the same inode — deleting either leaves the data accessible. Symbolic links store a path string to the target. Circular symlinks occur when a relative path resolves to itself: ln -s test static/test creates a symlink in static/ that points to static/test, not to the test in the current directory.

shellsymlinkfilesystem

Hard links vs symbolic links

# Hard link: second name for the same inode
ln original.txt hardlink.txt

# Symbolic link: stores a path to the target
ln -s original.txt symlink.txt
ln -s /absolute/path/target.txt symlink.txt

| Property | Hard Link | Symbolic Link | |---|---|---| | Stores | Reference to inode | Path string to target | | Cross-filesystem | No — same filesystem only | Yes | | Works on directories | No (usually restricted) | Yes | | Broken if target deleted | No — data persists | Yes — dangling symlink | | ls -l appearance | same as regular file | link -> target | | inode number | Same as original | Different inode |

# Check: both hard links share the same inode number
ls -li original.txt hardlink.txt
# 123456 -rw-r--r-- 2 user group 100 ... original.txt
# 123456 -rw-r--r-- 2 user group 100 ... hardlink.txt
# ^^ same inode, link count = 2

# Symlink: different inode, shows target
ls -li original.txt symlink.txt
# 123456 -rw-r--r-- 1 user group 100 ... original.txt
# 789012 lrwxrwxrwx 1 user group  12 ... symlink.txt -> original.txt

The circular symlink problem

The ln -s command uses the symlink path to resolve the target — not the current working directory.

# Intent: create static/test as a symlink to ./test
ln -s test static/test

# What actually happens:
# The symlink static/test is created pointing to the path "test"
# When resolved from static/: static/ + "test" = static/test
# The symlink points to itself → circular reference

Error when accessing: Too many levels of symbolic links

The fix: use a path relative to the symlink's location, or use an absolute path:

# Correct: relative path from symlink's directory
ln -s ../test static/test
# static/test → ../test → resolves from static/ → ./test ✓

# Or absolute path
ln -s /full/path/to/test static/test

Relative symlinks are resolved from the symlink's directory, not the current working directory

GotchaShell

When you create ln -s foo bar/baz, the symlink bar/baz stores the path 'foo'. When the OS follows bar/baz, it resolves 'foo' relative to bar/ — looking for bar/foo. This is not a bug; it's the correct behavior for relative symlinks. A symlink is a portable reference — it should resolve correctly wherever the symlink file lives, not relative to where you created it. This is why version managers (rbenv, nvm, pyenv) use absolute paths in their shims.

Prerequisites

  • File system inodes
  • Path resolution
  • Unix symlinks

Key Points

  • Relative symlink paths are resolved relative to the directory containing the symlink.
  • Absolute symlinks always resolve from root — safe but not portable if files move.
  • Use readlink -f to resolve the final target of a symlink chain.
  • find . -type l -xtype l finds broken symlinks (symlink with no valid target).
# Verify symlink targets
ls -la static/test          # shows what the symlink points to
readlink static/test        # shows the stored path string
readlink -f static/test     # resolves the full absolute path

# Find broken symlinks in a directory
find . -type l -xtype l     # symlinks pointing to nonexistent targets

# Fix: remove and recreate with correct relative path
rm static/test
ln -s ../test static/test

You run `ln -s config static/config` from your project root. static/config exists but accessing it gives 'Too many levels of symbolic links'. Why?

medium

The symlink static/config stores the path 'config'. The OS resolves the symlink from within the static/ directory.

  • AThere's already a config file in the project root with the same name causing conflict
    Incorrect.ln doesn't conflict on the source path. The issue is in how the relative path 'config' resolves when the OS follows the symlink.
  • BThe symlink static/config stores the path 'config'. When the OS follows it, it looks for 'config' relative to static/ — finding static/config, which is the symlink itself — creating an infinite loop.
    Correct!Relative symlink paths resolve from the symlink's directory, not from where ln was run. static/config stores 'config'. Following static/config → resolve 'config' from static/ → find static/config → follow static/config → resolve 'config' from static/ → infinite loop. Fix: `ln -s ../config static/config` — the path '../config' from static/ resolves to the project root's config file.
  • Cln -s requires an absolute path for the source
    Incorrect.ln -s works with both relative and absolute paths. Relative paths are valid; the issue is which directory they're relative to.
  • DThe static/ directory has permissions that prevent symlink creation
    Incorrect.A permission error would give 'Permission denied', not 'Too many levels of symbolic links'. The circular reference causes the loop error.

Hint:From which directory is the relative path 'config' resolved when the OS follows static/config?