C++20 Modules with CMake 3.28 and Ninja
Modules replace the header model with named modules, partitions, and a build that must order compilations. This guide builds a small geometry library as a named module with two partitions, wired through CMake 3.28 and Ninja, and tested on the compilers available here. The setups that fail are left in, with the exact errors, because the failures are the useful part.
What you will build
A geometry library called geometry, split into two partitions, with two consumers. The library defines a Point type, a distance function, and a triangle_area function. The demo program prints a distance and an area. The geometry_tests program asserts both results. Three targets, one module, no headers for the library itself.
The files:
| File | Role |
| src/geometry.cppm | Primary module interface unit for geometry |
| src/geometry-shapes.cppm | Partition interface unit geometry:shapes |
| src/geometry-measure.cppm | Partition interface unit geometry:measure |
| src/main.cpp | The demo program, imports geometry |
| tests/test_geometry.cpp | The geometry_tests program, imports geometry |
| CMakeLists.txt | Three targets, one FILE_SET of module files |
Tested with Clang 18.1.3, GCC 13.3.0, CMake 3.28.3, and Ninja 1.13.2 on Ubuntu 24.04. MSVC was not available for this guide. Every command below was run on those toolchains, and every error quoted below is a real one.
The toolchain reality first
Modules are a place where the version table matters more than the tutorial. Before writing any code, here is what actually worked on this box, and what did not.
| Setup | Result |
| Clang 18.1.3, compiled by hand | Works, named modules and partitions |
| GCC 13.3.0, compiled by hand | Works, named modules and partitions |
| CMake 3.28.3 + Ninja + Clang 18 | Works, once clang-scan-deps is installed |
| CMake 3.28.3 + Ninja + GCC 13 | Fails at the generate step, see below |
| import std on Clang 18 + libstdc++ 13 | Fails, module not found |
| import std on GCC 13 | Fails, no compiled std module exists |
| MSVC | Not tested here |
CMake's module documentation lists the compilers it supports for dependency scanning as MSVC toolset 14.34 and newer, Clang 16.0 and newer, and GCC 14 and newer. That list explains the GCC 13 failure: the compiler on this box is one major version short of what CMake 3.28 needs for scanning. The same page lists the generators that support scanning: Ninja, Ninja Multi-Config, and Visual Studio. Makefiles are not on the list, because the build graph has to be updated while the build runs, and Make cannot do that part.
The module files
A named module has exactly one primary module interface unit, and partitions are units of the same module that are visible only inside it. A partition must be export-imported by the primary interface before importers can see it. Those are the rules from the cppreference Modules page, and the files below follow them directly.
The shapes partition declares Point and distance. The definitions live in the same file, for a reason covered in the next section.
module;
#include <cmath>
export module geometry:shapes;
export struct Point {
double x = 0.0;
double y = 0.0;
};
export double distance(Point a, Point b) {
const double dx = a.x - b.x;
const double dy = a.y - b.y;
return std::sqrt(dx * dx + dy * dy);
}
// Not exported: module-linkage helper, invisible to importers.
int geometry_internal_helper() { return 7; }
The module; line at the top starts the global module fragment. Everything included there, here <cmath>, stays out of the module itself, so the header does not leak into importers. The export module geometry:shapes; line after it ends the fragment and declares the partition interface unit.
The measure partition imports the shapes partition, because it uses Point and distance. A partition can only be imported by units of the same module, which is exactly what this file is.
module;
#include <cmath>
export module geometry:measure;
import :shapes;
export double triangle_area(Point a, Point b, Point c) {
const double ab = distance(a, b);
const double bc = distance(b, c);
const double ca = distance(c, a);
const double s = (ab + bc + ca) / 2.0;
return std::sqrt(s * (s - ab) * (s - bc) * (s - ca));
}
The primary interface does one job: both partitions are exported through it, so that import geometry; exposes everything.
export module geometry;
export import :shapes;
export import :measure;
The consumer is an ordinary translation unit. It imports the module and includes <iostream> the normal way. Imports and includes can mix in a non-module file.
import geometry;
#include <iostream>
int main() {
const Point a{0.0, 0.0};
const Point b{3.0, 4.0};
const Point c{3.0, 0.0};
std::cout << "distance: " << distance(a, b) << '\n';
std::cout << "area: " << triangle_area(a, b, c) << '\n';
return 0;
}
The test program asserts the two results it can check exactly: a 3-4-5 triangle has distance 5, and the right triangle with legs 3 and 4 has area 6.
import geometry;
#include <cassert>
#include <iostream>
int main() {
assert(distance(Point{0, 0}, Point{3, 4}) == 5.0);
assert(triangle_area(Point{0, 0}, Point{3, 0}, Point{0, 4}) == 6.0);
std::cout << "all geometry tests passed\n";
return 0;
}
Why the definitions live in the interface units
The first version of this project put the function definitions in separate implementation units, one per partition, the way a header project splits declarations into .h files and definitions into .cpp files. It did not compile, on either compiler. The implementation unit for a partition cannot see the partition interface, and it cannot import the partition either, because that is importing a module inside its own purview.
Clang 18 said:
error: import of module 'geometry:shapes' appears within its own interface
error: unknown type name 'Point'
GCC 13 said:
error: cannot import module in its own purview
error: 'Point' was not declared in this scope
Both compilers agree, and both errors point at the same rule: a partition implementation unit does not get the partition interface for free, and it cannot import it. The working structure puts the definitions in the interface units, as above. That is also why the helper geometry_internal_helper sits in the shapes interface file without export: it has module linkage, and a separate check confirmed that importers cannot name it. Compiling a file that calls it fails with one error:
error: declaration of 'geometry_internal_helper' must be imported
from module 'geometry' before it is required
A probe also confirmed the narrower case: an implementation unit of the primary module interface, module geometry;, compiled cleanly on both compilers and could see the interface declarations. The limitation is specific to partitions. If your module has no partitions, separate implementation units behave the way you expect. If it has partitions, define the partition functions in the partition interface units.
The CMakeLists.txt
CMake learns about modules through file sets. Sources in a FILE_SET of type CXX_MODULES are scanned for imports, and CMake orders the compilations from the scan results. The scanning step is the visible change modules bring to a CMake build: with headers, every translation unit compiles independently, but with modules, one file cannot compile until the modules it imports have produced their interface files.
cmake_minimum_required(VERSION 3.28)
project(geometry CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_library(geometry)
target_sources(geometry
PUBLIC FILE_SET CXX_MODULES FILES
src/geometry.cppm
src/geometry-shapes.cppm
src/geometry-measure.cppm
)
add_executable(demo src/main.cpp)
target_link_libraries(demo PRIVATE geometry)
add_executable(geometry_tests tests/test_geometry.cpp)
target_link_libraries(geometry_tests PRIVATE geometry)
Two details are worth stating plainly. First, cmake_minimum_required(VERSION 3.28) matters: module support in CMake started in 3.28, and the policy that enables scanning by default comes with it. Second, the Ninja generator is required here, not a preference. The CMake module documentation requires Ninja 1.11 or newer for the Ninja generators, and the Make generators cannot express the dynamic dependency step at all.
Building it with CMake and Clang
CMake needs clang-scan-deps to scan sources for Clang builds. Ubuntu ships Clang and that tool in separate packages, so a stock install of clang-18 configures fine and then fails at the first scan step with CMAKE_CXX_COMPILER_CLANG_SCAN_DEPS-NOTFOUND. Install the tools package for your Clang version:
sudo apt install clang-tools-18
Without administrator access, the same package can be downloaded and unpacked into a home directory instead:
apt-get download clang-tools-18
dpkg-deb -x clang-tools-18_*.deb ~/clang-tools-18
Then point CMake at the scanner explicitly:
cmake -S . -B build -G Ninja -DCMAKE_CXX_COMPILER=clang++ \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_COMPILER_CLANG_SCAN_DEPS=$HOME/clang-tools-18/usr/lib/llvm-18/bin/clang-scan-deps
cmake --build build
./build/demo
./build/geometry_tests
The build runs 16 steps: five scans, three dependency files, three module compilations, a static library, two program compilations, and two links. The output is:
distance: 5
area: 6
all geometry tests passed
Compiling by hand with Clang
CMake hides the ordering work, so here is the same build done explicitly. Each interface unit is precompiled to a BMI file, then the BMI is compiled to an object file. Consumers name every module file they need with -fmodule-file, including the partitions: importing geometry alone is not enough, because Clang needs the partition interface files too.
clang++ -std=c++20 --precompile \
src/geometry-shapes.cppm -o build/geometry-shapes.pcm
clang++ -std=c++20 --precompile src/geometry-measure.cppm \
-fmodule-file=geometry:shapes=build/geometry-shapes.pcm \
-o build/geometry-measure.pcm
clang++ -std=c++20 --precompile src/geometry.cppm \
-fmodule-file=geometry:shapes=build/geometry-shapes.pcm \
-fmodule-file=geometry:measure=build/geometry-measure.pcm \
-o build/geometry.pcm
clang++ -std=c++20 -c build/geometry-shapes.pcm \
-o build/geometry-shapes.o
clang++ -std=c++20 -c build/geometry-measure.pcm \
-fmodule-file=geometry:shapes=build/geometry-shapes.pcm \
-o build/geometry-measure.o
clang++ -std=c++20 -c build/geometry.pcm \
-fmodule-file=geometry:shapes=build/geometry-shapes.pcm \
-fmodule-file=geometry:measure=build/geometry-measure.pcm \
-o build/geometry.o
clang++ -std=c++20 -c src/main.cpp \
-fmodule-file=geometry=build/geometry.pcm \
-fmodule-file=geometry:shapes=build/geometry-shapes.pcm \
-fmodule-file=geometry:measure=build/geometry-measure.pcm \
-o build/main.o
clang++ build/main.o build/geometry-shapes.o \
build/geometry-measure.o build/geometry.o -o build/app
./build/app
Leaving the partition files off the main.cpp compile fails with failed to find module file for module 'geometry:shapes'. That error is the manual form of the lesson CMake's scanner exists to teach: the build must know about every module file in the graph, not just the one named in the import.
GCC 13: what works and what does not
By hand, GCC 13 builds the same project. The flag is -fmodules-ts, the .cppm files need -x c++ so GCC treats them as C++ sources, and GCC caches the compiled module files in a gcm.cache directory in the working directory.
g++ -std=c++20 -fmodules-ts -x c++ -c \
src/geometry-shapes.cppm -o build/geometry-shapes.o
g++ -std=c++20 -fmodules-ts -x c++ -c \
src/geometry-measure.cppm -o build/geometry-measure.o
g++ -std=c++20 -fmodules-ts -x c++ -c \
src/geometry.cppm -o build/geometry.o
g++ -std=c++20 -fmodules-ts -c src/main.cpp -o build/main.o
g++ build/geometry-shapes.o build/geometry-measure.o \
build/geometry.o build/main.o -o build/app
./build/app
The output is the same: distance 5, area 6. Through CMake, GCC 13 fails before any compilation. The generate step stops with:
CMake Error in CMakeLists.txt:
The target named "geometry" has C++ sources that may use modules, but the
compiler does not provide a way to discover the import graph dependencies.
That matches the CMake module documentation, which lists GCC 14 and newer for scanning support. On this box, the practical split is: GCC 13 for hand-compiled modules, Clang 18 for CMake modules, and GCC 14 or newer if the project must build through CMake with GCC. That last combination was not tested here.
import std: not on this box
import std; is the C++23 standard library module, and it does not work with either compiler on this box. Clang 18 with the system libstdc++ 13 fails with:
fatal error: module 'std' not found
GCC 13 fails with:
std: error: failed to read compiled module: No such file or directory
std: note: compiled module file is 'gcm.cache/std.gcm'
std: note: imports must be built before being imported
The standard library on this box simply has no compiled std module to import. Per the CMake module documentation, import std through CMake needs CMake 4.5 or newer, Clang 18.1.2 or newer with libc++ or libstdc++, GCC 15 or newer, or MSVC toolset 14.36 or newer, and only with the Ninja generators. Header units, the import <header> form, are not supported by CMake at all. None of those combinations was tested here, and this guide makes no claim about them beyond what the documentation states. For this project, the standard library stays in headers, used through the global module fragment and ordinary includes.
What breaks, and the fix
| Error | Cause | Fix |
| CMAKE_CXX_COMPILER_CLANG_SCAN_DEPS-NOTFOUND | The scanner tool is not installed | Install the clang-tools package for your Clang version, or pass CMAKE_CXX_COMPILER_CLANG_SCAN_DEPS |
| Generate error, no way to discover the import graph | GCC older than 14 with CMake modules | Use Clang for CMake builds, compile by hand with GCC, or upgrade to GCC 14 or newer |
| failed to find module file for module 'geometry:shapes' | A consumer named only the primary module file | Pass -fmodule-file for every partition file too |
| import of module appears within its own interface | A partition implementation unit tried to import its own partition | Define partition functions in the partition interface unit |
| module 'std' not found | No compiled std module on this toolchain | Use headers for the standard library on this toolchain, see the import std section |
| must be imported from module 'geometry' before it is required | A name without export was used by an importer | That is the rule working: only exported names cross the module boundary |
Frequently asked questions
Can I use the Make generator instead of Ninja?
No, for CMake module builds. The scanning step updates the build graph while the build runs, and the Make generators cannot express that. Use Ninja or Ninja Multi-Config, or a Visual Studio generator.
Do I need partitions for a small module?
No. A single primary interface unit works, and it avoids the implementation-unit limitation in the previous section. Partitions earn their place when one module grows large enough that splitting the interface helps navigation, or when different parts of the module change at different rates.
Why does the build need a separate scan step?
Because module compilation order depends on the contents of the files. With headers, any translation unit can compile first. With modules, a file that imports geometry cannot compile until the geometry interface files exist. CMake asks the compiler to scan the sources, builds the dependency order from the results, and updates the graph during the build. That scan is what clang-scan-deps does for Clang.
Should I switch my project to import std today?
Only if your toolchain is on the list in the import std section and you have verified it on your own machine. On the toolchains tested here, it fails outright, and the failure is not subtle. Headers remain the working choice until the toolchain catches up.
What comes next in this course?
The next guide migrates a small template library from SFINAE to C++20 concepts, keeping the runtime output identical and comparing the real compiler errors before and after. See Migrating a Template Library from SFINAE to C++20 Concepts.
Source
- CMake, cmake-cxxmodules(7) — scanning strategy, compiler and generator support,
import stdrequirements. - cppreference, Modules (since C++20) — module declarations, partitions, fragments, and import rules.
Facts verified 2026-11-04. Every command and code listing on this page was compiled and run with Clang 18.1.3, GCC 13.3.0, CMake 3.28.3, and Ninja 1.13.2 on Ubuntu 24.04, except the combinations labeled as untested. Failures are quoted verbatim from the actual compiler output.
Keep Exploring
Keep exploring the docs, samples, and free tools to continue your evaluation.
Score SPF, DKIM, DMARC, MTA-STS, TLS-RPT, and BIMI for any domain.
Review grounded audit, compare, fix-plan, and report excerpts before you wire the API into anything.
Jump from the workflow page into the quickstart, endpoint guides, and full OpenAPI reference.