📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What actually is a file and a directory at the operating system level, and how do you list and walk directories efficiently in .NET?

Files, directories, metadata, inodes and links, plus efficient directory enumeration in .NET with EnumerateFiles and EnumerationOptions.

Junior Theory Beginner directories enumeration file-system files inode io

🎯 Key points

  • A file is a sequence of bytes plus metadata (size, timestamps, owner, permissions). On ext4 and XFS the inode holds the metadata and block or extent pointers; on NTFS the Master File Table record does.
  • A directory is itself a file whose entries map names to inode numbers (or MFT references). Large directories are indexed (ext4 htree, XFS and NTFS B+ trees, APFS B-trees) so name lookups stay fast.
  • Because names live in directory entries, a rename on the same volume is a metadata change, while a move across volumes is copy plus delete.
  • Hard links are extra names for the same inode; symbolic links store a path and can dangle. .NET exposes them through File.CreateSymbolicLink, LinkTarget and ResolveLinkTarget.
  • File systems differ in ways that affect apps: case sensitivity (ext4 sensitive, NTFS and APFS usually insensitive), copy-on-write and snapshots (Btrfs, APFS, ReFS), and metadata-heavy workloads being slow on network shares.
  • Directory.EnumerateFiles is lazy and streams entries in batches; GetFiles reads the whole tree into an array before returning.
  • DirectoryInfo.EnumerateFiles fills FileInfo from the directory read, avoiding a stat per file; FileSystemEnumerable<T> filters raw entries with no allocations for hot paths.
  • Use the EnumerationOptions overloads (RecurseSubdirectories, IgnoreInaccessible, MatchCasing); and remember a volume can run out of inodes before space (df -i), so shard huge flat folders.

⏱ 30-second answer

A file is a named sequence of bytes plus metadata (size, timestamps, owner, permissions). On Linux the data and metadata live in an inode; the name is just an entry in a directory. A directory is a special file that maps names to inodes (Linux) or file records (NTFS MFT), forming a tree.

In .NET, use Directory.EnumerateFiles / DirectoryInfo.EnumerateFiles (lazy, streams results) rather than GetFiles (builds the whole array first), and pass EnumerationOptions to recurse, skip inaccessible folders and control case matching.

🗣 Full answer (senior)

Files

Part What it holds
Name Stored in the directory entry, not in the file itself
Data Blocks on disk (or extents)
Metadata Size, created/modified/accessed times, owner, permissions, attributes (hidden, read-only)
Identity Inode number (Linux/macOS), file ID in the MFT (NTFS)

The OS does not care what is inside. .json or .png is only a naming convention; the content format is up to applications.

Directories

A directory is a table of name → inode entries. That explains several behaviours:

  • Renaming within the same file system is instant, because only the directory entry changes; the data does not move.
  • Moving across volumes is a copy plus delete.
  • Hard links are two names pointing to the same inode. Symbolic links are small files containing a path to another file.
  • Deleting a name removes the entry. The data is freed only when no names and no open handles remain.

Enumerating in .NET

API Behaviour Use when
Directory.GetFiles Reads everything into a string[] before returning Small folders, you need a count or random access
Directory.EnumerateFiles Lazy IEnumerable<string>, yields as it reads Large folders, early exit, streaming into a pipeline
DirectoryInfo.EnumerateFiles Yields FileInfo with metadata already loaded You need size or dates too (avoids a second system call per file)
FileSystemEnumerable<T> Low-level, custom filter and projection on the raw entry Hot paths with millions of entries

Production example

A nightly archive job listed an upload folder with 2 million files using Directory.GetFiles(path, "*", SearchOption.AllDirectories). It took 4 minutes before the first file was processed, allocated hundreds of MB of strings, and failed outright when it hit one folder without read permission. Switching to new DirectoryInfo(path).EnumerateFiles("*", new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }) started processing straight away, kept memory flat, and logged inaccessible folders instead of crashing.

🔍 What the interviewer is checking

Checks basic understanding of what the file system stores and whether the candidate knows the streaming enumeration APIs.

Green flags

  • Separates name, data and metadata.
  • Knows EnumerateFiles is lazy and GetFiles is eager.
  • Mentions EnumerationOptions and inaccessible folders.
  • Bonus: inodes, hard vs symbolic links, inode exhaustion.

Red flags

  • Thinks the extension defines the file type.
  • Loads millions of paths into memory without thinking.

