Worked examples — Directory structure — tree, DAG (hard links, symbolic links)
This page is the "put the theory to work" companion to the parent topic. We will not introduce new theory — instead we enumerate every kind of situation the topic can throw at you, then solve one worked example for each. If a symbol shows up (like the link count ), it was defined in the parent; we re-anchor it here so you never have to scroll back.
The scenario matrix
Think of the "inputs" to this topic as three knobs: which kind of link (hard / soft), what happens to the target (created / deleted / renamed), and what boundary we cross (same FS / other FS / a directory). The interesting behaviour lives at the corners.
| Cell | Case class | What's special / degenerate | Covered by |
|---|---|---|---|
| C1 | Hard link, count goes up then down, data survives | ordinary DAG case | Example 1 |
| C2 | Hard link, count reaches | degenerate: data actually freed | Example 2 |
| C3 | Open handle holds inode while | limiting case of the free rule | Example 3 |
| C4 | Symlink to a live target vs a deleted target | dangling / broken-link boundary | Example 4 |
| C5 | Symlink target renamed (not deleted) | text-vs-inode subtlety | Example 5 |
| C6 | Cross-filesystem attempt | hard fails, soft succeeds | Example 6 |
| C7 | Link to a directory | hard forbidden, soft allowed | Example 7 |
| C8 | Symlink chain / loop (limiting ELOOP) |
traversal cycle, depth cap | Example 8 |
| C9 | Real-world word problem (versioned release) | applied reasoning | Example 9 |
| C10 | Exam twist: count a mixed ls -li table |
reading link counts cold | Example 10 |
Recall The two numbers you must track in every example
Every example is really about two counters. What is the link count ? ::: the number of directory entries (dentries) pointing at an inode (on-disk). What is the open-handle count ? ::: the number of open file descriptors processes hold on the inode (in-memory). What is the exact free condition? ::: data freed .
Example 1 — Hard link, count up then down (cell C1)
Forecast: guess the final count and whether c.txt works before reading on.
The figure below tracks as a running bar chart, one bar per line of the script — watch it climb then dip.

- Line (i):
echo hi > a.txtcreates a brand-new inode (call it inode 42) with one directory entrya.txt. Why this step? A fresh file starts with exactly one name, so (first bar). - Line (ii):
ln a.txt b.txtadds a second dentry to the same inode 42. Why this step? A hard link is another name for the same inode — the parent note's rule is , so (bar rises). - Line (iii):
ln a.txt c.txtadds a third dentry. Why this step? Same rule again: , so (bar peaks). - Line (iv):
rm b.txtcallsunlink, which removes the nameb.txtand decrements: , so (bar dips, the coral step in the figure). Why this step?rmremoves a name, not data. Data survives while .
Final count . Since , the blocks are alive, so cat c.txt prints hi. ✅
Verify: Count the surviving names:
a.txt,c.txt→ exactly 2 dentries → . Matches.c.txtstill references inode 42, so the read succeeds.
Example 2 — Count hits zero, data freed (cell C2, degenerate)
Forecast: at which rm does the actual data die?
- After
echo+ln: (two names for inode 42). Why this step? Same up-count logic as Example 1. rm a.txt: . Data not freed. Why this step? The free rule is . Here , so the condition is false.rm b.txt: . Now and . Why this step? Both halves of are true, so the kernel returns the inode and its data blocks to the free list.
The data is freed only at the second rm, not the first.
Verify: Reachability argument from the parent: "any path can reach the data" "at least one dentry exists" . After both removals, zero dentries exist, so no path reaches it — freeing is correct. . ✅
Example 3 — Open handle keeps data alive at (cell C3, limiting)
Forecast: does rm free the space while the writer is still running?
The figure shows the two counters (lavender) and (mint) as timelines; the data lives until both hit the floor.

