Skip to content
C++
Language
since C++20
Intermediate

Modules (C++20)

"C++20 modules replace preprocessor-based #include with a compile-once binary interface model: no macro leakage, no include-order bugs, dramatically faster builds."

Modulessince C++20

A module is a named compilation unit whose exported interface is captured in a Binary Module Interface (BMI) file compiled once, allowing any number of importers to consume declarations without re-parsing source β€” eliminating macro leakage, include-order fragility, and redundant parsing work.

Overview

The preprocessor-based #include model has a fundamental scaling problem: every header is textually pasted into every translation unit that includes it. A project with n TUs and m headers does O(nΓ—m) parsing work, and every #include can alter the meaning of everything that follows it through macros.

C++20 modules fix this at the language level. A module interface unit is compiled once into a BMI (.pcm for Clang, .ifc for MSVC, .gcm for GCC). Every subsequent import reads that binary artifact β€” no reparsing, no macro side-effects, and the compiler can express the full module dependency graph to the build system.

Three structural guarantees distinguish modules from headers:

  • Macro isolation: macros defined inside a module do not escape to importers, and macros from importers do not affect the module. Preprocessing and the module boundary are entirely separate.
  • Hygiene: the order in which you import modules is irrelevant to the meaning of each module.
  • Ownership: an entity defined in a module has module linkage unless exported. It cannot collide with same-named entities in other modules, even without a namespace.

Syntax

Module interface unit

cpp
// geometry.cppm β€” module interface unit (C++20)
export module geometry;        // declares this TU as the interface for "geometry"

import <cmath>;                // header unit: wraps <cmath> as a module
import <string_view>;

// internal linkage β€” invisible and inaccessible to importers
namespace detail {
    constexpr double kPi = 3.141592653589793;
}

export struct Point {          // exported β€” visible to importers
    double x, y;
};

export double distance(Point a, Point b) {
    double dx = a.x - b.x, dy = a.y - b.y;
    return std::sqrt(dx*dx + dy*dy);
}

export double to_degrees(double radians) {   // exported
    return radians * 180.0 / detail::kPi;
}

// export block: groups multiple declarations
export {
    int manhattan(Point a, Point b);
    bool coincident(Point a, Point b, double eps = 1e-9);
}

Only one module interface unit may exist per module name. Everything exported becomes part of the public interface captured in the BMI.

Module implementation unit

cpp
// geometry.cpp β€” implementation unit (C++20)
module geometry;               // no "export" keyword

// Can see everything declared in the interface, including non-exported internals
int manhattan(Point a, Point b) {
    return static_cast<int>(std::abs(a.x - b.x) + std::abs(a.y - b.y));
}

bool coincident(Point a, Point b, double eps) {
    return distance(a, b) < eps;
}

Implementation units are compiled separately but link into the same module. They can access non-exported declarations from the interface; importers cannot.

Module partitions

Large modules can be subdivided into partitions β€” internal compilation units that aggregate into the primary module:

cpp
// geometry-shapes.cppm β€” partition (C++20)
export module geometry:shapes;    // partition name: geometry:shapes

export struct Circle { double cx, cy, r; };
export struct Rect   { double x, y, w, h; };
export double area(Circle c);
export double area(Rect r);
cpp
// geometry.cppm β€” primary interface re-exports partitions
export module geometry;

export import :shapes;            // re-export the partition's interface
export import :algorithms;        // additional partition

Partitions are an implementation detail of the module; importers only import geometry, never import geometry:shapes directly (unless the primary re-exports it).

Global module fragment

When a module needs legacy headers β€” especially ones that define macros consumed by the module body β€” use the global module fragment:

cpp
module;                           // global module fragment begins
#include <cassert>
#include "third_party_c_api.h"   // defines macros used below

export module mymodule;           // module proper begins here
// #include is disallowed past this point

export void checked_op(int x) {
    assert(x >= 0);               // macro from GMF is visible here
}

The global module fragment is preprocessed normally; only its macro definitions survive into the module. Its declarations do not become part of the module interface.

Private module fragment

cpp
// single-file module: interface + implementation in one TU (C++20)
export module widget;