📚 Learn in depth

1. The inode model (Linux, macOS)

Directory /var/app/          Inode table                   Data blocks
┌───────────────────────┐      ┌───────────────────────┐        ┌─────────┐
│ "config.json" → 1201  │─────►│ 1201: size 812, uid 1000, │───────►│ {"a":1} │
│ "logs"        → 1305  │      │       mode 0644, mtime,  │        └─────────┘
│ "current.json"→ 1201  │──────┘       links = 2           │
└───────────────────────┘      │ 1305: directory           │
   (hard link: two names,     └───────────────────────┘
    one inode)

ls -i shows inode numbers; stat file shows metadata. A file system can run out of inodes before it runs out of space (millions of tiny files), which shows up as "No space left on device" while df -h shows free space. Check with df -i.

2. NTFS (Windows)

NTFS stores every file as a record in the Master File Table (MFT). Small files can live entirely inside their MFT record. Directories are B-tree indexes of names, which is why lookups in big folders stay fast. NTFS also supports alternate data streams, hard links, symbolic links and junctions.

3. Links in .NET

File.CreateSymbolicLink("current.json", "config.v2.json");
var info = new FileInfo("current.json");
Console.WriteLine(info.LinkTarget);                 // config.v2.json
var target = info.ResolveLinkTarget(returnFinalTarget: true);

Creating symbolic links on Windows needs Developer Mode or admin rights.

4. Why EnumerateFiles is faster to first result

GetFiles:        [read all entries] ──────────────────► return array ─► process
EnumerateFiles:  [read batch] ► process ► [read batch] ► process ► ...

Under the hood .NET calls getdents64 (Linux) or NtQueryDirectoryFile (Windows) in batches. DirectoryInfo.EnumerateFiles fills FileInfo from the same batch, so you avoid a stat per file.

5. EnumerationOptions

Option Default Why it matters
RecurseSubdirectories false Walk the tree
IgnoreInaccessible true (with EnumerationOptions) Skip folders you cannot read instead of throwing
MatchCasing PlatformDefault Make *.JPG vs *.jpg consistent across OSes
MatchType Simple Win32 keeps legacy DOS wildcard quirks
AttributesToSkip Hidden | System Include or exclude hidden files
MaxRecursionDepth int.MaxValue Limit deep trees

Note the legacy SearchOption overloads use old behaviour (no ignore inaccessible, Win32 matching), so prefer the EnumerationOptions overload.

6. Try it yourself

  1. Create 200,000 empty files in a temp folder with a loop.
  2. Time Directory.GetFiles(dir).First() vs Directory.EnumerateFiles(dir).First().
  3. Run df -i on Linux before and after to see inode usage grow.
  4. Create a hard link (ln a.txt b.txt), edit b.txt, and read a.txt.

💻 Code

// Stream a large tree, skip folders we cannot read, get size without extra stat calls
var options = new EnumerationOptions
{
    RecurseSubdirectories = true,
    IgnoreInaccessible = true,
    MatchCasing = MatchCasing.CaseInsensitive,
    AttributesToSkip = FileAttributes.System
};

long totalBytes = 0;
foreach (var file in new DirectoryInfo("/data/uploads").EnumerateFiles("*.jpg", options))
{
    totalBytes += file.Length;
    if (file.LastWriteTimeUtc < DateTime.UtcNow.AddDays(-30))
    {
        await ArchiveAsync(file); // processing starts on the first file found
    }
}
// High-performance custom enumeration: filter on the raw entry, no FileInfo allocations
using System.IO.Enumeration;

var bigFiles = new FileSystemEnumerable<string>(
    "/data/uploads",
    (ref FileSystemEntry entry) => entry.ToFullPath(),
    new EnumerationOptions { RecurseSubdirectories = true })
{
    ShouldIncludePredicate = (ref FileSystemEntry entry) =>
        !entry.IsDirectory && entry.Length > 100 * 1024 * 1024
};

foreach (var path in bigFiles)
    Console.WriteLine(path);

⚠ Common mistakes

Mistake: Using Directory.GetFiles with SearchOption.AllDirectories on large trees. Why it is wrong: It materialises every path before returning, uses a lot of memory, and throws UnauthorizedAccessException on the first unreadable folder. Instead: Use EnumerateFiles with EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }.

