A symlink is just a path, and that explains everything
Why upgrading Python leaves two versions behind, why your venv breaks, why ln -sfn is safe on Linux but not on a Mac, and twelve more things a symlink can do to you. Every claim comes with output you can reproduce.
I upgraded Python on my Mac and looked at what changed:
/opt/homebrew/bin/python3 -> ../Cellar/python@3.14/3.14.7/bin/python3
/opt/homebrew/bin/python3.11 -> ../Cellar/python@3.11/3.11.16/bin/python3.11
/opt/homebrew/bin/python3.14 -> ../Cellar/python@3.14/3.14.7/bin/python3.14
Both versions are still installed, each in its own folder. The upgrade moved one arrow. python3 used to point at 3.11, and now it points at 3.14.
That arrow is a symlink. I use them every day without thinking, so I went and tested what they actually do. Every output below comes from a script you can run yourself (links at the end).
A symlink is a tiny file that holds a path#
Not the data. Not a pointer to the data. Just a string.
$ ln -s /usr/lib/python3/dist-packages pkgs
$ stat -c '%F size=%s' pkgs
symbolic link size=30
The size is 30 bytes because /usr/lib/python3/dist-packages is 30 characters long. When a program opens pkgs, the kernel reads that string and continues the lookup from there.
Hold on to that idea. Everything else in this post follows from it.
Short paths don’t even get their own block#
On ext4, a target shorter than 60 bytes fits inside the inode itself, in the space that would normally list data blocks. This is a “fast symlink”. A longer target needs a real data block.
short (5 chars): Blockcount: 0 Fast link dest: "short"
long (70 chars): Blockcount: 2 EXTENTS:
So resolving a short link costs no extra disk read. A filesystem detail, but a nice one.
Why upgrades use symlinks#
Every tool that ships many versions does the same thing as Homebrew’s Python:
/opt/homebrew/opt/node -> ../Cellar/node/26.11.1node_modules/.bin/astro -> ../astro/bin/astro.mjs(this is hownpxfinds tools)- On macOS,
/etc -> private/etc - Let’s Encrypt keeps every certificate in
archive/and pointslive/at the newest one - Debian runs it in two hops:
/usr/bin/awk -> /etc/alternatives/awk -> /usr/bin/mawk
Install the new version next to the old one, then move one arrow. Rolling back means moving the arrow back. Nothing gets copied or overwritten.
The two Python traps#
Trap one: your package “disappeared”. Each Python version has its own site-packages. You ran pip install requests last month under 3.11. Today python3 means 3.14, which has never heard of it. Nothing was deleted. You’re talking to a different interpreter.
Trap two: your venv breaks after a cleanup. A venv is mostly symlinks:
venv/bin/python -> python3
venv/bin/python3 -> /usr/bin/python3
venv/bin/python3.13 -> python3
Python also uses its own symlink to find its standard library. Start it through a link and it follows the chain to the real binary:
$ ln -s /usr/bin/python3 /tmp/py
$ /tmp/py -c '...'
executable /tmp/py | real /usr/bin/python3.13 | prefix /usr
Homebrew is careful here. A venv made with its Python 3.14 links through a stable path, not the exact version folder:
python3.14 -> /opt/homebrew/opt/python@3.14/bin/python3.14
𝜋thon -> python3.14
(Yes, Python 3.14 venvs ship a 𝜋thon link. Someone had fun.)
opt/python@3.14 is itself a symlink that moves to each new 3.14.x patch release, so patch upgrades don’t break the venv. Removing the minor version does. Run brew uninstall python@3.11 and every venv built on 3.11 now points at nothing.
A link can point at nothing#
Nobody checks that the target exists, not when you create the link and not later.
$ ln -s nowhere dang
$ cat dang
cat: dang: No such file or directory
$ [ -L dang ] && echo true # is it a link?
true
$ [ -e dang ] || echo false # does it exist?
false
ls shows dang happily, but -e says it doesn’t exist, because -e follows the link. A cleanup script that tests with -e will walk right past every broken link.
Relative links resolve from their own folder#
A relative target is resolved from the folder the link lives in, not from where you are.
$ ln -s file a/rel && cat a/rel
data
$ mv a/rel b/rel && cat b/rel
cat: b/rel: No such file or directory
Homebrew writes ../Cellar/... on purpose. The whole tree can move to a new place and every link still works, because the links and their targets move together.
Forty hops, then the kernel gives up#
The kernel follows links for you, but not forever.
$ cat t40 # a chain of 40 links
hi
$ cat t41 # one more
cat: t41: Too many levels of symbolic links
That error (ELOOP) usually means a loop like a -> b -> a. But it’s really a hop counter. Linux stops at 40, loop or not.
A link’s permissions mean nothing#
Every symlink shows lrwxrwxrwx, and the kernel ignores those bits. Run chmod on a link and it changes the target instead:
$ chmod 600 secret
$ chmod 644 sl # sl -> secret
$ stat -c '%n %A' sl secret
sl lrwxrwxrwx
secret -rw-r--r--
That is a quiet way to make a private key readable by everyone.
Hard links are a different animal#
A hard link isn’t a path. It’s a second name for the same inode, and an inode is where the file’s data and metadata actually live.
o inode=268728148 links=2
hard inode=268728148 links=2
soft inode=268728149 links=1
$ rm o
$ cat hard
orig
$ cat soft
cat: soft: No such file or directory
rm never deletes data. It removes a name. The data goes away when the last name is gone and no process has the file open. The symlink broke because it only knew the name o.
The deploy trick, and the Mac that breaks it#
A common deploy pattern is current -> releases/2026-10-16. You ship a new release by pointing current at the new folder. The question is whether there is ever a moment when current doesn’t exist.
I assumed ln -sfn deletes the old link and then creates the new one, leaving a gap. For GNU ln I was wrong. Here is what it actually does, traced with strace:
symlinkat("r2", AT_FDCWD, "current") = -1 EEXIST (File exists)
symlinkat("r2", AT_FDCWD, "Cu4oI2aw") = 0
renameat(AT_FDCWD, "Cu4oI2aw", AT_FDCWD, "current") = 0
It creates the new link under a random name and then renames it over the old one. rename is atomic: anyone looking sees the old link or the new one, never nothing.
So I raced it. One loop swapped the link 6,000 times while another checked whether it existed:
GNU coreutils 9.7 (Debian): checks=215476 link-missing=0
macOS 27.2 /bin/ln: checks=640830 link-missing=10764
On the Mac, about 1 read in 60 found no link at all. BSD ln really does delete first and create second. The same command is safe on one system and racy on the other.
If you need the safe version everywhere, do the rename yourself: create the link under a temporary name, then mv it over the old one. Newer GNU coreutils also has mv --exchange, which swaps two paths in one step:
$ mv --exchange x y
x=B y=A
Your shell lies about ..#
Your shell remembers the path you typed. The kernel only knows where you actually are.
$ ln -s real/deep shortcut
$ cd shortcut
pwd=/tmp/lab/shortcut pwd -P=/tmp/lab/real/deep
cd shortcut/.. lands in /tmp/lab
realpath shortcut/.. is /tmp/lab/real
cd .. takes you back the way you came, because the shell trims the path it remembers. But any program that opens shortcut/.. gets the real parent, /tmp/lab/real. So cd .. and ls .. can show you two different folders. pwd -P shows the truth.
/proc is full of links that shouldn’t work#
Linux exposes every process under /proc, and some entries look like symlinks but behave like handles. They are called magic links.
Delete a binary while it’s running, and the process still points at it:
$ readlink /proc/$PID/exe
/tmp/mysleep (deleted)
$ cp /proc/$PID/exe /tmp/recovered && cmp /bin/sleep /tmp/recovered
deleted binary recovered byte for byte
Same for an open file:
$ exec 3<f && rm f
$ readlink /proc/$$/fd/3
/tmp/lab/f (deleted)
$ cat /proc/$$/fd/3
gone
The file is gone from every folder, but the data lives while something holds it open. This is also why deleting a huge log on a full disk often frees nothing: the server still has it open. lsof +L1 lists those files.
Links are an attack surface#
/tmp is writable by everyone. Years ago, the classic attack was to guess the name a root process would write to in /tmp and plant a symlink there pointing at /etc/passwd. Root follows the link and overwrites the real file.
Linux now blocks this by default:
$ cat /proc/sys/fs/protected_symlinks
1
With this setting, the kernel refuses to follow a link in a world-writable sticky folder unless you own the link, or the link’s owner also owns the folder.
What I took from it#
A symlink is a stored path that the kernel follows on your behalf. Once you see it that way, the rest makes sense:
- upgrades are an arrow moving
- broken venvs are arrows pointing at deleted folders
rmremoves names, not data..depends on who is asking- “atomic” depends on which
lnyou run
Try it yourself#
- symlink-lab.sh runs every Linux example above. Run it in a throwaway container:
docker run --rm -v "$PWD/symlink-lab.sh:/lab.sh:ro" debian:trixie-slim bash /lab.sh - ln-race.sh is the
ln -sfnrace. Run it in an empty folder on your Mac, then in the container, and compare.