Filesystems · Filesystem Fundamentals

What an Inode Stores, and Why It Stores No Name

The inode is the object a filesystem calls a file: its fields, what it deliberately omits, how stat exposes it, and why a full inode table can refuse a write on a filesystem with free space.

A file on disk is not a stream of bytes with a name attached. It is a record of metadata plus a description of where its data lives, and the name lives somewhere else entirely. The record is the inode, and the separation between the record and the name is the single design decision from which most of the POSIX file interface follows: hard links, rename, unlink on an open file, and the possibility of removing a directory whose former contents a process is still reading.

The record and the things that point at it

An inode holds the attributes of one file and the location of its data. A directory entry holds a name and an inode number. Nothing in the inode refers back to a name. That asymmetry is deliberate, and it is what makes two names for one file cheap: a second directory entry with the same inode number is a complete hard link, costing one entry and one increment of a counter.

The counter is the link count. It exists because the inode has no way to know how many entries name it, and the filesystem must decide when the record and its data can be reclaimed. The kernel decrements the count on unlink and frees the inode only when the count reaches zero and no open file description still refers to it. The second condition is the subject of Unlink While Open and the Life of an Orphaned Inode.

Fields that the interface exposes

POSIX defines a structure that a conforming implementation must fill from whatever internal record it keeps. The mapping is close to one-to-one on traditional Unix filesystems and looser elsewhere, but the fields are the contract:

FieldMeaningTypical source in the inode
st_devDevice containing the fileFilesystem identifier, not the inode
st_inoInode numberThe record’s index or address
st_modeType and permission bits16-bit mode word
st_nlinkNumber of directory entriesLink counter
st_uid, st_gidOwner and groupTwo integer ids
st_sizeFile length in bytesByte count, may exceed allocated data
st_blksizePreferred I/O sizeFilesystem block size, often 4096
st_blocksSpace allocated, in 512-byte unitsBlock counter
st_atim, st_mtim, st_ctimAccess, modification, status-change timesThree timestamps

Two of those deserve attention because they are commonly misread.

st_blocks is counted in units of 512 bytes regardless of the filesystem’s own block size. On a filesystem with 4096-byte blocks, a file holding one byte reports 8 blocks, and du multiplies by 512 to produce the number it prints. The unit is a historical constant baked into the interface; changing it would break every program that computes allocated space.

st_ctim is the inode status change time, not the creation time, and no portable interface allows a process to set it. It moves whenever the inode’s metadata changes: chmod, chown, a link count change, or a write that updates st_mtim. A file whose permissions were changed an hour ago and whose contents were last written yesterday has an older st_mtim than st_ctim. Backup tools that key on st_ctim therefore copy files whose permissions changed but whose data did not.

The size field also decouples from allocation. st_size is the byte offset of the end of the file as the interface defines it, while the blocks actually mapped can be fewer. That gap is a sparse file, covered in Sparse Files and the Gap Between Length and Allocation.

Type, mode, and the numbers that are not file data

The mode word packs the file type into its high bits and permissions into its low nine. The types POSIX requires are regular file, directory, block device, character device, FIFO, and symbolic link; sockets are added by the platforms that support them. A device node carries a device number in a separate field rather than in the data area, so opening /dev/null never touches a disk block: the inode’s type tells the kernel to dispatch to the device driver instead of the file data path.

An inode also carries a generation number on filesystems that export over NFS. Inode numbers are reused after deletion, and a client caching a file handle across a delete-and-recreate cycle would otherwise read the new file while believing it holds the old one. The generation number changes on reuse, so a stale handle fails to resolve instead of resolving to the wrong object. That field is invisible through stat; it exists only for the handle layer.

#include <sys/stat.h>
#include <stdio.h>

int main(void) {
    struct stat a, b;
    if (stat("report.txt", &a) != 0 || stat("report-link.txt", &b) != 0)
        return 1;
    /* Same device and inode number: the two names refer to one file. */
    printf("same file: %d, links: %lu\n",
           a.st_dev == b.st_dev && a.st_ino == b.st_ino,
           (unsigned long)a.st_nlink);
    return 0;
}

The comparison in that snippet is the only correct way to ask whether two names denote the same file. Comparing paths, sizes, or modification times answers a different question, and each of those can coincide for distinct files.

Identity is scoped to one mounted filesystem

An inode number is unique within a filesystem, not across the machine. Two files on different mounts can carry the same number, which is why st_dev participates in the identity test. The pair (st_dev, st_ino) is the portable identity of a file, and it is what the kernel uses for its own bookkeeping; the same pair appears in /proc and in file handles.

st_dev identifies the filesystem instance, not the physical device. Two mounts of the same filesystem, or a bind mount, produce the same st_dev for the underlying data but can differ in the value reported for the mount as a whole on some systems. Code that assumes st_dev distinguishes mounts is relying on behaviour the standard does not promise; statx reports the device as a major and minor pair in stx_dev_major and stx_dev_minor, which at least makes the encoding explicit.

A full inode table on a filesystem with free blocks

Traditional designs, including ext2, ext3, and ext4, allocate the inode table when the filesystem is created. The table is a fixed array, sized from the total capacity at a default ratio of one inode per 16384 bytes of space. A terabyte filesystem therefore carries on the order of 67 million inodes and can never hold more files than that, no matter how small they are.

The consequence is a failure that looks like a contradiction: df reports gigabytes free, and write fails with ENOSPC. The blocks are free; the records are not. df -i reports inode usage, and it is the first thing to check when space is available and creation fails. The usual cause is a workload of many tiny files, such as mail queues, caches of small objects, or unpacked source trees, where average file size falls far below the ratio the filesystem was built with.

Modern designs avoid the fixed table. XFS allocates inodes dynamically in chunks as needed, and btrfs stores metadata in a copy-on-write tree with no fixed inode array. The trade is the opposite one: a fixed table gives predictable layout and fast allocation at the cost of a hard ceiling, while dynamic allocation removes the ceiling at the cost of a more complex allocator and metadata that can fragment.

What the inode cannot answer

An inode does not know its own path. Reconstructing a path from an inode requires walking the directory tree downward, searching each directory for an entry with a matching number, and a file with several hard links has several equally valid answers. Linux exposes one of them through /proc, choosing a path by traversal, but the choice is arbitrary and can change as directories are modified.

This is also why a directory has a link count greater than one even with no hard links to it: each subdirectory holds a .. entry pointing back to its parent, and the parent’s count includes those entries. On filesystems that maintain those entries, the count of a directory is two plus the number of its subdirectories, a fact that leaks the structure of the tree through a single stat call. Filesystems that synthesize the dot entries or treat directories as records in a tree may report a constant one instead, so the value is informational rather than structural. The entries themselves, and why directories are the one file type the interface refuses to hard-link, are the subject of Directory Entries as a Name-to-Inode Map.

The inode also says nothing about which process has the file open, how many times it is mapped, or where the file descriptor table points. Those belong to the open file description, a separate object created by open and shared by dup and fork, described in Open File Descriptions and the Offsets They Share. Keeping the three layers (inode, description, descriptor) distinct is what makes the semantics of fork, dup, and O_APPEND predictable rather than mysterious.

References

  1. stat(2) — Linux manual page — man7.org
  2. The Open Group Base Specifications Issue 7, 2018 edition — System Interfaces — The Open Group
  3. The Design of the UNIX Operating System — Maurice J. Bach, Prentice Hall, 1986
  4. Ext4 Data Structures and Algorithms — Inode Table — Linux kernel documentation