Inodes and File Metadata

Imagine a file system that stores a video's name, permissions, and size inside every directory entry that refers to it. Renaming would be cheap. But hard-linking the file twice would mean two copies of its size and modification time. These copies could drift out of sync. Real file systems avoid this duplication with a single, elegant device: the inode (index node). This is a fixed-format record that holds everything about a file except its name. The inode is the atomic unit of "fileness" in Unix-derived file systems (ext2/3/4, UFS, XFS conceptually, though not NTFS or FAT which use different metadata schemes). It stores ownership, permissions, timestamps, size, and — critically — the pointers that locate the file's data blocks on disk. Directories, by contrast, store only a mapping from human-readable names to inode numbers. This separation of naming from metadata allows multiple names (hard links) to refer to the same underlying file. It also allows renaming without touching file content. And it allows the operating system to reason about files using a small, fixed-size structure, regardless of how large the file's data actually is. This article explains the internal shape of an inode. It covers the classic direct/single-indirect/double-indirect/triple-indirect block-pointer scheme. This scheme lets a small, fixed-size inode address files ranging from a few bytes to many gigabytes. It also explains the path-to-inode resolution algorithm that every `open()` call performs. We derive the maximum file size supported by a given pointer layout. We walk through a concrete trace of a multi-level lookup. And we examine why this design was such a durable engineering compromise. That compromise balances simplicity, bounded metadata size, and support for enormous files. Understanding inodes is a prerequisite for reasoning about Allocation Methods for File Storage (which decides how those block pointers get chosen). It is also a prerequisite for Protection and Access Control (which lives inside the inode as permission bits). It builds directly on File-System Implementation and Layout and Directory Structure. It connects the abstract idea of "a file" to the concrete on-disk bytes that represent it.

Key takeaways
  • An inode is a fixed-size, per-file metadata record: owner, permissions, timestamps, size, link count, and block pointers — but not the file's name.
  • Directories map names → inode numbers; this indirection is what makes hard links, renames, and metadata sharing possible.
  • Classic Unix inodes use direct pointers for small files plus single/double/triple indirect pointers to address large files without inflating inode size.
  • Path resolution (`/usr/bin/ls`) is an iterative walk: read directory inode, find name, get next inode number, repeat.
  • Maximum addressable file size is a closed-form function of block size and pointer count, derivable and checkable by hand.
  • Inodes are consumed at file-creation time and are a genuinely finite resource — "disk full but inodes exhausted" is a real failure mode.
The core insight

An inode holds all of a file's metadata and the map of its data-block locations, while directories hold only names that point at inode numbers — decoupling identity from naming.

The idea

An inode (index node) is a fixed-size on-disk data structure, one per file, that stores everything the file system needs to know about that file except its name. This includes the file type (regular file, directory, symbolic link, device), owner and group IDs, permission bits, size in bytes, timestamps (last access, last modification, last metadata change), a link count (how many directory entries reference this inode), and a set of block pointers that locate the file's actual data on disk. Each inode is identified by an inode number, a small integer unique within its file system. A directory is itself just a special file whose data consists of a list of `(name, inode_number)` pairs. Looking up a file by path means repeatedly consulting directory data to translate a name into an inode number, then reading that inode to find its data (which, if it's itself a directory, is another list of name→inode pairs). This design cleanly separates two concerns that are easy to conflate: naming (what a file is called, which can vary per directory entry) and identity/content (what the file actually contains and who owns it, which is singular). A consequence is the hard link: two different directory entries, possibly in different directories, can list the same inode number. Both names refer to the identical file — same permissions, same size, same data — and the file's link count tracks how many names exist. Only when the link count drops to zero (and no process holds it open) does the file system reclaim the inode and its data blocks. This is precisely why deleting a file conceptually means "remove a name," not "destroy the content" — content vanishes only when the last name and last open handle disappear.

How it works

Anatomy of an inode

A classic Unix-style inode (as in early ext2, following the original BSD FFS design) contains roughly: ``` Inode { file_type // regular, directory, symlink, device, ... mode // permission bits (rwx for owner/group/other) owner_uid, group_gid size_bytes // logical size of file content link_count // number of directory entries pointing here timestamps { atime, mtime, ctime } direct_pointers[12] // block numbers, point straight at data single_indirect_pointer // points at a block full of pointers double_indirect_pointer // points at a block of pointers to // blocks of pointers triple_indirect_pointer // one more level of indirection } ``` Notice what is absent: the file's name. The inode is name-agnostic by design.

