Reading a file usually means calling read() and copying bytes into a buffer that your program manages. That model is explicit and works well for most file I/O.

Linux offers another model with mmap(): map a file region into the process’s virtual address space, then access the file through ordinary memory loads and stores.

That can simplify workloads such as random access into large files, shared file-backed state, indexes, and binary formats whose access pattern naturally looks like “read bytes at offset N.” It can also avoid an application-managed read buffer for those accesses.

But mmap() is not simply a faster read(). It changes where I/O boundaries appear, how errors can surface, and how file-size changes affect a running process. A memory access can trigger filesystem work later through a page fault, and accessing a mapped page beyond the current end of the file can deliver SIGBUS.

The useful mental model is:

mmap() creates a virtual-memory mapping. The bytes look like memory to your program, while the kernel connects mapped pages to a file or to private copy-on-write pages according to the mapping flags.

Once that model is clear, the differences between MAP_SHARED, MAP_PRIVATE, msync(), and ordinary buffered I/O become much easier to reason about.

Start with the smallest read-only mapping

Suppose message.txt contains:

hello from mmap

A minimal C program can map the file and write its bytes to standard output:

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(void) {
    int fd = open("message.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return EXIT_FAILURE;
    }

    struct stat st;
    if (fstat(fd, &st) == -1) {
        perror("fstat");
        close(fd);
        return EXIT_FAILURE;
    }

    if (st.st_size == 0) {
        close(fd);
        return EXIT_SUCCESS;
    }

    size_t length = (size_t)st.st_size;

    const char *data = mmap(
        NULL,
        length,
        PROT_READ,
        MAP_PRIVATE,
        fd,
        0
    );

    if (data == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return EXIT_FAILURE;
    }

    close(fd);

    size_t written = 0;
    while (written < length) {
        ssize_t n = write(
            STDOUT_FILENO,
            data + written,
            length - written
        );

        if (n == -1) {
            perror("write");
            munmap((void *)data, length);
            return EXIT_FAILURE;
        }

        written += (size_t)n;
    }

    if (munmap((void *)data, length) == -1) {
        perror("munmap");
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

Several details are important even in this small example.

The mapping length must be greater than zero, so the program handles an empty file before calling mmap().

PROT_READ says the process may read from the mapped pages. MAP_PRIVATE says writes, if the mapping also allowed them, would be private copy-on-write changes rather than updates to the underlying file.

The first argument is NULL, which lets the kernel choose the mapping address. This is the normal choice unless a program has a specialized reason to control virtual addresses.

The final argument, 0, is the file offset where the mapping begins.

After mmap() succeeds, the file descriptor can be closed without invalidating the mapping. The mapping is a separate kernel-managed resource and remains valid until it is unmapped or the process exits.

Treat mmap as an address-space operation, not a read call

A successful read() transfers bytes as part of that system call:

read(fd, buffer, 4096)
        |
        v
application buffer receives bytes

A successful mmap() does something different:

mmap(...)
   |
   v
virtual address range is created

later:
data[1234]
   |
   v
memory access may trigger a page fault
   |
   v
kernel resolves the page

mmap() establishes the relationship between a virtual-address range and the backing object. It does not imply that every mapped byte has already been read from storage.

When the process first touches a page that is not currently resident, the CPU raises a page fault. The kernel handles the fault, obtains the required page if necessary, updates the process’s page tables, and resumes the instruction.

This is why mapping a very large file does not mean the process immediately consumes the same amount of physical RAM. The mapping reserves virtual-address space; resident physical pages are managed separately.

It also explains an important performance trade-off. mmap() can be convenient for sparse or random access because the program can address bytes directly. But page faults introduce work at memory-access points, which can make latency less explicit than a deliberate pread() or read() call.

MAP_PRIVATE gives you copy-on-write semantics

For a file-backed MAP_PRIVATE mapping, modifications are private to the process and are not carried through to the underlying file.

A writable private mapping looks like this:

char *data = mmap(
    NULL,
    length,
    PROT_READ | PROT_WRITE,
    MAP_PRIVATE,
    fd,
    0
);

Conceptually, the process begins by observing file-backed pages:

process mapping
      |
      v
file-backed page

When the process writes to a page, the kernel can give that mapping a private copy:

before write:

process -> file-backed page

after private write:

process -> private modified page
file    -> original file data

This behavior is called copy-on-write. The important guarantee is the visibility rule: changes made through a MAP_PRIVATE mapping are not written back to the underlying file.

Do not depend on a private mapping as a coherent live view of a file that another process is modifying. Linux documents that whether later changes to the underlying file are visible through an existing MAP_PRIVATE mapping is unspecified.

Private mappings are therefore useful when the file provides initial content but process-local modifications should remain private.

MAP_SHARED makes stores update the mapped file

MAP_SHARED changes the write semantics:

char *data = mmap(
    NULL,
    length,
    PROT_READ | PROT_WRITE,
    MAP_SHARED,
    fd,
    0
);

For a file-backed shared mapping, updates are visible to other processes mapping the same region and are carried through to the underlying file.

A small update can look like this:

data[0] = 'H';

That store modifies the shared mapped page rather than creating a process-private replacement.

There is an important permission boundary: if you request PROT_WRITE with MAP_SHARED, the file descriptor must have been opened with a mode that permits writing, such as O_RDWR. Opening the file only with O_RDONLY and then requesting a writable shared mapping fails.

Shared mapping does not automatically provide application-level synchronization. If two processes concurrently modify the same bytes, MAP_SHARED does not define a transaction, record lock, message protocol, or conflict-resolution rule for you.

If multiple writers must preserve invariants, they still need an appropriate synchronization design.

Use msync when you need explicit writeback timing

A shared writable mapping lets modified pages be carried through to the underlying file. That does not mean every store immediately reaches durable storage.

When an application needs explicit control over synchronization of a mapped file region, Linux provides msync():

if (msync(data, length, MS_SYNC) == -1) {
    perror("msync");
}

MS_SYNC waits for the requested synchronization to complete before returning.

This distinction matters for durable-state designs:

CPU store
   |
mapped page becomes dirty
   |
writeback may happen later
   |
storage

A store to a MAP_SHARED mapping is therefore not equivalent to a durability barrier.

msync() is specifically about mapped regions. It should not be treated as a universal replacement for the broader crash-consistency protocol an application may need. For example, a multi-file update can still require ordering and directory synchronization decisions that msync() alone does not solve.

For read-only mappings and private mappings whose changes should never be persisted, there is normally no reason to call msync() for writeback.

Map only file ranges that actually exist

One of the most important mmap() failure modes happens after the call succeeds.

Linux can deliver SIGBUS when a process accesses a mapped page that lies beyond the end of the mapped file.

Consider this sequence:

1. process maps a 16 KiB file
2. another process truncates the file to 4 KiB
3. first process touches a page that is now beyond EOF
4. first process can receive SIGBUS

This surprises developers because the failing operation looks like an ordinary memory access:

char value = data[8192];

There is no read() call at that line to return an error code. The error appears as a signal because the virtual address exists in the mapping but the backing file no longer has a valid page for that access.

This makes concurrent file truncation especially dangerous for memory-mapped readers.

If another component replaces a file by atomically renaming a new inode over the pathname, an existing mapping continues to refer to the old mapped object. That is different from truncating the same underlying file. Designs that publish immutable files and replace pathnames can therefore avoid some in-place resize hazards, though readers still need an application-level rule for discovering new versions.

Remember that offsets are page-aligned

The file offset passed to mmap() must be a multiple of the system page size.

You can query the page size with:

long page_size = sysconf(_SC_PAGE_SIZE);

Suppose you want bytes starting at file offset 5000, while the page size is 4096. Passing 5000 directly as the mmap() offset is invalid.

Instead, align the mapping down to a page boundary:

wanted offset:      5000
page size:          4096
mapping offset:     4096
delta inside map:    904

The program maps from offset 4096, then begins reading 904 bytes into the returned mapping.

A common calculation is:

off_t map_offset = offset - (offset % page_size);
off_t delta = offset - map_offset;

The mapping length must include that leading delta:

size_t map_length = delta + wanted_length;

The length passed to mmap() does not itself have to be an exact multiple of the page size.

For production code, also validate integer conversions and overflow when combining offsets and lengths. File sizes may be wider than size_t on some targets, and blindly converting arithmetic into an unsigned size can produce an incorrect mapping request.

Do not access a mapping after munmap

munmap() removes the mapping:

if (munmap(data, length) == -1) {
    perror("munmap");
}

After the relevant pages have been unmapped, accessing those addresses is invalid.

That means pointers into the mapping have the same lifetime boundary:

const char *record = data + 128;

munmap(data, length);

/* record is no longer usable here */

The pointer value may still contain an address numerically, but the process no longer has the mapping that made that address valid for this purpose.

This becomes a maintainability concern in larger programs. If parsing code stores pointers into a mapping, the owner of the mapping must outlive every consumer of those pointers.

One safer API design is to keep the mapping inside an object whose lifetime is explicit, or to copy small values out when they must survive independently.

mmap does not remove the page cache

It is easy to describe memory mapping as “reading without the kernel’s file cache,” but that is not the normal model for ordinary Linux file-backed mappings.

File-backed mappings participate in the kernel’s page-cache machinery. Ordinary buffered file I/O also uses the page cache in common cases.

The meaningful distinction is therefore not simply “cached versus uncached.” It is how the application accesses the cached data:

  • read() copies file data into an application-managed buffer;
  • mmap() exposes mapped pages through virtual addresses.

That can reduce copying between the page cache and a separate userspace buffer for some workloads, but it does not guarantee that mmap() will be faster overall.

Page-fault cost, access locality, mapping setup, virtual-address pressure, synchronization, and the amount of data actually touched all matter.

Measure the workload you care about rather than choosing mmap() solely because it is described as zero-copy.

Random access is where mmap often fits naturally

Consider a large immutable index containing fixed-size records. If the program repeatedly looks up records at unpredictable offsets, memory mapping can make the access path straightforward:

const struct record *records = mmap(...);

const struct record *item = &records[index];

The kernel can populate pages as they are touched, and the application does not need to issue a separate pread() for every lookup or maintain its own block cache.

That design is most attractive when:

  • the file format supports direct offset-based lookup;
  • the file remains stable while mapped;
  • the process has sufficient virtual-address space;
  • pointer-like access simplifies the code;
  • measured page-fault behavior is acceptable.

There are still format concerns. Mapping bytes does not make an on-disk C struct representation portable. Padding, alignment, endianness, integer widths, and format-version changes still need deliberate design.

For portable file formats, decode fields from a documented byte representation rather than assuming an in-memory compiler layout is the file format.

Sequential streaming may be simpler with read

If a program consumes a file once from beginning to end, a conventional read loop is often easier to reason about:

char buffer[64 * 1024];

for (;;) {
    ssize_t n = read(fd, buffer, sizeof buffer);

    if (n == 0) {
        break;
    }

    if (n < 0) {
        perror("read");
        break;
    }

    consume(buffer, (size_t)n);
}

The I/O boundary is explicit, errors are reported by read(), and buffer lifetime is straightforward.

For large sequential workloads, Linux can already perform readahead around normal file I/O. An mmap() rewrite is not automatically an optimization.

Use measurement when performance is the motivation. If the conventional code is already clear and fast enough, mapping can add lifecycle and signal-related complexity without solving a real problem.

Common mistakes come from treating mapped memory like ordinary heap memory

Assuming mmap has already loaded the whole file

A successful mapping establishes virtual-address mappings. Pages can still be brought in later when they are touched.

If worst-case latency matters, account for page faults rather than measuring only the mmap() call.

Using MAP_SHARED as a concurrency protocol

Shared visibility is not mutual exclusion.

Two writers can still race or publish inconsistent multi-field state. Use synchronization whose guarantees match the data structure and the processes involved.

Modifying or truncating a mapped file without a lifecycle rule

Changing the file’s size while readers retain mappings can create invalid accesses and SIGBUS.

Prefer a clear ownership or publication protocol. Immutable versioned files are often easier to reason about than in-place resizing under active readers.

Assuming a writable mapping is durable after a store

A store modifies memory backed by the mapping. Persistence timing is a separate concern.

Use the synchronization primitives required by your durability model, and test crash behavior when durability is a correctness requirement.

Mapping an empty file

mmap() requires a nonzero mapping length. Handle zero-length files before mapping them.

Treating mmap as automatically faster

Memory mapping can remove some explicit copies and simplify random access, but it also moves work into page faults and virtual-memory management.

Benchmark representative data sizes and access patterns.

Choose mmap when its memory model matches the problem

mmap() is a strong fit when treating a stable file region as an addressable byte array makes the program simpler.

Typical examples include large read-only indexes, binary databases with direct offsets, shared file-backed regions, and workloads that touch only selected portions of a large file.

Prefer ordinary read() or pread() when explicit I/O boundaries are useful, when files are frequently resized, when access is naturally streaming, or when the additional mapping lifetime and signal behavior would complicate the design.

The choice is not between a slow API and a fast API. It is between two different I/O models.

Conclusion

Linux mmap() connects virtual memory to a file so programs can access file-backed data through ordinary addresses.

The most important distinctions are practical:

  • MAP_PRIVATE provides private copy-on-write modifications;
  • MAP_SHARED carries modifications through to the backing file and can expose them to other mappings;
  • msync() provides explicit mapped-region synchronization when writeback timing matters;
  • file offsets must be page-aligned;
  • closing the original file descriptor does not destroy the mapping;
  • changing a mapped file’s size can make later memory accesses fail with SIGBUS;
  • and mmap() should be chosen because its access and lifetime model fits the workload, not because it is assumed to be faster.

If you keep the virtual-memory mental model in view, memory-mapped I/O becomes a predictable systems tool rather than a mysterious alternative to read().