Filesystems: NTFS

📚 Filesystems (4 of 7)
  1. 1. Introduction
  2. 2. FAT32
  3. 3. Ext4
  4. 4. NTFS You are here
  5. 5. XFS
  6. 6. Btrfs
  7. 7. ZFS
NTFS

In the previous article , we explored ext4—a filesystem that divides the disk into block groups and separates metadata from data, with inodes in one place and file content in another.

NTFS takes a radically different approach. In NTFS, everything is a file—the allocation bitmap, the journal, even the Master File Table that tracks all the other files. This single idea shapes the entire design: the same mechanisms that store your documents also store the filesystem’s own bookkeeping.

If you’ve ever used Windows, your files have almost certainly been sitting on NTFS. It was introduced with Windows NT in 1993 for enterprise and server use, became the default for consumer Windows starting with XP in 2001, and has been the standard ever since.

So where do we begin? The same place the operating system does—the very first bytes on the partition.

The Boot Sector

Like every filesystem, NTFS starts with a boot sector—the first 512 bytes of the partition. It serves the same dual purpose as FAT32’s boot sector: bootstrap code for booting the OS, and a description of the filesystem’s layout.

The most important fields tell us the cluster size—a cluster being the smallest unit of disk space NTFS works with, typically 4KB—the total number of sectors, and—crucially—where the Master File Table starts. That’s the single most important piece of information on the entire volume, because everything in NTFS flows from the MFT.

NTFS also keeps a backup copy of the boot sector at the very end of the volume. If the primary gets corrupted, recovery tools can restore it.

Now that we know how NTFS bootstraps, let’s zoom out and look at how the entire volume is organized.

The Physical Layout

NTFS divides the disk into a few regions, and we’ll explore each one in detail throughout the article. But first, let’s get a bird’s-eye view of how they fit together.

The boot sector comes first—we’ve already covered that. The boot sector contains pointers to two critical locations: the Master File Table ($MFT) and its backup mirror ($MFTMirr). These can be placed anywhere on the volume—Windows typically puts the MFT roughly a third of the way in, and the mirror around the middle.

Around the MFT, NTFS reserves an MFT zone—roughly 12.5% of the volume—to give the MFT room to grow without becoming fragmented. Keeping the MFT contiguous on disk means fewer seeks when reading metadata, which directly translates to better performance.

The rest of the volume is the data area, where the actual file content lives—your documents, applications, media files, everything that isn’t filesystem metadata.

The $MFTMirr contains copies of the four most critical MFT records (the MFT itself, its mirror, the journal, and the volume information), so even serious corruption of the MFT doesn’t necessarily mean total data loss. And at the very last sector of the volume sits a backup copy of the boot sector, as an additional safety net.

We mentioned the MFT zone takes up a big chunk of the volume. Let’s see what actually lives inside it.

The Master File Table: Everything Is a File

Here’s where NTFS gets interesting. The Master File Table is the central data structure of the entire filesystem. You can think of it as a flat database: one record per file or directory, stored sequentially. Each record is typically 1KB, and they’re numbered starting from zero.

What makes it unusual is that the filesystem’s own metadata is stored as files in the MFT too. The first 24 records are reserved for system files:

  • Record 0 ($MFT): The MFT itself. Yes, the MFT has an entry describing itself—its own size, location, and allocation on disk. This is how the MFT bootstraps: the boot sector tells us where the first cluster of the MFT is, and from there the MFT’s own record tells us where the rest of it lives.
  • Record 1 ($MFTMirr): The backup of the first four records.
  • Record 2 ($LogFile): The transaction journal for crash recovery.
  • Record 5 (root directory): The root of the filesystem tree, equivalent to ext4’s inode 2.
  • Record 6 ($Bitmap): The cluster allocation bitmap—which clusters on the volume are free and which are in use.
  • Record 10 ($UpCase): A Unicode uppercase mapping table used for case-insensitive filename comparisons.

And so on—there are entries for bad cluster tracking, security descriptors, volume information, and more. The first user file or directory gets record 24.

This “everything is a file” design means NTFS can use the same caching, access control, and journaling mechanisms for its own metadata that it uses for your data. The cluster bitmap isn’t some special structure with bespoke code—it’s a file, managed like any other file, just with a well-known record number.

We know the MFT is a sequence of records, but what does each record actually look like on the inside?

