JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

The Filesystem, Inodes, and Paths

You can already open(), read(), write(), and close() a file by name. But what is a name, really, and what does it point at? This guide opens up the filesystem: the inode that is the file's true identity, the directory that is just a table of names, and the path your program hands to open() — plus why hard links, symlinks, and rename() behave the way they do.

A name is not the file

In the last three guides you treated a file as something you reach through a file descriptor: you called open() with a name like "/home/jo/notes.txt", got back a small integer, and read and wrote through it. The name went in, a working file came out, and the machinery in between stayed hidden. This guide pulls that machinery into the light, and the first surprising fact is the one to hold onto: the name is not the file. The text "/home/jo/notes.txt" is a label, a way to find the file; the file itself — its bytes, its size, who owns it, when it was last changed — lives somewhere else entirely, and the name merely points at it.

That separation is the whole design of a Unix filesystem, and it falls into two pieces. The file's real, nameless identity is a structure called an inode (index node), which holds every fact about the file except its name. The names live separately, in directories, which are nothing more than tables mapping a name to an inode. So a path is a chain of lookups, not a thing in itself: "/home/jo/notes.txt" says start at the root, find "home" in it, find "jo" in that, find "notes.txt" in that — and only at the end of that walk do you arrive at the inode that is the actual file. Once you see a name and a file as two separate things joined by a directory entry, almost every odd corner of Unix files stops being odd.

The inode: a file's true identity

An inode is a small fixed-size record the filesystem keeps for every file. It stores the file's metadata — its size in bytes, its type (regular file, directory, device, and so on), the owner and group, the permission bits, the three timestamps, a link count we will need shortly — and, most importantly, the locations of the data blocks on disk where the actual bytes live. What an inode deliberately does not store is the file's name. The name is the one fact about a file that lives outside it, up in some directory. Every inode on a filesystem has a unique number, the inode number, and that number — not the path — is the file's genuine identity. You can see it with "ls -i" or "stat".

This is why a path can change while the file does not. Renaming "notes.txt" to "draft.txt" does not touch the inode at all — it only edits a directory entry, swapping one name for another over the same inode number. The bytes never move; the file's identity is untouched; you have merely relabelled the door. The same logic explains a subtle thing from the earlier guides: when you open() a file you get a descriptor that refers to the open file (and through it the inode), not to the name. So if another program renames or even deletes the path while you hold the descriptor open, your reads and writes keep working on the very same inode, because your handle was never the name to begin with.

$ stat -c '%i  %s bytes  links=%h  %n' notes.txt
  1310726  482 bytes  links=1  notes.txt

  path  -->  directory entry  -->  inode #1310726
  "notes.txt"     (name -> number)     size, owner, perms,
                                       timestamps, link count,
                                       block locations  ... NO name
The directory maps the name "notes.txt" to inode number 1310726; the inode holds everything about the file except that name.

Directories and how a path is walked

If names live in directories, what exactly is a directory? Under the everything-is-a-file idea, a directory is itself just a file with its own inode — but a special kind whose contents the kernel interprets as a table of entries, each pairing a name with an inode number. That is all a directory is: a list of (name, inode number) pairs. "home", "jo", "notes.txt" are entries in three different such tables. Because a directory only stores names and numbers, the actual files can be huge and the directory stays tiny; and because the names live in the directory rather than in the files, a single inode can be reached from more than one directory entry — the hook the next section hangs on.

Now the path walk becomes mechanical. When you open() an absolute path like "/home/jo/notes.txt", the kernel starts at the root directory (inode of "/"), looks up "home" in it to get the next inode number, reads that directory, looks up "jo", reads that directory, looks up "notes.txt", and lands on the final inode — the file you wanted. A relative path like "jo/notes.txt" works identically except that the walk starts at your process's current working directory instead of the root. Each slash is one lookup step, and ".." is simply an entry every directory keeps that points back to its parent's inode. This is why a path with many components costs more lookups than a short one, and why a typo deep in a path fails precisely at the component the kernel could not find.

Hard links, symlinks, and why deleting is really 'unlink'

Since a directory entry is just a name pointing at an inode number, nothing stops two entries — even in different directories — from pointing at the same inode number. That is a hard link: two names that are not copies but the genuinely identical file, sharing one inode, one set of bytes, one identity. Edit through either name and the change is visible through the other, because there is only one file. This is exactly why the inode carries a link count: it tallies how many directory entries point at it. Create a second hard link and the count goes from 1 to 2; the file does not exist twice, it is simply named twice.

This reframes what "deleting a file" means, and the truth is more honest than the word delete suggests. The syscall is called unlink(), and it does exactly what it says: it removes one name-to-inode entry from a directory and decrements the inode's link count. The file's bytes are freed only when the count reaches zero — that is, when the last name is gone and no process still holds it open. So "rm notes.txt" does not necessarily erase any data; if a hard link elsewhere still names that inode, the file lives on under the other name. And a file you unlink while a program still has it open survives, invisibly nameless, until that program closes it — a real and useful trick for temporary files.

A symbolic link (symlink) is a different animal, and the contrast is the whole point. A symlink is its own tiny file whose contents are a path string — it does not point at an inode number, it points at a name, and the kernel re-resolves that name every time the symlink is followed. So a symlink can cross filesystems and can point at a directory, neither of which a hard link can do; but it can also become a dangling link if its target is renamed or deleted, pointing at a name that no longer resolves to anything. A hard link can never dangle, because it is a co-owner of the inode itself. Hard link: a second true name. Symlink: a signpost holding a path that may or may not still lead somewhere.

What this buys you, and the honest limits

Standing back, the name-versus-inode split is what makes a pile of everyday operations cheap and atomic. rename() can move a name across directories on the same filesystem just by editing two directory entries — no bytes copied — which is why renaming a 1 GiB file is instant. It is also why the safe way to update a config file is to write a fresh file and then rename() it over the old name: rename() on one filesystem is atomic, so a reader either sees the entire old file or the entire new one, never a half-written mess. None of these tricks would be possible if a file were its name; they all rely on the name and the bytes being separable.

Now the honest caveats, because a tidy model invites overreach. First, every boundary here is per filesystem: inode numbers are unique only within one filesystem, hard links cannot cross from one filesystem to another, and rename()'s cheap-and-atomic guarantee evaporates the moment the source and destination live on different mounts — there the move becomes a real copy-then-delete that can fail halfway. Second, open()'s flags and mode act on the directory entry and inode, not on the path text: O_CREAT makes a new name-and-inode if none exists, and the mode bits you pass set the new inode's permissions, ignored entirely when the file already exists. The mental model is powerful, but it is filesystem-shaped, and its guarantees stop at the filesystem's edge.