Why indirection, not one flat array?

If the inode stored, say, 10,000 direct block pointers to support large files, every inode — even one for a 4-byte file — would consume the space needed for 10,000 pointers. That wastes space badly given that most files are small. The indirect-pointer scheme is the classic engineering compromise: keep the inode itself small and fixed-size, and only pay the cost of extra pointer blocks when a file is actually large enough to need them.

  • Direct pointers (e.g., 12 of them) point straight to data blocks. Small files (up to `12 × block_size`) need nothing else.
  • Single indirect pointer points to one block that itself is filled entirely with data-block pointers (not inode fields — a whole block of pointers). If a block is 4 KiB and pointers are 4 bytes, that's 1024 additional data blocks addressed through one inode pointer.
  • Double indirect pointer points to a block of pointers to single-indirect blocks — each of which points to 1024 data blocks. So one double-indirect pointer reaches `1024 × 1024` data blocks.
  • Triple indirect pointer adds one more level: `1024 × 1024 × 1024` data blocks.

This is a tree: the inode is the root, indirect blocks are internal nodes, data blocks are leaves. Reading byte offset `X` in the file means computing which "leaf" (data block) holds it, and walking down at most 4 levels (inode → optional single → optional double → optional triple → data) to find it.

Path-to-inode resolution algorithm

Every `open("/usr/bin/ls")` call performs the following, starting from a known root inode (inode 2, by convention, in many Unix file systems): ``` function RESOLVE_PATH(path): components = split(path, "/") // ["usr", "bin", "ls"] current_inode = ROOT_INODE_NUMBER // e.g. 2 for name in components: inode = READ_INODE(current_inode) if inode.file_type != DIRECTORY: return ERROR("not a directory") entries = READ_DIRECTORY_DATA(inode) // list of (name, inum) found = null for (entry_name, entry_inum) in entries: if entry_name == name: found = entry_inum break if found == null: return ERROR("no such file or directory") current_inode = found return READ_INODE(current_inode) // the target file's inode ```

Translating a byte offset to a physical block (the tree walk)

``` function BLOCK_FOR_OFFSET(inode, byte_offset, block_size, ptrs_per_block): block_index = byte_offset / block_size // integer division N = 12 // number of direct pointers P = ptrs_per_block // e.g. 1024 if block_index < N: return inode.direct_pointers[block_index] block_index -= N if block_index < P: indirect_block = READ_BLOCK(inode.single_indirect_pointer) return indirect_block.pointers[block_index] block_index -= P if block_index < P P: outer = READ_BLOCK(inode.double_indirect_pointer) outer_index = block_index / P inner_index = block_index % P inner_block = READ_BLOCK(outer.pointers[outer_index]) return inner_block.pointers[inner_index] block_index -= P P if block_index < P P P: outer = READ_BLOCK(inode.triple_indirect_pointer) i1 = block_index / (P P) rem = block_index % (P P) mid = READ_BLOCK(outer.pointers[i1]) i2 = rem / P i3 = rem % P leaf_ptrs = READ_BLOCK(mid.pointers[i2]) return leaf_ptrs.pointers[i3] return ERROR("offset exceeds max file size") ```

Figure
Inode structure: metadata fields plus the direct/indirect block-pointer tree
Inode type: regular mode: rw-r--r-- owner: uid 501 size: 5,242,880 B links: 2 mtime: 2024-03-01 dir[0..11] → data blk single indirect ● double indirect ● triple indirect ● Data blocks block 0 block 1 … block 11 Indirect block ptr 0 → data ptr 1..1023 → data Double indirect: 1024 blocks each → 1024 ptrs = 1,048,576 blocks Triple indirect: ~1.07B blocks

Complexity & correctness

Path resolution time. For a path with `k` components, `RESOLVE_PATH` performs `k` directory reads. Each directory read scans a directory's entries linearly (in simple designs) or via a hashed/B-tree index (in modern file systems like ext4's htree or XFS's B+trees). Linear scan of a directory with `m` entries costs `O(m)`; with indexing it is `O(log m)` or expected `O(1)`. Total: `O(k · m)` naive, or `O(k · log m)` with directory indexing. Since `k` is bounded by path depth (typically small, a few dozen at most) and file systems cache recently resolved directory entries (the dentry cache), the amortized cost of path resolution is very low — often just cache hits.