MFT Records and Attributes

Each MFT record starts with a header containing a magic number (FILE), a sequence number that increments each time the record is reused (so stale references can be detected), a hard link count, and flags indicating whether the record is in use and whether it represents a directory.

But the real content of an MFT record is its attributes. Everything about a file—its name, its timestamps, its actual data—is stored as a sequence of typed attributes packed one after another inside the record. An attribute has a type, a size, and then either inline data or a pointer to external storage. The record ends with a special end marker.

A typical file has at least three attributes. $STANDARD_INFORMATION holds the timestamps—creation, modification, access, status change—DOS-style flags like read-only or hidden, and on modern NTFS volumes, security and quota information. $FILE_NAME stores the filename and a reference to the parent directory, along with cached copies of the file’s timestamps and size. And $DATA holds the file’s actual content.

You might wonder why timestamps and size appear in both $STANDARD_INFORMATION and $FILE_NAME. That’s not an accident—the $FILE_NAME structure is the same one used inside directory entries, and that duplication turns out to be very useful. We’ll see exactly why when we get to directories later.

Let’s make this concrete. Imagine a file called hello.txt containing the text “Hello world” (11 bytes), sitting in the root directory. Its MFT record would look something like this:

MFT Record (1024 bytes)
├─ Header
│   ├─ Magic number: "FILE"
│   ├─ Sequence number: 1
│   ├─ Hard link count: 1
│   └─ Flags: 0x01 (in use, not a directory)
│
├─ $STANDARD_INFORMATION (type 0x10)
│   ├─ Created:       2026-03-15 10:30:00
│   ├─ Modified:      2026-03-15 10:30:00
│   ├─ Accessed:      2026-03-15 10:30:00
│   └─ Flags:         Archive
│
├─ $FILE_NAME (type 0x30)
│   ├─ Parent ref:    MFT record 5 (root directory)
│   ├─ Name:          "hello.txt"
│   ├─ Created:       2026-03-15 10:30:00  (cached copy)
│   ├─ Modified:      2026-03-15 10:30:00  (cached copy)
│   └─ File size:     11 bytes             (cached copy)
│
├─ $DATA (type 0x80)
│   └─ Content:       "Hello world" (11 bytes, stored right here)
│
└─ End marker (0xFFFFFFFF)

[~700 bytes of unused space in this record]

Notice how the entire file—metadata and content—fits inside a single 1KB MFT record. Those 11 bytes of text sit right there in the $DATA attribute, alongside the timestamps and filename. One disk read gets you everything.

This is a fundamentally different model from ext4. In ext4, the inode has fixed fields for timestamps, size, and permissions, plus a dedicated area for extent tree data. In NTFS, everything is an attribute in a uniform container. Want to add a new kind of metadata? Just add a new attribute type.

Our hello.txt example fit neatly inside its MFT record, but 1KB is only enough for very small files. What happens when a file’s data doesn’t fit?

Resident vs Non-Resident: Where Data Actually Lives

This is one of NTFS’s cleverest design choices. Each attribute can be stored in one of two ways: resident (inside the MFT record) or non-resident (in external clusters on disk).

Resident Attributes

For small data—typically under about 700 bytes—NTFS stores the attribute’s content directly inside the MFT record. Our hello.txt example earlier was exactly this: the 11 bytes of “Hello world” sat right there in the $DATA attribute, no clusters allocated, no jumping across the disk. One disk read gets you everything.

This is a significant win for small files. Think about how many files on a typical system are tiny—configuration files, shortcuts, small scripts. NTFS handles all of them with a single I/O operation.

But most files aren’t tiny. What happens when the data outgrows the MFT record?

Non-Resident Attributes

When data grows too large to fit inside the MFT record, NTFS converts it to non-resident storage. Instead of inline data, the attribute now contains a run list—a compact description of which clusters on disk hold the data (we’ll explain how these work in detail in a moment).

A non-resident attribute tracks three separate sizes:

  • Allocated size: How much disk space is reserved, rounded up to cluster boundaries.
  • Data size: The logical file size—what you see when you check the file’s properties.
  • Valid size: How much of the file has been explicitly written to.