export class Widget {
public:
    explicit Widget(int id);
    void render() const;
private:
    struct Impl;
    Impl* impl_;                  // pimpl β€” Impl is defined below the private boundary
};

module : private;                 // private module fragment β€” not part of the BMI

struct Widget::Impl {
    int id;
    // ... implementation details
};

Widget::Widget(int id) : impl_(new Impl{id}) {}
void Widget::render() const { /* ... */ }

Declarations after module : private; are invisible to importers and do not affect the BMI. Changing only the private fragment does not trigger recompilation of importers β€” a significant build performance property.

Standard library modules (C++23)

cpp
// C++23 β€” replaces all standard headers
import std;                       // entire ISO C++ standard library
import std.compat;                // std + C names injected into global namespace

import std; is substantially faster than including individual standard headers and is available in MSVC (VS 2022 17.5+), Clang 17+, and GCC 15+. For new C++23 projects targeting supported toolchains, prefer it unconditionally.

Examples

Splitting a real module across interface and implementation

cpp
// logger.cppm β€” interface (C++20)
export module logger;

import <string_view>;
import <source_location>;         // C++20

export enum class Level { Debug, Info, Warn, Error };

export class Logger {
public:
    explicit Logger(std::string_view name);
    void log(Level level, std::string_view msg,
             std::source_location loc = std::source_location::current());
    void set_min_level(Level l) noexcept;
private:
    struct State;
    State* state_;
};
cpp
// logger.cpp β€” implementation (C++20)
module logger;

import <iostream>;
import <format>;                  // C++20

struct Logger::State {
    std::string name;
    Level min_level = Level::Debug;
};

Logger::Logger(std::string_view name)
    : state_(new State{std::string(name)}) {}

void Logger::log(Level level, std::string_view msg,
                 std::source_location loc) {
    if (level < state_->min_level) return;
    std::cout << std::format("[{}] {}:{}: {}\n",
        state_->name, loc.file_name(), loc.line(), msg);
}

void Logger::set_min_level(Level l) noexcept { state_->min_level = l; }
cpp
// main.cpp β€” consumer (C++20)
import logger;

int main() {
    Logger log{"app"};
    log.set_min_level(Level::Info);
    log.log(Level::Info, "starting up");   // prints with file/line
    log.log(Level::Debug, "verbose");      // suppressed
}

Build System Support

CMake 3.28+

CMake 3.28 introduced native module support. It requires the Ninja generator (Make cannot express module dependency ordering):

cmake
cmake_minimum_required(VERSION 3.28)
project(geometry CXX)
set(CMAKE_CXX_STANDARD 20)

add_library(geometry)
target_sources(geometry
    PUBLIC FILE_SET CXX_MODULES FILES
        geometry.cppm
        geometry-shapes.cppm
    PRIVATE
        geometry.cpp
)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE geometry)

Clang (manual)

bash
# Step 1: precompile the module interface β†’ .pcm (Binary Module Interface)
clang++ -std=c++20 --precompile geometry.cppm -o geometry.pcm

# Step 2: compile the implementation, referencing the BMI
clang++ -std=c++20 -fprebuilt-module-path=. -c geometry.cpp -o geometry.o

# Step 3: compile and link the consumer
clang++ -std=c++20 -fprebuilt-module-path=. main.cpp geometry.o -o app

GCC 14+

bash
# GCC uses .gcm files stored in a gcm.cache directory
g++ -std=c++20 -fmodules-ts -c geometry.cppm
g++ -std=c++20 -fmodules-ts main.cpp geometry.o -o app

MSVC

MSVC has had first-class module support since VS 2019 16.10. Use .ixx or .cppm; the build system handles BMI (.ifc) generation automatically when using MSBuild or CMake.

Best Practices

One module interface unit per module. Multiple implementation units are fine; multiple interface units are an error. Keep the interface lean and let implementation units carry the bulk.

Prefer module : private; for pimpl and hidden implementation. Anything after the private boundary does not recompile importers when changed β€” a free encapsulation win.

Do not export using-declarations for entire namespaces. export using namespace std; effectively re-exports the entire standard library into every importer's scope, which defeats the precision of module interfaces.

Use export import deliberately. Re-exporting a dependency makes it part of your module's public API. Only re-export what your interface genuinely depends on in its surface; otherwise consumers can't predict what they're getting.