Block-offset lookup time. `BLOCK_FOR_OFFSET` does at most 4 block reads (inode is already in memory; then 0–3 indirect-block reads depending on which region the offset falls in) — this is `O(1)` in the number of disk accesses, independent of file size. This is the entire point of the indirection scheme: a file of any size supported by the scheme is reached in constant, small number of I/Os, not a number proportional to file size.

Maximum file size — derivation. Let `B` = block size in bytes, `p` = pointer size in bytes, so `P = B / p` pointers fit in one block. With `N` direct pointers, the maximum number of addressable blocks is: ``` max_blocks = N + P + P^2 + P^3 max_file_size = max_blocks × B ``` Concretely, with the classic ext2 parameters `N = 12`, `B = 4096` bytes, `p = 4` bytes, so `P = 1024`: ``` max_blocks = 12 + 1024 + 1024² + 1024³ = 12 + 1,024 + 1,048,576 + 1,073,741,824 ≈ 1,073,791,436 blocks max_file_size ≈ 1,073,791,436 × 4096 bytes ≈ 4.398 TiB ``` (In practice ext2/ext3 capped file size lower, at 2 TiB, due to a 32-bit block-count field elsewhere in the on-disk format — a reminder that multiple limits compose, and the smallest one binds.)

Space overhead — correctness of the "small inode" claim. A single inode with 12 direct pointers, 3 indirect pointers, and the metadata fields above fits comfortably in 128–256 bytes, a size fixed regardless of file size. This is the load-bearing correctness property: the inode's size never grows even as the file it describes grows from 1 byte to several terabytes, because growth is absorbed by additional (dynamically allocated) indirect blocks, not by the inode itself.

Correctness invariant of the tree walk. The `BLOCK_FOR_OFFSET` algorithm is correct because the address space it enumerates is partitioned into four disjoint, contiguous ranges — direct (`[0, N)`), single-indirect (`[N, N+P)`), double-indirect (`[N+P, N+P+P²)`), triple-indirect (`[N+P+P², N+P+P²+P³)`) — and the algorithm subtracts off each range's size before checking the next, so `block_index` is always re-based to be a valid local index into whichever level it lands in. This is the same pattern as converting a flat index into mixed-radix (base-`P`) digits for the double/triple cases: `outer_index` and `inner_index` are exactly the base-`P` digits of the offset within the double-indirect region, which is why `outer_index = block_index / P` and `inner_index = block_index % P` correctly recover them (division/modulo is the standard way to decompose an integer into digits of a fixed radix).

Tradeoffs. The indirection scheme optimizes for the common case (small files, cheap metadata) at a modest cost for large files (up to 3 extra disk reads per block access, though these are heavily cached in practice and often prefetched). Modern file systems (ext4, XFS, Btrfs) largely replace deep indirect-pointer chains with extents — `(start_block, length)` pairs describing runs of contiguous blocks — which represent large contiguous files with far fewer metadata entries and reduce the number of indirection levels needed in the common case of low fragmentation. The inode's core role — fixed-size per-file metadata plus some map to data — persists; only the shape of that map has evolved.

Worked example

Suppose `block_size = 4096` bytes, `pointer_size = 4` bytes, so `P = 1024` pointers per indirect block, and `N = 12` direct pointers. Trace `BLOCK_FOR_OFFSET` for a file needing the block containing byte offset `5,000,000`.

StepComputationValue
`block_index` initial`5,000,000 / 4096` (integer div)`1220`
Direct checkis `1220 < 12`?No
Subtract direct`1220 - 12``1208`
Single-indirect checkis `1208 < 1024`?No
Subtract single`1208 - 1024``184`
Double-indirect checkis `184 < 1024² = 1,048,576`?Yes
`outer_index``184 / 1024``0`
`inner_index``184 % 1024``184`