That third one—valid size—is interesting. The gap between valid size and data size represents uninitialized space. If you create a 1GB file and only write the first 100MB, reads beyond that 100MB boundary return zeros, even though clusters are allocated. This prevents a security issue: without valid size tracking, reading uninitialized clusters could expose data from previously deleted files.

Let’s revisit our example. Suppose hello.txt has grown into a 50KB document. It no longer fits inside the MFT record, so its $DATA attribute becomes non-resident:

MFT Record (1024 bytes)
├─ Header
│   ├─ Magic number: "FILE"
│   ├─ Sequence number: 1
│   ├─ Hard link count: 1
│   └─ Flags: 0x01 (in use, not a directory)
│
├─ $STANDARD_INFORMATION (type 0x10)
│   ├─ Created:       2026-03-15 10:30:00
│   ├─ Modified:      2026-03-15 14:22:00
│   ├─ Accessed:      2026-03-15 14:22:00
│   └─ Flags:         Archive
│
├─ $FILE_NAME (type 0x30)
│   ├─ Parent ref:    MFT record 5 (root directory)
│   ├─ Name:          "hello.txt"
│   └─ File size:     51,200 bytes         (cached copy)
│
├─ $DATA (type 0x80) — non-resident
│   ├─ Allocated size: 53,248 bytes (13 clusters × 4KB)
│   ├─ Data size:      51,200 bytes
│   ├─ Valid size:     51,200 bytes
│   └─ Run list:       13 clusters starting at disk cluster 8,200
│
└─ End marker (0xFFFFFFFF)

The file content is no longer in the MFT record—it lives out on disk in those 13 clusters. The $DATA attribute just holds a pointer to where the data is, along with the size information. Reading this file now takes two steps: first read the MFT record to find out where the data lives, then read the actual clusters.

Whenever the file data plus the MFT metadata grows beyond the 1KB space of the record, NTFS automatically converts the $DATA attribute from resident to non-resident—allocating clusters, moving the data out, and replacing the inline content with a run list. The transition is seamless; nothing above the filesystem layer notices.

We’ve mentioned run lists a couple of times now. Let’s look at how they actually work.

Run Lists: Mapping Files to Disk

Run lists are NTFS’s equivalent of ext4’s extents. They map virtual cluster numbers (VCNs)—positions within the file—to logical cluster numbers (LCNs)—positions on the physical disk.

On disk, each run is packed into just a few bytes: a header byte that says how wide the next two fields are, followed by the run’s length (how many clusters) and its offset (where on disk).

The offset isn’t stored as an absolute position—it’s a delta from the previous run’s LCN. The first run’s delta is relative to LCN 0, so it effectively is an absolute position. But from the second run onward, each offset is just the difference from where the previous run started.

Why go through this trouble? Because files are often allocated in nearby regions of the disk, especially after defragmentation. Nearby clusters mean small deltas, and small numbers need fewer bytes to store. A run that starts 100 clusters after the previous one only needs one byte for the offset, while an absolute LCN like 8,000,000 would need three or four.

The run list ends with a zero byte, and NTFS knows it’s done reading.

A perfectly contiguous file is just a single run—a few bytes to describe the entire allocation. A fragmented file has multiple runs. Here’s what a file stored in three fragments looks like:

┌─────┬────────┬───────────┬───────────────────┐
│ Run │ Length │ LCN Start │ Delta (from prev) │
├─────┼────────┼───────────┼───────────────────┤
│ 1   │ 5      │ 100       │ 0 + 100 = 100     │
│ 2   │ 10     │ 200       │ +100              │
│ 3   │ 3      │ 300       │ +100              │
├─────┼────────┼───────────┼───────────────────┤
│ end │ 0x00   │           │                   │
└─────┴────────┴───────────┴───────────────────┘

file clusters 0-4   → disk clusters 100-104
file clusters 5-14  → disk clusters 200-209
file clusters 15-17 → disk clusters 300-302

Let’s see this in action. Say an application wants to read byte 30,000 of this file. First, NTFS converts that byte offset to a VCN: 30,000 ÷ 4096 gives us cluster 7 in the file. Now it needs to figure out where VCN 7 lives on disk. It walks the run list: Run 1 covers VCNs 0–4, so that’s not it. Run 2 covers VCNs 5–14 starting at LCN 200—that’s our match. VCN 7 is two clusters into Run 2, so the physical location is LCN 202. One read from that cluster and the data is returned.