Avoid macros crossing module boundaries entirely. Macros that must be shared belong in a separate header included in the global module fragment by every module that needs them, or better: replaced with constexpr/consteval equivalents.

Common Pitfalls

Macros cannot be exported. A macro #define in a module interface is not visible to importers β€” the module boundary is a hard preprocessor firewall. Replace shared macros with constexpr constants or consteval functions.

BMIs are not portable. A .pcm or .ifc compiled by one compiler version is not consumable by a different version, or even a different compilation mode (debug vs. release on some toolchains). Treat BMIs as build artifacts, never distributed headers.

Compilation order is enforced. Module interface units must be compiled before any TU that imports them. Misconfigured build systems produce cryptic "module not found" errors. Use CMake 3.28+ or a build system with proper dependency scanning.

import <header> is not the same as #include <header>. Header units wrap a header's declarations into a module-like interface, but macro definitions in header units behave differently from those in #included headers. Prefer import std; (C++23) or named modules over header units whenever possible.

Visibility vs. reachability. A type returned by an exported function from an imported module is reachable (you can use it) even if the module that defines it was not explicitly imported. It is not, however, visible β€” unqualified lookup will not find it. If consumers need to name the type, they must import the module that defines it, or the owning module must export import it.

Module units and partitions

A module can be spread across many compilation units, each playing a distinct role. The primary module interface unit (export module foo;) is the single source of truth for the public API. Implementation units (module foo;) supply definitions. Partition interface units (export module foo:bar;) let large modules split their exported API across files while keeping a single logical name visible to consumers.

cpp
// math.cppm β€” primary interface (C++20)
export module math;

export import :algebra;     // re-export partition's interface
export import :geometry;    // consumers see everything from both partitions
cpp
// math-algebra.cppm β€” partition interface unit (C++20)
export module math:algebra;

export int gcd(int a, int b);
export int lcm(int a, int b);
cpp
// math-algebra.cpp β€” partition implementation unit (C++20)
module math:algebra;        // implements the :algebra partition

int gcd(int a, int b) { return b ? gcd(b, a % b) : a; }
int lcm(int a, int b) { return a / gcd(a, b) * b; }

Importers only ever write import math; β€” they are never aware of how the module is partitioned internally. Partitions that are not re-exported by the primary interface remain entirely invisible outside the module.

Private module fragment

The private module fragment turns a single file into a complete self-contained module while protecting implementation details from the BMI. Everything after module : private; is excluded from the binary interface, so changes there do not force recompilation of any importer.

cpp
// counter.cppm β€” single-file module with private fragment (C++20)
export module counter;

export class Counter {
public:
    explicit Counter(int start = 0) noexcept;
    Counter& operator++() noexcept;
    int value() const noexcept;
private:
    struct Impl;
    Impl* impl_;
};

module : private;           // nothing below enters the BMI

struct Counter::Impl { int val; };

Counter::Counter(int start) noexcept : impl_(new Impl{start}) {}
Counter& Counter::operator++() noexcept { ++impl_->val; return *this; }
int Counter::value() const noexcept { return impl_->val; }

Only one private fragment is allowed per module, and it is only valid in a module with no separate implementation units. It cannot be used in a partition.

Module interface vs implementation units

The split between interface and implementation units mirrors the old .h/.cpp split but with stricter semantics. The interface unit owns the BMI; implementation units can see non-exported declarations from the interface but add no new exports.

cpp
// network.cppm β€” interface unit (C++20)
export module network;

import <string_view>;
import <cstdint>;

export class Socket {
public:
    explicit Socket(std::string_view host, std::uint16_t port);
    bool connect();
    void send(std::string_view data);
    void close() noexcept;
private:
    struct Conn;          // forward-declared; defined only in implementation
    Conn* conn_;
};

// internal helper β€” visible to implementation units, invisible to importers
std::string format_endpoint(std::string_view host, std::uint16_t port);
cpp
// network.cpp β€” implementation unit (C++20)
module network;

import <format>;
import <stdexcept>;

// Full definition lives here β€” out of the BMI entirely
struct Socket::Conn {
    std::string host;
    std::uint16_t port;
    int fd = -1;
};