So the file system: (1) reads the inode (already cached from the `open()` call), (2) reads the block pointed to by `double_indirect_pointer` — call it the "outer" block — (3) reads pointer 0 of that outer block, obtaining the address of a "middle" indirect block, (4) reads pointer 184 of that middle block, obtaining the actual data block's address, (5) reads that data block and extracts byte `5,000,000 mod 4096 = 5,000,000 - 1220×4096 = 4,480` within it. Total disk reads for this access (assuming nothing cached beyond the inode): 3 (outer indirect + middle indirect + data block) — matching the "at most 3 indirect reads plus 1 data read" bound for the double-indirect region, confirming the `O(1)`-disk-access claim independent of the fact that offset 5,000,000 is deep inside a multi-gigabyte file.

A second trace — path resolution with a hard link. Suppose `/home/alice/report.txt` and `/home/alice/backup/report_copy.txt` are hard-linked (same inode number, say inode 88431). Resolving the first path: root(inode 2) → `home` entry → inode 40 → `alice` entry → inode 512 → `report.txt` entry → inode 88431. Resolving the second: root(inode 2) → `home` → inode 40 → `alice` → inode 512 → `backup` entry → inode 900 → `report_copy.txt` entry → inode 88431. Both paths terminate at the same inode 88431; reading that inode once yields identical size, permissions, and timestamps regardless of which path was used, and inode 88431's `link_count` field equals 2 — correctly reflecting that two directory entries reference it. Deleting one name (`unlink("/home/alice/report.txt")`) decrements `link_count` to 1 and removes only that directory entry; the data, and the other name, survive untouched.

Common pitfalls & traps

PitfallWhy it happensFix / correct mental model
Confusing "delete" with "erase content"Students assume `rm` destroys data immediately`unlink()` removes a name and decrements link count; data is freed only when link count and open-file count both reach 0
Treating inode number as global/portableInode numbers are unique only within one file systemCopying a file across file systems always gets a new inode number; hard links cannot cross file-system boundaries
Off-by-one in block-index range checksWriting `<=` instead of `<` when comparing against `N`, `P`, etc.The direct range is `[0, N)`, so index `N` itself belongs to the next range — always subtract before checking the next tier
Assuming inode ≈ disk block, one-to-oneConflating "inode" with "the file's storage"Inode is metadata only; the actual bytes live in separately allocated data blocks the inode points to
Ignoring the separate inode limitAssuming "disk full" is the only way to run out of spaceClassic file systems pre-allocate a fixed inode table at format time; you can exhaust inodes (e.g., millions of tiny files) while gigabytes of space remain free
Forgetting directories are files tooTreating directory lookup as magic rather than a data readA directory's data blocks hold ordinary `(name, inode#)` records, found via the same block-pointer mechanism as any file

Pioneers & history

The inode concept traces to the earliest Unix implementations at Bell Labs, where the need for a compact, name-independent metadata record — decoupled from the tree of directory names above it — was recognized as central to a clean file system design. The indirect-block scheme for addressing large files with a small, fixed-size metadata record was refined through the Berkeley Fast File System (FFS) work, which also introduced ideas like cylinder groups to keep inodes physically near their data.

Origins · 1 minDennis Ritchie and how this was discovered
DR
Dennis Ritchie
1941–2011 · Bell Labs, co-creator of Unix and the C programming language

Ritchie, with Ken Thompson, designed the original Unix file system in which the inode first appeared as the unit of per-file metadata, separated cleanly from directory naming — an architectural decision that persists essentially unchanged in modern Linux and BSD file systems half a century later.

History
1969
— Thompson and Ritchie begin Unix at Bell Labs; the inode table and directory-as-file design take shape.
1978
— Unix documentation formalizes inode fields: type, mode, links, size, and block pointers.
1984
— Berkeley Fast File System (FFS) paper refines block allocation and indirect-pointer performance.
1993
— ext2 for Linux adopts the classic direct/indirect/double-indirect/triple-indirect inode layout.
2008
— ext4 introduces extent-based mapping, reducing reliance on deep indirect-pointer chains for large files.

Where this matters

Every Unix-family operating system — Linux (ext2/3/4, XFS, Btrfs), macOS (APFS, historically HFS+), the BSDs (UFS/FFS) — implements some variant of the inode concept, even where the on-disk representation has evolved to extents or B-trees. Systems administrators diagnose "disk full" incidents by checking inode exhaustion (`df -i`) as a distinct resource from block exhaustion. Backup and sync tools (`rsync`, `cp --preserve=links`) must understand hard links and inode numbers to avoid duplicating data unnecessarily or breaking link relationships. Container and virtualization file systems (overlayfs, union mounts) manipulate inode-like metadata layers to implement copy-on-write efficiently. Even network file systems (NFS) expose file handles that function analogously to inode numbers, letting a client reference a file's identity independent of the path used to reach it. Any systems engineer debugging "why does this file show link count 2" or "why is `df` fine but `open()` fails" is, in effect, reasoning directly about inode mechanics.

