C++20 Modules Explained for Practical Developers

C++20 modules are the biggest change to C++ compilation since the language was standardized in 1998. They replace the textual #include mechanism with a proper module system that's faster, safer, and more explicit. Yet adoption remains slow, partly because the tooling hasn't kept up and partly because the concepts are poorly explained.

This guide cuts through the confusion. No committee language, no corner cases. Just what you need to know to start using modules in real projects.

The Problem with Headers

Before understanding modules, you need to understand what's wrong with the system they replace.

When you write #include <vector>, the preprocessor literally copies the contents of that header file into your source file. For a typical standard library header, that's thousands of lines. For a project that includes the same header in 100 translation units, that's the same code parsed 100 times.

The consequences:

  • Slow compilation: Large projects spend most of their compile time re-parsing the same headers
  • Macro pollution: Macros defined in one header leak into every file that includes it (directly or transitively)
  • Include order matters: Different include orderings can produce different compilation results
  • No encapsulation: Everything in a header is visible to consumers, even "internal" details
  • ODR violations: The same entity can be defined differently in different translation units, causing undefined behavior

What Modules Replace

Modules replace the #include mechanism with import declarations. Instead of textual inclusion, modules are compiled once into a Binary Module Interface (BMI) and then imported by consumers.

Old way (headers):

// math.h — header file
#pragma once
int add(int a, int b);
double sqrt(double x);

// main.cpp
#include "math.h"  // Textual copy-paste
int main() {
    return add(1, 2);
}

New way (modules):

// math.cppm — module interface unit
export module math;

export int add(int a, int b);
export double sqrt(double x);

// main.cpp
import math;  // Import the compiled BMI
int main() {
    return add(1, 2);
}

Key Concepts

Module Interface Units (.cppm)

A module interface unit declares what a module exports. It's the public API of your module. By convention, these files use the .cppm extension.

// com.github.user.mylib.cppm
export module com.github.user.mylib;

// Only exported symbols are visible to consumers
export int public_function();

// This is NOT exported — internal to the module
int internal_helper();

The export keyword is explicit. Only what you mark as export is visible to consumers. Everything else is truly private — not just "private by convention" like with headers.

Module Implementation Units (.cpp)

Implementation units contain the definitions for exported symbols:

// mylib.cpp
module com.github.user.mylib;

int public_function() {
    return internal_helper() * 2;
}

int internal_helper() {
    return 21;
}

Note: no export keyword here. Implementation units implement the module but don't add to its interface.

Module Partitions

Large modules can be split into partitions. Each partition is a sub-unit of the parent module:

// math:ops.cppm — partition interface
export module math:ops;

export int add(int a, int b);
export int multiply(int a, int b);

// math:stats.cppm — another partition
export module math:stats;

export double mean(const std::vector<double>& v);

// math.cppm — primary module interface
export module math;

export import :ops;    // Re-export the ops partition
export import :stats;  // Re-export the stats partition

Consumers just write import math; and get everything. Partitions are an implementation detail.

Binary Module Interfaces (BMIs)

When a module interface is compiled, the compiler produces a BMI — a binary representation of the module's exported symbols. BMIs are analogous to precompiled headers, but they're part of the language standard and much more reliable.

BMIs enable fast compilation because:

  • Each module is compiled once, not once per consumer
  • BMIs are binary, so importing is much faster than parsing text
  • No macro pollution or include-order sensitivity

The Build Order Problem

Here's the catch that makes module adoption hard: modules introduce build ordering requirements.

With headers, you can compile all .cpp files in any order (or in parallel) because headers are resolved by the preprocessor. With modules, if main.cpp imports math, then math.cppm must be compiled first to produce the BMI that main.cpp needs.

This means the build system must:

  1. Discover the module dependency graph
  2. Determine the correct compilation order (topological sort)
  3. Compile modules in dependency order
  4. Compile consumers only after their dependencies' BMIs are ready

This is exactly what cmod does. It uses clang-scan-deps to discover the module graph automatically, then constructs a DAG and compiles in the correct order.

How cmod Handles Modules

cmod makes modules practical by automating the hard parts:

# cmod discovers all module dependencies automatically
cmod build

# See the module graph
cmod graph --format dot

# Understand build order
cmod explain my_module

Behind the scenes, cmod:

  1. Runs clang-scan-deps on all source files to discover import and export module declarations
  2. Constructs a DAG of module dependencies
  3. Performs a topological sort to determine compilation order
  4. Compiles modules in order, passing BMI paths to dependent compilations
  5. Caches BMIs for incremental builds

Module Naming in cmod

cmod uses reverse-domain notation for module names, derived from the Git URL:

github.com/fmtlib/fmt       → com.github.fmtlib.fmt
github.com/user/my_project  → com.github.user.my_project

This prevents naming collisions globally — no two repositories can have the same module name because no two repositories have the same URL.

Modules vs. Header Units

C++20 also introduces "header units" as a migration path. A header unit wraps an existing header as if it were a module:

import <vector>;     // Import the vector header as a header unit
import "legacy.h";   // Import a project header as a header unit

Header units are useful for gradual migration, but they don't provide the full benefits of modules (no encapsulation, no guaranteed macro isolation). cmod supports header units but encourages proper module interfaces for new code.

A Complete Example

# Create a library project
cmod init mylib
cd mylib

The module interface (src/mylib.cppm):

export module com.github.user.mylib;

export namespace mylib {
    int add(int a, int b);
    int multiply(int a, int b);
}

The implementation (src/mylib.cpp):

module com.github.user.mylib;

namespace mylib {
    int add(int a, int b) { return a + b; }
    int multiply(int a, int b) { return a * b; }
}

The manifest (cmod.toml):

[package]
name = "mylib"
version = "0.1.0"

[module]
name = "com.github.user.mylib"
root = "src/mylib.cppm"

[build]
type = "library"

Build and test:

cmod build
cmod test

When to Adopt Modules

Modules are ready for new projects today. For existing projects, consider:

  • New projects: Use modules from the start. cmod makes this easy.
  • Active projects with good test coverage: Migrate incrementally, starting with leaf libraries.
  • Legacy projects without tests: Wait until you have test coverage, then migrate.
  • Projects that must support pre-C++20 compilers: Wait, or maintain dual interfaces.

The ecosystem is moving toward modules. Starting now means you're ahead of the curve, not behind it.

Get started with cmod and build your first module-based C++ project.