Filesystems 101: Inodes, Directories, and What a Path Lookup Really Does

This article covers Filesystems 101: Inodes, Directories, and What a Path Lookup Really Does. Understand the core filesystem abstractions: inodes, dentries, hard links, symlinks, and why path lookup can be expensive. Includes practical...

When you call open("/home/user/file.txt"), the kernel performs a surprising amount of work:

  • parse path components
  • walk directories
  • resolve symlinks
  • check permissions
  • create or locate an inode

Understanding these fundamentals helps you reason about performance (lots of tiny files), correctness (links), and security (TOCTOU).

1) Inodes: the file’s identity

On many Unix filesystems, the inode stores metadata:

  • file type (regular file, directory, symlink, device, ...)
  • permissions, owner, timestamps
  • file size
  • pointers to data blocks (implementation-specific)

Important: filenames are not “stored in the inode”. Filenames live in directories.

2) Directories are maps: name → inode

A directory is a special file whose content encodes entries mapping:

  • filename
  • inode number

Hard links:

  • multiple directory entries pointing to the same inode
  • same inode number, different names

Symlinks:

  • a file containing a path to another file
  • symlink resolution can cross filesystem boundaries

3) Path lookup and the dentry cache

Linux caches name lookups using dentry objects.

  • Hot paths can be fast due to cache hits.
  • Cold paths (many unique directories) can be slow.

If you create tools that touch millions of files (build systems, backups, package managers), path lookup and metadata I/O often dominates.

st_nlink is the number of directory entries (hard links) pointing at the inode.

  • A regular file usually starts at 1.
  • A directory's link count reflects its . entry plus subdirectories.

You can use link counts to reason about whether a file has additional names elsewhere.

#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <path>\n", argv[0]);
        return 2;
    }

    struct stat st;
    if (lstat(argv[1], &st) != 0) {
        fprintf(stderr, "lstat: %s\n", strerror(errno));
        return 1;
    }

    printf("inode: %lu\n", (unsigned long)st.st_ino);
    printf("links: %lu\n", (unsigned long)st.st_nlink);
    printf("size:  %lu\n", (unsigned long)st.st_size);

    if (S_ISLNK(st.st_mode)) {
        printf("type: symlink\n");
    } else if (S_ISDIR(st.st_mode)) {
        printf("type: directory\n");
    } else if (S_ISREG(st.st_mode)) {
        printf("type: regular\n");
    } else {
        printf("type: other\n");
    }

    return 0;
}

Use lstat (not stat) when you want information about the symlink itself.

5) Zig: list directory entries

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const a = gpa.allocator();

    var args = try std.process.argsAlloc(a);
    defer std.process.argsFree(a, args);
    if (args.len != 2) return error.InvalidArgs;

    var dir = try std.fs.cwd().openDir(args[1], .{ .iterate = true });
    defer dir.close();

    var it = dir.iterate();
    while (try it.next()) |e| {
        std.debug.print("{s} ({any})\n", .{ e.name, e.kind });
    }
}
use std::fs;

fn main() -> std::io::Result<()> {
    let path = std::env::args().nth(1).expect("path");
    let md = fs::symlink_metadata(&path)?; // like lstat

    println!("len: {}", md.len());
    println!("is_file: {}", md.is_file());
    println!("is_dir: {}", md.is_dir());
    println!("is_symlink: {}", md.file_type().is_symlink());

    Ok(())
}

7) Security pitfall: TOCTOU

A classic mistake:

  1. check permissions with stat
  2. later call open

Between the check and the use, an attacker can replace the file (symlink swap).

Mitigations:

  • open first, then fstat
  • use openat with directory fds
  • consider O_NOFOLLOW and O_CLOEXEC

8) Rename is special (and why databases love it)

Within a filesystem, rename is typically atomic: after a crash, you should see either the old name or the new name.

That makes it a common commit primitive for safe updates:

  • write a temp file
  • fsync the temp file
  • rename over the old file
  • (optionally) fsync the directory

Related:

References