The in-memory run list is kept as a sorted array, and lookups use binary search—so even for heavily fragmented files with thousands of runs, finding the right cluster is fast.

Not every region of a file needs to have actual data behind it, and run lists handle that case too.

Sparse Files

Run lists also handle sparse files elegantly. Remember that each run has a header byte that says how many bytes the offset field uses? A sparse run is one where the offset field is zero bytes wide—there’s simply no disk location to point to, because no clusters are allocated for that region. When NTFS encounters a run with no offset, it knows there’s nothing to read from disk, so it just returns zeros.

A 100GB virtual machine disk image that’s mostly empty might only allocate clusters for the 5GB that’s actually been written to. The rest is sparse runs that take up no disk space at all.

So far we’ve focused on how NTFS stores file data. But how does it organize files into directories?

Directories: B-Tree Indexes

In NTFS, directories are files whose content is an index of filenames. Small directories store their entries directly inside the MFT record in an attribute called $INDEX_ROOT. As the directory grows, NTFS spills entries into external index blocks stored in an $INDEX_ALLOCATION attribute, forming a B-tree—a huge improvement over FAT32’s linear scan through all entries, especially in directories with thousands of files.

Let’s see what a small directory looks like. Imagine a documents directory containing three files:

MFT Record (1024 bytes)
├─ Header
│   ├─ Magic number: "FILE"
│   ├─ Sequence number: 1
│   ├─ Hard link count: 1
│   └─ Flags: 0x03 (in use, directory)
│
├─ $STANDARD_INFORMATION (type 0x10)
│   ├─ Created:       2026-03-15 10:00:00
│   ├─ Modified:      2026-03-15 14:22:00
│   └─ Flags:         Archive
│
├─ $FILE_NAME (type 0x30)
│   ├─ Parent ref:    MFT record 5 (root directory)
│   └─ Name:          "documents"
│
├─ $INDEX_ROOT (type 0x90) — sorted by filename
│   ├─ Entry: "hello.txt"   → MFT record 30  (size: 51,200  modified: 2026-03-15 14:22:00)
│   ├─ Entry: "notes.txt"   → MFT record 42  (size: 200     modified: 2026-03-14 09:15:00)
│   └─ Entry: "report.pdf"  → MFT record 55  (size: 524,288 modified: 2026-03-13 16:40:00)
│
└─ End marker (0xFFFFFFFF)

Notice the differences from a file record. The flags mark it as a directory, and instead of a $DATA attribute, there’s a $INDEX_ROOT attribute containing the directory entries sorted by name. Each entry uses the same $FILE_NAME structure we saw earlier—and now you can see why it duplicates the timestamps and file size. When you run dir on Windows or ls -l on Linux, NTFS can show file sizes and dates by reading just the directory entries, without touching each file’s individual MFT record.

Entries inside each index node are sorted by filename. Since NTFS is case-insensitive on Windows, it needs a way to compare filenames without caring about uppercase or lowercase. That’s what the $UpCase table is for—remember it from the system files in the MFT? It’s a Unicode uppercase mapping table stored as MFT record 10, and NTFS uses it to normalize every character before comparison. That’s how “File.txt” and “file.txt” resolve to the same file.

We’ve covered how NTFS reads data, but what keeps everything consistent when things go wrong? That’s where the journal comes in.

Journaling: $LogFile

NTFS uses write-ahead logging for crash recovery. The journal lives in $LogFile (MFT record 2)—which, following the “everything is a file” philosophy, is just another file with its own MFT record and run list.

Every metadata change—creating a file, updating an MFT record, modifying the cluster bitmap, inserting a directory entry—is written to the journal before it’s applied to the actual on-disk structures. The journal records both the new state (for redo) and enough information to reverse the change (for undo). Changes are grouped into transactions, and each transaction is sealed with a commit record. But what does all this mean in practice? Let’s walk through an example.

Consider creating a file called “test.txt”. NTFS needs to allocate an MFT record, initialize it with attributes, add a directory entry in the parent directory, and mark clusters as used in the bitmap. That’s multiple writes to different locations on disk. The journal records all of them as a single transaction. If the system crashes partway through, the next mount scans the journal: complete transactions (those with a commit record) are replayed to ensure their changes are fully applied; incomplete transactions are rolled back so no partial state remains.

