Table of Contents

  1. C++20 Modules Overview
  2. CMake Module Support
  3. Declaring Module Sources
  4. Module Partitions
  5. Compiler Support Matrix
  6. Generator Requirements
  7. Consuming Modules
  8. Mixing Headers and Modules
  9. Interface vs Implementation
  10. Limitations & Workarounds
Back to CMake Mastery Series

Part 18: C++20 Modules Support

June 4, 2026 Wasil Zafar 35 min read

Configure CMake for C++20 modules — from FILE_SET declarations and partitions to compiler-specific flags, Ninja requirements, and gradual migration strategies.

C++20 Modules Overview

C++20 modules are the most significant change to the C++ compilation model since the language's inception. They replace the decades-old textual inclusion mechanism (#include) with a proper module system that provides encapsulation, faster compilation, and cleaner dependency management.

Key Insight: Modules solve two fundamental problems: (1) compilation speed — headers are re-parsed in every translation unit that includes them, while modules are compiled once and imported as pre-compiled binary interfaces; (2) encapsulation — everything in a module is private by default, only explicitly exported symbols are visible to consumers.

Module vs Header: What Changes

AspectHeaders (#include)Modules (import)
MechanismTextual copy-pasteBinary interface import
Parse frequencyEvery TU that includesCompiled once, imported many
Macro leakageAll macros leak across TUsMacros don't cross module boundaries
Include orderOrder-dependent (fragile)Order-independent
Symbol visibilityEverything is visibleOnly export-ed symbols visible
Build parallelismHigh (independent TUs)Constrained by DAG (module deps)
ODR violationsCommon, hard to detectEliminated by design
// Traditional header approach
// math_utils.h
#pragma once
#include <cmath>
#include <vector>

namespace math {
    double mean(const std::vector<double>& data);
    double stddev(const std::vector<double>& data);
}

// C++20 module approach
// math_utils.cppm (module interface unit)
export module math_utils;

import <cmath>;
import <vector>;

export namespace math {
    double mean(const std::vector<double>& data);
    double stddev(const std::vector<double>& data);
}
Module Dependency DAG
        flowchart TD
            APP[app.cpp
import mylib;] --> MYLIB[module mylib
mylib.cppm] APP --> UTILS[module utils
utils.cppm] MYLIB --> BASE[module base
base.cppm] UTILS --> BASE MYLIB --> STD[import std;] BASE --> STD UTILS --> STD

CMake Module Support

CMake added experimental C++20 module support in version 3.25 and stabilized it in 3.28. The support is documented in cmake-cxxmodules(7).

Minimum Requirements

# Minimum versions for C++20 module support:
cmake_minimum_required(VERSION 3.28)  # Stable module support
project(ModulesDemo LANGUAGES CXX)

# REQUIRED: C++20 standard
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)  # Recommended for portability

CXX_SCAN_FOR_MODULES

CMake must scan module source files to determine the dependency DAG before compilation. This is controlled by the CXX_SCAN_FOR_MODULES target property:

# Enabled by default for targets with C++20 sources in CMake 3.28+
# You can explicitly control it:
set_target_properties(mylib PROPERTIES
    CXX_SCAN_FOR_MODULES ON
)

# Or disable scanning for a target that doesn't use modules
set_target_properties(legacy_lib PROPERTIES
    CXX_SCAN_FOR_MODULES OFF
)

Declaring Module Sources

Module interface units are declared using target_sources() with the FILE_SET syntax:

FILE_SET CXX_MODULES

add_library(mymath)

# Declare module interface units via FILE_SET
target_sources(mymath
    PUBLIC
        FILE_SET CXX_MODULES
        FILES
            src/math_utils.cppm    # Module interface unit
            src/statistics.cppm    # Another module interface
)

# Regular implementation files (not modules)
target_sources(mymath
    PRIVATE
        src/math_utils_impl.cpp   # Module implementation unit
        src/statistics_impl.cpp
)
// src/math_utils.cppm — Module Interface Unit
export module math_utils;

import <vector>;
import <numeric>;
import <cmath>;

export namespace math {

double mean(const std::vector<double>& data) {
    if (data.empty()) return 0.0;
    return std::accumulate(data.begin(), data.end(), 0.0) / data.size();
}

double stddev(const std::vector<double>& data) {
    if (data.size() < 2) return 0.0;
    double m = mean(data);
    double sum_sq = 0.0;
    for (double x : data) {
        sum_sq += (x - m) * (x - m);
    }
    return std::sqrt(sum_sq / (data.size() - 1));
}

} // namespace math
Hands-On First Module Build
Try It: Build Your First C++20 Module

Create a project with a module interface unit (greeter.cppm) that exports a greet(std::string_view name) function. Declare it with FILE_SET CXX_MODULES in CMake. Build with Ninja and CMake 3.28+. Observe the .pcm (Clang) or .ifc (MSVC) files generated in the build directory.

modules FILE_SET Ninja

Module Partitions

Module partitions allow splitting a large module into multiple files while presenting a single module interface to consumers:

// math_utils-types.cppm — Partition interface
export module math_utils:types;

export struct Statistics {
    double mean;
    double stddev;
    double min;
    double max;
    size_t count;
};

export enum class Method {
    Population,
    Sample
};
// math_utils-compute.cppm — Partition interface
export module math_utils:compute;

import :types;  // Import sibling partition
import <vector>;

export Statistics compute_stats(
    const std::vector<double>& data,
    Method method = Method::Sample
);
// math_utils.cppm — Primary module interface (re-exports partitions)
export module math_utils;

export import :types;    // Re-export the types partition
export import :compute;  // Re-export the compute partition
# CMakeLists.txt — declaring partitions
add_library(mymath)

target_sources(mymath
    PUBLIC
        FILE_SET CXX_MODULES
        FILES
            src/math_utils.cppm           # Primary interface
            src/math_utils-types.cppm     # Partition: types
            src/math_utils-compute.cppm   # Partition: compute
)

# Implementation units for partitions
target_sources(mymath PRIVATE
    src/math_utils-compute_impl.cpp
)

Compiler Support Matrix

C++20 module support varies significantly across compilers. Here's the current state (as of 2026):

CompilerVersionModule StatusExtensionKey Flags
MSVC19.34+ (VS 2022 17.4+)Production-ready.ixx/std:c++20 (auto-enabled)
GCC14+Usable (improving).cppm-std=c++20 -fmodules-ts
Clang16+Good support.cppm-std=c++20 (auto-scans)
# Compiler-specific considerations
if(MSVC)
    # MSVC uses .ixx extension by convention
    # Module support enabled automatically with /std:c++20
    # Generates .ifc (Interface File Cache) files
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    # GCC uses .cppm extension
    # Generates .gcm files in gcm.cache/
    # May need: -fmodules-ts flag (added by CMake automatically)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
    # Clang uses .cppm extension
    # Generates .pcm (Precompiled Module) files
    # Excellent scanning support with Ninja
endif()

Generator Requirements

Module dependency scanning requires the build system to understand inter-file dependencies before compilation. This imposes constraints on which generators work:

Critical: Ninja is required for proper C++20 module support in CMake. The Makefile generator does not support the dynamic dependency scanning protocol (dyndep) that modules require. Visual Studio generator works for MSVC only.
# REQUIRED: Use Ninja generator for module projects
cmake -S . -B build -G Ninja

# Or specify in CMakePresets.json
{
    "configurePresets": [{
        "name": "modules",
        "generator": "Ninja",
        "cacheVariables": {
            "CMAKE_CXX_STANDARD": "20"
        }
    }]
}

# Ninja's dyndep feature enables:
# 1. Scan source files for module declarations/imports
# 2. Build dependency DAG dynamically
# 3. Compile modules in correct topological order
# 4. Maximize parallelism within constraints
Module Compilation Pipeline
        flowchart LR
            SCAN[Dependency Scan
P1689 format] --> DAG[Build DAG
Topological sort] DAG --> COMPILE_BASE[Compile base.cppm
→ base.pcm] COMPILE_BASE --> COMPILE_UTILS[Compile utils.cppm
→ utils.pcm] COMPILE_BASE --> COMPILE_MYLIB[Compile mylib.cppm
→ mylib.pcm] COMPILE_UTILS --> COMPILE_APP[Compile app.cpp
imports utils, mylib] COMPILE_MYLIB --> COMPILE_APP COMPILE_APP --> LINK[Link
→ executable]

Consuming Modules

When a target declares module sources via FILE_SET CXX_MODULES with PUBLIC visibility, dependent targets can import those modules:

# Library with modules
add_library(mathlib)
target_sources(mathlib
    PUBLIC
        FILE_SET CXX_MODULES
        FILES src/math_utils.cppm
)

# Application that imports the module
add_executable(calculator main.cpp)
target_link_libraries(calculator PRIVATE mathlib)
# → main.cpp can now use: import math_utils;
// main.cpp — consuming the module
import math_utils;  // Import our library's module
import <iostream>;
import <vector>;

int main() {
    std::vector<double> data = {1.0, 2.0, 3.0, 4.0, 5.0};
    std::cout << "Mean: " << math::mean(data) << "\n";
    std::cout << "StdDev: " << math::stddev(data) << "\n";
    return 0;
}
Key Insight: Module visibility follows CMake's existing PUBLIC/PRIVATE/INTERFACE semantics. A PUBLIC FILE_SET CXX_MODULES makes the module importable by consumers. PRIVATE module file sets are internal implementation modules not visible to dependents.

Mixing Headers and Modules

Most projects will need to gradually migrate from headers to modules. CMake supports this hybrid approach:

# Library with both headers (legacy) and modules (new)
add_library(mylib)

# Traditional headers still work
target_include_directories(mylib
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)