std::string format_endpoint(std::string_view host, std::uint16_t port) {
    return std::format("{}:{}", host, port);   // internal helper visible here
}

Socket::Socket(std::string_view h, std::uint16_t p)
    : conn_(new Conn{std::string(h), p}) {}

bool Socket::connect() {
    // ... platform connect() call
    return true;
}

void Socket::send(std::string_view data) { /* ... */ }
void Socket::close() noexcept { /* ... */ }

Splitting into multiple implementation units is purely additive β€” add more .cpp files with module network; at the top and they become part of the same module without touching the interface.

Header units

Header units are a compatibility bridge that lets you import a legacy header without converting it to a named module. The compiler wraps the header in a module-like envelope, giving you faster repeated inclusion and some macro isolation, but not the full isolation of a true named module.

cpp
// C++20 β€” header unit syntax
import <vector>;            // header unit for standard header
import <cstdio>;            // C compatibility header as header unit
import "mylegacy.h";        // local header as header unit
cpp
// contrasting named module vs header unit behaviour (C++20)
import <cassert>;           // macros from <cassert> ARE exported to importer
                            // (header unit exception β€” macros leak, unlike named modules)

export module mymod;
import <cassert>;           // now macros from cassert are available inside mymod
                            // but NOT propagated to importers of mymod

Header units are not fully portable: the compiler must be told which headers to treat as importable (via -fmodule-header on Clang or equivalent). Prefer import std; (C++23) for standard headers and true named modules for your own code. Use header units only as a migration stepping stone.

Migrating from headers to modules

Migration is incremental β€” you can mix #include and import freely within a translation unit. The recommended path is to convert one library at a time, starting with stable, widely-used utilities.

Step 1 β€” wrap an existing header in a module interface unit without changing the header itself:

cpp
// compat/strings.cppm β€” thin module wrapper (C++20)
module;
#include "strings.h"        // existing header goes in global module fragment

export module compat.strings;

// Re-export the declarations you want consumers to use
export using strings::trim;
export using strings::split;
export using strings::join;

Step 2 β€” replace #include "strings.h" with import compat.strings; one consumer at a time. The header still exists and still works for unconverted TUs.

Step 3 β€” once all consumers are migrated, move definitions into the module proper and delete the header:

cpp
// strings.cppm β€” final named module (C++20)
export module strings;

import <string>;
import <string_view>;
import <vector>;

export std::string trim(std::string_view s);
export std::vector<std::string> split(std::string_view s, char delim);
export std::string join(const std::vector<std::string>& parts, std::string_view sep);

Do not attempt a big-bang migration of an entire codebase at once. Modules interact poorly with certain precompiled-header workflows; disable PCH for module-enabled targets.

Modules and templates

Templates defined in a module interface are fully instantiable by importers β€” the compiler can always see their definitions through the BMI. This eliminates the old #include workaround of putting template definitions in headers and is one of the cleanest wins of the module system.

cpp
// containers.cppm β€” module exporting a template (C++20)
export module containers;

import <concepts>;
import <memory>;
import <stdexcept>;

export template<typename T>
    requires std::movable<T>
class Stack {
public:
    void push(T val) { data_[size_++] = std::move(val); }
    T pop() {
        if (size_ == 0) throw std::underflow_error{"empty stack"};
        return std::move(data_[--size_]);
    }
    [[nodiscard]] bool empty() const noexcept { return size_ == 0; }
    [[nodiscard]] std::size_t size() const noexcept { return size_; }
private:
    T data_[256]{};
    std::size_t size_ = 0;
};
cpp
// main.cpp β€” instantiation across the module boundary (C++20)
import containers;

int main() {
    Stack<int> s;
    s.push(1); s.push(2); s.push(3);
    while (!s.empty())
        (void)s.pop();   // instantiated here; definition is in the BMI
}

Explicit instantiations (template class Stack<int>;) in an implementation unit still work and can reduce per-TU instantiation cost for expensive templates. Friend declarations inside exported class templates must name fully qualified types to avoid ODR issues across translation units.

See Also

  • export β€” declaration-level export syntax
  • Concepts β€” C++20 constraints, frequently paired with modules
  • Header Units β€” wrapping legacy headers as importable units
  • import std β€” C++23 standard library modules