Mistake: Thinking the file name is stored inside the file. Why it is wrong: The name lives in the directory entry, so hard links, instant renames and deleted-but-open files make no sense under that model. Instead: Explain the directory entry to inode (or MFT record) split.

Mistake: Calling new FileInfo(path).Length for every path from Directory.EnumerateFiles. Why it is wrong: It adds a stat system call per file, which is very slow on network shares. Instead: Use DirectoryInfo.EnumerateFiles, which returns FileInfo objects already filled from the directory read.

Mistake: Assuming every file system behaves like the one on your laptop. Why it is wrong: Case sensitivity, path length, locking and metadata speed differ between ext4, NTFS, APFS and SMB or NFS mounts. Instead: Set MatchCasing explicitly, test on the target OS, and avoid metadata-heavy loops over network storage.

Mistake: Assuming free disk space means you can create files. Why it is wrong: Millions of tiny files can exhaust inodes while df -h still shows free space. Instead: Check df -i and shard large flat folders into subfolders by date or hash prefix.

🏛 Senior insight: trade-offs

The directory entry versus inode split explains a lot of real behaviour: instant renames, hard links, deleted-but-open files keeping space, and inode exhaustion. For enumeration, the rule is to stream rather than materialise, and to design folder layout before worrying about the API.

File system Directory indexing Notable traits for apps
ext4 htree (hashed B-tree) Case-sensitive, fixed inode count at format time
XFS B+ trees Dynamic inodes, strong with large files and parallel I/O
NTFS B+ tree indexes in the MFT Case-insensitive by default, mandatory share locks, alternate data streams
ReFS B+ trees, copy-on-write Integrity streams, block cloning; used for Dev Drive on Windows
APFS B-trees, copy-on-write Snapshots, clones; usually case-insensitive on macOS
SMB or NFS share Server-side Every metadata call is a network round trip
API Memory Time to first result When to use it
GetFiles Whole array After full scan Small folders
EnumerateFiles Flat Immediate Large trees, early exit
DirectoryInfo.EnumerateFiles Flat Immediate Need size or dates too
FileSystemEnumerable<T> Minimal Immediate Millions of entries, hot paths

🔗 Follow-ups the interviewer may ask

  1. When: Candidate talks about building paths to the files they enumerate Why: Checks cross-platform path handling
    How to answer
    • Separators: Windows uses \ (and accepts /), Linux and macOS use only /. Never hard-code \; use Path.Combine/Path.Join or Path.DirectorySeparatorChar.
    • Absolute vs relative: C:\data\a.txt or /data/a.txt are absolute; data/a.txt is resolved against the current working directory, which differs between dotnet run, a service and a container. Base paths on AppContext.BaseDirectory or configuration.
    • Path.Combine drops earlier parts if a later part is rooted (Combine("/app", "/etc/passwd") gives /etc/passwd). Path.Join just concatenates with one separator.
    • Case: Linux file systems are case-sensitive, Windows and default macOS are not. Config.json vs config.json works on your laptop and fails in the Linux container.
  2. When: Candidate mentions metadata such as owner or permissions Why: Probes how permissions actually work
    How to answer
    • Linux: each file has an owner (UID), a group (GID) and rwx bits for owner, group and others (for example 0644, 0755). On a directory, x means "can enter" and w means "can create, delete or rename entries". Root bypasses most checks.
    • Windows: each file has an ACL (a list of allow and deny entries for users and groups), usually inherited from the parent folder.

    The container error usually means the app runs as a non-root user (the official .NET 10 images define user app, UID 1654, via $APP_UID) while the mounted folder is owned by root with 0755. Fix it by giving that UID or a shared group write access (chown, Kubernetes fsGroup, or volume mount options), not by running the container as root or using chmod 777.

  3. When: Candidate mentions deleted files still using space Why: Connects files to open descriptors and handles
    How to answer

    A file descriptor (Linux, a small integer) or handle (Windows, an opaque value) is the process's reference to a kernel object: an open file, socket, pipe, event, mutex, process or thread. The kernel owns the object, and the process holds an index into its own table.

    They are limited (e.g. ulimit -n often 1,024 soft by default on Linux shells, much higher in many containers and under systemd), so leaks eventually break the app.

    In .NET, SafeHandle wraps the raw value: it releases it in a critical finaliser if you forget, guarantees release even under thread aborts or async exceptions, and prevents handle recycling bugs by reference counting during P/Invoke calls. You still call Dispose (using) to release deterministically.

More in File System

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me