# New module interface (alongside headers)
target_sources(mylib
    PUBLIC
        FILE_SET CXX_MODULES
        FILES
            src/mylib.cppm         # Module wrapping existing functionality
    PRIVATE
        src/legacy_impl.cpp        # Can use #include internally
        src/module_impl.cpp        # Module implementation
)
// src/mylib.cppm — Module that wraps existing header-based code
export module mylib;

// You can #include headers INSIDE a module's global fragment
module;
#include "internal_header.h"  // Private to this module
export module mylib;

// Re-export functionality as module API
export {
    using ::internal::Widget;
    using ::internal::process;
}

// Header units — import headers as if they were modules
// (compiler must support this)
import <string>;     // Standard library header unit
import <vector>;     // More efficient than #include in module context
Hands-On Gradual Migration
Try It: Wrap a Header Library as a Module

Take an existing header-only library (or create a simple one with 2-3 functions). Create a module interface unit that includes the header in its global module fragment and exports the public API. Verify that consumers can both #include the header (legacy) and import the module (modern) from the same library target.

migration header units gradual adoption

Module Interface vs Implementation

C++20 modules separate interface from implementation — similar to header/source pairs but with proper language semantics:

// math_utils.cppm — Interface unit (compiled to .pcm/.ifc)
export module math_utils;

import <vector>;

// Only declarations here (like a header, but better)
export namespace math {
    double mean(const std::vector<double>& data);
    double stddev(const std::vector<double>& data);
    double median(std::vector<double> data);  // Takes by value intentionally
}
// math_utils_impl.cpp — Implementation unit
module math_utils;  // No 'export' keyword — this is an implementation unit

import <algorithm>;
import <numeric>;
import <cmath>;

namespace math {

double mean(const std::vector<double>& data) {
    if (data.empty()) return 0.0;
    return std::accumulate(data.begin(), data.end(), 0.0)
           / static_cast<double>(data.size());
}

double stddev(const std::vector<double>& data) {
    if (data.size() < 2) return 0.0;
    double m = mean(data);
    double accum = 0.0;
    for (double x : data) {
        accum += (x - m) * (x - m);
    }
    return std::sqrt(accum / (data.size() - 1));
}

double median(std::vector<double> data) {
    std::sort(data.begin(), data.end());
    size_t n = data.size();
    if (n % 2 == 0)
        return (data[n/2 - 1] + data[n/2]) / 2.0;
    return data[n/2];
}

} // namespace math

File extension conventions by compiler:

File TypeMSVCGCC/ClangPurpose
Module interface.ixx.cppmContains export module declaration
Module implementation.cpp.cppContains module name; (no export)
Partition interface.ixx.cppmContains export module name:part;
Internal partition.cpp.cppContains module name:part;

Current Limitations and Workarounds

While C++20 modules in CMake have matured significantly, some limitations remain:

Known Limitations (as of CMake 3.30 / 2026):
  • Ninja required — Makefile generators do not support dyndep
  • No install() support for modules — Installing module interfaces for consumption by other projects is still being standardized
  • Limited cross-compilation — Module BMI (Binary Module Interface) files are compiler-version-specific
  • IDE support varies — IntelliSense/clangd may have gaps with complex module hierarchies
  • Header units inconsistentimport <header>; support varies across compilers
# Workaround: Conditional module support
# Allow projects to build with or without modules

option(USE_CXX_MODULES "Enable C++20 modules (requires CMake 3.28+, Ninja)" OFF)

if(USE_CXX_MODULES)
    if(CMAKE_VERSION VERSION_LESS "3.28")
        message(FATAL_ERROR "C++20 modules require CMake 3.28+")
    endif()

    if(NOT CMAKE_GENERATOR MATCHES "Ninja")
        message(FATAL_ERROR "C++20 modules require Ninja generator")
    endif()

    add_library(mylib)
    target_sources(mylib
        PUBLIC FILE_SET CXX_MODULES FILES src/mylib.cppm
        PRIVATE src/mylib_impl.cpp
    )
else()
    # Fallback: traditional header-based build
    add_library(mylib src/mylib.cpp)
    target_include_directories(mylib PUBLIC include/)
endif()
# Version requirements summary
# CMake 3.25: Experimental module support (CMAKE_EXPERIMENTAL_CXX_MODULE_DYNDEP)
# CMake 3.26: Improved scanning, better error messages
# CMake 3.27: FILE_SET CXX_MODULES stabilizing
# CMake 3.28: STABLE — no experimental flags needed
# CMake 3.29+: install() for modules (in progress)
Hands-On Module Project Template
Try It: Complete Module-Based Project

Create a project with two libraries (core and utils) where utils imports core's module. Add an application that imports both. Use module partitions in the core library. Build with Ninja and verify the correct compilation order. Try building with Make to confirm it fails with a helpful error.

complete project partitions multi-library
Recommendation: For new projects starting in 2026, C++20 modules are production-ready with MSVC and increasingly stable with Clang/GCC. For existing projects, adopt modules incrementally — wrap existing headers in module interfaces and migrate consumers one at a time. Always keep a header-based fallback until your minimum compiler requirements guarantee module support. See cmake-cxxmodules(7) for the latest status.