C++ Modules (C++20)


  • Description: A note on C++20 modules — import, export, module partitions, header units, the global module fragment, and migration from #include
  • My Notion Note ID: K2A-B1-27
  • Created: 2022-03-15
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Why Modules?

#include literally pastes header text into every translation unit (TU). For a large project, the same headers are reparsed thousands of times, and any non-trivial macro can leak into unrelated code.

Modules solve both:

  1. One-time compilation. A module is parsed once, then imported as a precompiled artifact (a BMI — Binary Module Interface).
  2. Hard isolation. Macros, internal symbols, and unrelated declarations don't leak across the module boundary.
  3. Faster builds. No re-parsing the same headers in every TU.
  4. Cleaner ABI control. Only exported symbols are visible to importers.

Modules are not a runtime feature — they change the build model, not the generated code.


2. Importing a Module

import std;                  // C++23: the entire standard library as one module
import std.compat;           // C++23: same as std plus C library names in the global namespace
import math;                 // your own module
import :helpers;             // a module partition (only inside another module)

import looks like a statement, but it must appear at the top of the file (after any global module fragment). It does not introduce names into the current scope — only the names the imported module exports.


3. Defining a Module

A module's primary interface unit declares the module and what it exports.

// math.cppm                 (the .cppm extension is convention; not required)
export module math;          // primary interface

export int add(int a, int b) {     // exported, visible to importers
    return a + b;
}

int internal(int x) {              // NOT exported; internal to this TU
    return x * 2;
}

export {                            // export multiple declarations at once
    int sub(int a, int b);
    int mul(int a, int b);
}

Implementation files (no export keyword on the module declaration) provide bodies for declarations:

// math_impl.cpp
module math;                       // implementation unit (no `export`)

int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

4. Module Partitions

Large modules can be split into partitions — submodules that share the parent module's name.

// math-trig.cppm
export module math:trig;           // partition of math

export double sin(double x);
export double cos(double x);

// math.cppm
export module math;
export import :trig;               // re-export the partition

Importers see one logical module:

import math;
math::sin(0.5);

Partitions can also be private (not re-exported):

// math.cppm
export module math;
import :impl_helpers;              // not re-exported; for use only inside math

5. Header Units

Header units let you import an existing header as if it were a module — useful during migration.

import <vector>;          // standard header as a module
import "myheader.h";      // your header as a module

Macros from the header still work (header units preserve them, unlike named modules), but you get faster compilation. Header units are a stepping stone, not the final destination.


6. Global Module Fragment

Sometimes a module needs to use a #include-only header (one that hasn't been modularized). The global module fragment at the top lets you do this:

module;                            // begin global module fragment
#include <vector>                   // legacy header
#include "legacy_macro.h"           // bring in macros, types, etc.

export module myapp;                // begin the module proper

import std;                         // can also use proper imports

export void foo(std::vector<int>);

The global module fragment is a holdover for legacy interop. New code should prefer import and import <header>;.


7. Migration from #include

A practical migration path for an existing codebase:

  1. Convert the heaviest, most-included headers first (the ones in many TUs' include graph). Convert them to header units (import <header>;) for an easy win.
  2. Modularize internal libraries as named modules. Drop #include "internal.h" in favor of import internal;.
  3. Move public APIs to modules last, since their interface and ABI are more visible.
  4. Don't mix lifestyles needlessly — once a header is a module, its consumers should use import.

Module-aware compilers can usually still consume #include for unported headers, so you don't need a flag day.


8. Compiler and Build-System Support

As of late 2025:

  1. MSVC — strongest support. Named modules work with /std:c++20. import std; requires /std:c++latest (C++23 standard library named modules).
  2. Clang — named modules solid since Clang 16; import std; since Clang 18 (with libc++ configured to ship the std module).
  3. GCC — improving, but import std; and some advanced features lag the others.
  4. CMake — named-module support added in 3.28 (controlled by CMAKE_CXX_SCAN_FOR_MODULES); import std; integration added in CMake 3.30.
  5. Build systems other than CMake — Bazel, Meson, etc. each have their own module integrations; check vendor docs.

Reality check: modules deliver real build-time wins, but the toolchain is still maturing. Most existing codebases are still on #include. Expect to mix both for a while.