This is conceptually similar to ext4’s journaling, but the approach is different. Ext4 uses physical logging—it saves before/after images of entire blocks. NTFS uses logical logging—it records the operations themselves, like “insert this index entry” or “update this attribute.” The logical approach gives NTFS more precise undo and redo capabilities, but it’s also more complex to implement since recovery needs to understand the semantics of each operation.

Beyond reliability, NTFS also has a few tricks for saving disk space. Let’s look at how it handles compression.

Compression

NTFS supports transparent file compression using an algorithm called LZNT1, an LZ77 variant. Files are compressed in units of 16 clusters (typically 64KB). Each unit is compressed independently: if the compressed result fits in fewer clusters than the original 16, the compressed data is stored; if not, the unit is stored uncompressed. Units that are entirely zeros don’t get stored at all—they become sparse runs.

The compression is transparent to applications. A program reading a compressed file sees the original uncompressed data; NTFS handles decompression on the fly. The run list for a compressed file reflects the actual disk space used, so a 64KB compression unit that shrinks to 24KB occupies just 6 clusters instead of 16.

Modern Windows also supports additional compression formats through the Windows Overlay Filter (WOF)—XPRESS and LZX, which offer different compression ratios and speed tradeoffs.

Now that we’ve seen all the individual pieces—the MFT, attributes, run lists, directories, journaling, and compression—let’s watch them work together.

Putting It All Together

Let’s trace what happens when you open and read a file at /documents/report.txt.

We start at the root directory—MFT record 5. We read that record and find its $INDEX_ROOT attribute, which contains directory entries sorted by name. We search for “documents” using binary search on the B-tree index. We find a match, and the entry tells us the MFT record number for the documents directory.

We read that MFT record, find its index, and search for “report.txt”. We find the entry, which gives us the MFT record number for the file itself—say, record 150.

Now we read MFT record 150. We verify the magic number is FILE and parse the attributes. The $STANDARD_INFORMATION attribute gives us timestamps and permissions. The $FILE_NAME attribute confirms the name. The $DATA attribute tells us whether the content is resident or non-resident.

If the file is small—say, 500 bytes—the data is resident, sitting right there in the MFT record. Logically, we’ve accessed three MFT records: root directory, parent directory, and file record. In practice, the MFT is often cached in memory, so not all of these may actually hit disk.

If the file is larger, the $DATA attribute contains a run list. We look up the byte range we need, find the corresponding runs, calculate the physical cluster addresses, and read the data from disk. For a contiguous file, the run list has a single entry and the reads are sequential. For a fragmented file, we might need to read from multiple locations.

Writing follows a similar path but with journaling involved. NTFS opens a transaction, logs all the metadata changes it’s about to make (updating the MFT record, modifying the bitmap, inserting directory entries), writes the actual data to clusters, and then commits the transaction. If power fails at any point, the journal ensures consistency on the next mount.

Summary

So that’s NTFS. The boot sector points us to the Master File Table, and from there everything unfolds. The MFT is a flat array of records—one per file, one per directory, and one per piece of system metadata, because in NTFS everything is a file. Each record is a container of typed attributes: timestamps, filenames, and data are all stored in the same uniform format.

Small files live entirely inside their MFT record as resident attributes—one disk read gets you everything. Larger files use run lists to map virtual cluster numbers to physical locations on disk, with delta encoding to keep the on-disk representation compact and sparse runs for files with holes. Directories are B-tree indexes that give fast lookups instead of FAT32’s linear scans. The journal records every metadata change as a transaction, so crashes never leave the filesystem in an inconsistent state. And transparent compression lets NTFS save disk space without applications needing to know about it.

Where FAT32 is simple and universal, and ext4 is fast and reliable, NTFS is feature-rich and self-consistent. That “everything is a file” philosophy means one set of mechanisms—attributes, run lists, journaling—handles both your data and the filesystem’s own bookkeeping. It’s an elegant idea that has kept NTFS relevant for over three decades.

In the next article , we’ll look at XFS—a filesystem designed from the ground up for high performance and massive scalability.


Want to dive deeper? The ntfs3 driver lives in fs/ntfs3/ in the Linux kernel tree. Key files include super.c (mounting), inode.c (inode operations), fslog.c (journaling), index.c (B-tree directories), attrib.c (attribute handling), and run.c (run list management).