rm log.txt→unlink→ , but is still . Why this step? The last (and only) name is gone, so the dentry count drops to zero — the lavender line drops.- The writer still holds an open file descriptor → . Why this step? The full free rule is . Here , so the AND is false — data not freed (mint line still high).
- The blocks are released the instant the process closes the fd (or exits), making while is already . Why this step? Only when both references vanish does the AND become true — both lines on the floor.
Verify: Truth table of the free condition:
free? 1 1 no 0 1 no ← this example, mid-write 1 0 no 0 0 yes ← after close Only the last row frees. Matches "space reclaimed on close". ✅
Example 4 — Symlink to live vs deleted target (cell C4)
Forecast: does making the symlink change a.txt's count?
ln -s a.txt s.txtmakes a new inode 99 whose data block just stores the string"a.txt". Why this step? A symlink is a special file holding a path string — "Hard counts, Soft doesn't." Soa.txt's count stays . → answer (a) is 1.cat s.txt: kernel reads inode 99, sees it's typel, reads the text"a.txt", restarts lookup ona.txt, finds inode 42, printshi. → answer (b) ishi. Why this step? Resolving a symlink = "read the string, then re-run path lookup."rm a.txt:a.txt's count goes , , so inode 42 is freed. Nows.txtstill stores"a.txt"but that name is gone. Why this step? The symlink never touched inode 42's count, so nothing protected it.cat s.txt: lookup of"a.txt"fails →No such file or directory— a dangling link. → answer (c) is the error.
Verify: Sign check on counts: symlink creation added to the target's count (), while a hard link would have added . The dangling behaviour is exactly the "signpost points at nothing" picture. ✅
Example 5 — Symlink survives a rename only if the name matches (cell C5)
Forecast: rename isn't delete — will the signpost still find it?
sstores the literal text"report.txt". Why this step? Symlinks resolve by name, not by inode number.mvchanges the directory entry name fromreport.txttoreport_final.txt(same inode, just a new name). Why this step? The data and inode are untouched, but the name the symlink hunts for no longer exists.- Resolution of
slooks up"report.txt"→ not found →sis now dangling, even though the data is perfectly alive under a new name. Why this step? This is the sharp difference vs a hard link: a hard link points at the inode and would not care about renames.
Verify: Contrast case — if
shad been a hard link (ln report.txt s), it points at the inode;mvonly changes the other name, socat swould still print the data. Two links, two behaviours, one rename — exactly the "points at path vs points at inode" distinction. ✅
Example 6 — Cross-filesystem: hard fails, soft wins (cell C6)
Forecast: which one gives Invalid cross-device link?
- (a) hard link fails with
EXDEV: Invalid cross-device link. Why this step? A hard link stores an inode number, and inode numbers are only meaningful within one filesystem. Inode 500 on the USB and inode 500 on root are different files, so a dentry in~cannot legally name a USB inode. - (b) soft link succeeds.
Why this step? A symlink stores only a path string
"/mnt/usb/movie.mp4". Strings don't care which filesystem they cross; resolution just re-runsnamei()from that path.
Verify: Constraint table from the parent — "cross-filesystem? Hard = No, Soft = Yes." The mechanism (inode number is FS-local vs path is universal) is the why behind that table row. ✅
Example 7 — Linking to a directory (cell C7)
Forecast: why would the OS refuse one of these?
Look at the figure: on the left, a hard link from a directory back toward its ancestor closes a loop (the dashed coral arrow) — a recursive walk following the black parent-arrow down and the coral arrow up would circle forever. On the right, the butter-yellow symlink is just a text signpost into /var/log; even a loop of such signposts is cut off by the kernel's hop cap.
- (a) hard link to a directory → refused (
EPERM/Operation not permittedfor users). Why this step? As the left panel shows, every directory already has.and... If users could hard-link a directory into an ancestor, you'd create a cycle among directories, and a recursive walk (find,du, recursiverm) would loop forever. Also the link-count-as-reachability idea breaks. So the kernel bans it. - (b) symlink to a directory → allowed.
Why this step? As the right panel shows, a symlink is just text; the kernel caps symlink-chain depth (e.g.
ELOOPafter ~40 hops), so even accidental loops terminate.