Ask the tutor: How would the maximum file-size formula change if a file system used 8-byte block pointers instead of 4-byte ones, and why?

Why doesn't the inode just store the file's name?

Names are properties of directory entries, not of the file's identity. A single file can legitimately have multiple names (hard links) in the same or different directories, and renaming a file should not require touching the file's content or metadata at all. If the inode stored a name, every hard link would need its own inode (destroying the point of hard links) or the file system would need to keep multiple names magically in sync inside one inode, which is awkward and fragile. Storing the name in the directory entry instead means renaming is just an update to one small directory record, and hard links become a natural, cheap feature: just add another `(name, inode#)` pair pointing at the existing inode.

What exactly is stored in an indirect block, and is it different from an inode?

An indirect block is an ordinary data block, physically no different from a block holding file content, except that its bytes are interpreted as an array of block-number pointers rather than as file data. It is not part of the inode structure itself; the inode merely holds a single pointer to it. This is the trick that keeps inodes small: one 4-byte pointer in the inode can, transitively, reference up to a few thousand (single indirect), a million (double indirect), or over a billion (triple indirect) additional data blocks, at the cost of extra disk reads only when a file is actually that large.

How does the operating system know how many blocks to read for path resolution like /a/b/c?

It doesn't know in advance — it processes the path one component at a time, and each step requires exactly one directory read (to find the next component's inode number) followed by one inode read (to get that inode's own data-block pointers, needed if it's itself a directory being descended into further). For a path with `k` components, that's on the order of `k` directory lookups. In practice, most of these reads are served from the dentry cache and inode cache in memory, since the same directories (like `/`, `/usr`, `/usr/bin`) are traversed constantly, so actual disk I/O is far rarer than the raw algorithm suggests.

Why do modern file systems move away from deep indirect-pointer chains toward extents?

Indirect pointers work block-by-block, so a large contiguous file (say, a 10 GB video written in one sequential pass) still requires enormous numbers of pointer entries — one per 4 KiB block — even though those blocks are physically contiguous on disk. An extent instead records a single `(starting_block, length)` pair for a whole contiguous run, so the same 10 GB file might need only a handful of extent records instead of millions of individual pointers. This shrinks metadata size, reduces the number of indirection levels actually needed, and speeds up both allocation and lookup for the common case of files written with little fragmentation, while gracefully falling back to more extent records for fragmented files.

Can two files in the same file system ever share the same inode number by coincidence?

No — inode numbers are unique identifiers within a single file system volume, assigned by the file system itself from a fixed table (in classic designs) or a managed allocation structure, and the file system guarantees no two live files share one. Two files on different file systems (or different partitions/volumes) certainly can have numerically identical inode numbers, since each file system maintains its own independent numbering; this is exactly why hard links cannot cross file-system boundaries — the destination file system has no matching inode to point a new directory entry at.

What happens to the inode number when a file is copied versus moved?

A move (rename) within the same file system leaves the inode number, and the inode itself, completely untouched — only the directory entry (or entries) referencing it change, which is why `mv` on the same volume is nearly instantaneous regardless of file size. A copy, by contrast, always creates a brand-new inode (and new data blocks) with a new inode number, because a copy is logically a distinct file with independent metadata (its own timestamps, its own link count starting at 1) even though its content is initially identical to the source.

Does every file system use exactly this direct/single/double/triple-indirect scheme?

No. This is the classic scheme from BSD FFS and early Linux ext2/ext3, chosen for its historical importance and pedagogical clarity, but it is one design point among several. Modern file systems like ext4, XFS, Btrfs, and APFS use extent-based B-trees, which can represent large contiguous files far more compactly and often support sparse files, delayed allocation, and copy-on-write more naturally. The conceptual role of the inode — a fixed-size per-file metadata record with some mechanism for mapping logical offsets to physical blocks — is universal even where the specific mapping mechanism has been redesigned.

Before this

Where this leads