Verify: The DAG rule from the parent: we forbid cycles among directories so traversal terminates. A directory hard link would inject a cycle → banned. A symlink cycle is bounded by the depth cap → allowed. Consistent. ✅
Example 8 — Symlink loop and the ELOOP limit (cell C8, limiting)
Forecast: predict the error and why it's bounded.
astores"b",bstores"a"— a two-node cycle of signposts. Why this step? Each symlink resolves to the other by name, so lookup ping-pongsa → b → a → b ….- If unbounded,
namei()would never terminate. So the kernel counts hops and aborts once the chain exceeds a fixed limit (commonly 40), returningELOOP: Too many levels of symbolic links. Why this step? This is the limiting-value safeguard: unlike hard links (which can never form a cycle by construction), symlinks can, so the depth cap is the safety net.
Verify: Model the resolver as a counter starting at 0, incrementing each hop, failing at . For the loop , the counter marches and provably crosses 40 in finite steps → terminates with
ELOOP. So it cannot hang. ✅
Example 9 — Real-world word problem: atomic release swap (cell C9)
Forecast: hard link or symlink? And why does it feel "instant"?
- Make
currenta symlink:ln -s /srv/www/v1 current. Why this step? You need to point at a directory and possibly across mounts — only a symlink can point at a directory (Example 7). It also stores a path, so re-pointing is a single text change. - To switch, atomically replace the symlink:
ln -sfn /srv/www/v2 current(the-smakes it a symlink,-foverwrites the existingcurrent,-nstops it from diving into the old directory). Equivalently,mv -T newlink current. Why this step? Replacing the symlink is one rename operation → the server sees either the old target or the new target, never a half state. Reversible: point back tov1the same way. - Count bookkeeping: the directories
v1andv2keep their own link counts unchanged (a symlink adds ). Only one inode — thecurrentsymlink — has its stored text swapped. Why this step? "Soft doesn't count," so this whole scheme leaves every real directory's untouched.
Verify: Impact tally: directory
v1count change ,v2count change , number of symlink inodes involved . Total link-count deltas across the switch . Matches "symlink creation/repoint never bumps target count." ✅
Example 10 — Exam twist: read the ls -li table cold (cell C10)
Forecast: count the inodes, not the names.
- Read the first column (inode number) and the first character of the permissions column (the type):
-= regular file,l= symlink. Why this step? Distinct data = distinct inode numbers, not distinct names; and the type char tells you whether an inode holds real data or just a path string. - Inode numbers present: 42, 99, 71. Inode 99 is a symlink (type
l) whose "data" is merely the texta.txt(size 5 = length of"a.txt"). So the distinct regular-file data blobs are inode 42 and inode 71 → 2. → (a) answer 2 (plus 1 symlink inode, which stores no file data of its own). Why this step? We must not count the symlink inode as a data blob — it's a signpost. a.txtandb.txtboth show inode 42 with link count 2 → they are two hard links sharing one data blob. → (b)a.txt⇄b.txtshare data. Why this step? Same inode number + count = two names, one inode.s.txt(inode 99, typel) is the symlink; it stores the text"a.txt". It did not contribute to inode 42's count (which is = the two hard links). Now the twist: deleting the namea.txtleaves inode 42's data alive viab.txt(count drops ), buts.txtresolves by the name"a.txt", which is now gone →s.txtdangles. So the symlink does not protect its target and is itself fragile to renames/deletes of that name. → (c)s.txtis the symlink; it does not protect its target. Why this step? Symlinks resolve by name (Example 5); "Soft doesn't count."
Verify: Cross-checks: number of inode-42 dentries in the listing → matches printed count
2. Inodes whose printed count is1:s.txt(99) andc.txt(71) → each name appears exactly once → matches. Distinct regular-file data inodes , size . All consistent. ✅
Connections
- Inodes and File Metadata
- Reference Counting and Garbage Collection
- Path Resolution and the namei() routine
- Unix system calls — link, unlink, symlink, stat
- Mount points and Virtual File System (VFS)
- Graphs — Trees vs DAGs
- File System Implementation — Block Allocation