Five Years In, C++ Modules Are Still Waiting for the Ecosystem to Catch Up
Source: isocpp
C++20 landed in February 2020 with a feature that developers had been requesting for decades: a proper module system. No more preprocessor hacks, no more include guards, no more waiting for the compiler to re-parse the same 50,000 lines of standard library headers for the hundredth time in a build. The promise was transformative. The reality, five years on, is that almost nobody ships modules in production, and getting a library as foundational as Boost to support import boost is a multi-year engineering project.
Rubén Pérez Hidalgo’s talk, covered on isocpp.org, gives us a ground-level view of what that migration actually involves. His experience working on Boost.MySQL is worth examining not just for the Boost-specific details, but because it exposes the architectural assumptions that the entire C++ ecosystem built on top of the preprocessor.
What Modules Actually Change
Before getting into the friction, it helps to be precise about what the new model is.
In the traditional header-based world, a translation unit is a .cpp file plus every header it transitively includes. The compiler starts fresh for each one. Every #include <vector> triggers the same parse, the same template instantiation machinery, the same symbol table construction. Include guards prevent double-inclusion within a single translation unit, but they do nothing across translation units. You compile a thousand .cpp files, you parse <vector> a thousand times.
Modules break this. A module interface unit (a .cppm or .ixx file, depending on the compiler and convention) is compiled once into a Binary Module Interface file, typically .bmi or .pcm. Subsequent files that import that module read the BMI instead of re-parsing source. The BMI contains the compiler’s internal representation of the module’s exported interface, pre-digested.
// math.cppm - module interface unit
export module math;
export int add(int a, int b) { return a + b; }
export double sqrt_approx(double x);
// main.cpp - consumer
import math;
int main() {
return add(1, 2);
}
MSVC reports 50-70% build time reductions on large projects once modules are pervasive. The theoretical ceiling is even higher once import std is universally available, eliminating the parse cost of the standard library entirely. But “theoretical” is doing a lot of work in that sentence.
The Dual-Mode Problem
A library developer cannot simply abandon header support. Users on older compilers, older build systems, or codebases that haven’t migrated cannot consume a modules-only library. The practical requirement is that the library works both ways: traditional #include for existing users, import for new ones.
This creates a structural tension. The whole point of modules is that macros don’t leak across boundaries. Macros are how C++ libraries have traditionally done configuration for thirty years. Boost.Asio has BOOST_ASIO_NO_DEPRECATED. Nearly every Boost library exposes some macro-based configuration surface. None of that survives a module boundary by design.
The solution is the global module fragment, a section at the top of a module interface unit that runs before the module declaration:
module; // begins global module fragment
// Legacy headers go here. Their contents are visible within
// this module but do NOT become part of the module's exports.
#include <string>
#include <vector>
#include "boost/mysql/detail/config.hpp"
export module boost.mysql; // module declaration
// Exported API goes here
export namespace boost::mysql {
class connection { /* ... */ };
}
The global module fragment is essentially a quarantine zone. You drag in everything the module needs from the legacy world, and it stays contained. Nothing from that fragment leaks to consumers of the module. This works, but it means the module interface unit has two distinct compilation phases: a preprocessor-dominated preamble and the actual module interface. Compile times for that first file stay high; only consumers get the speedup.
For a library like Boost.MySQL that depends on Boost.Asio, Boost.Beast, Boost.Variant2, and several other sub-libraries, every one of those transitive dependencies ends up in the global module fragment until those libraries also ship modules. The build time benefit degrades proportionally.
Build Systems Had to Reinvent Dependency Tracking
The deeper structural problem is that the entire build system model assumed independence between source files. In the traditional world, you write explicit dependency declarations or use compiler flags to generate header dependency files. The critical property is that you know the dependencies of a file before you compile it, because headers are named explicitly in #include directives.
Modules break this property. A module interface unit might import another module interface unit. The build system needs to know that dependency to order compilation correctly. But the dependency is encoded inside the source file, not in its filename or location. To discover it, you have to scan the source. That scan needs to happen before the build graph is constructed. This creates a bootstrapping problem: you need partial compilation to know the build order, but you need the build order to start compiling.
CMake 3.28, released in December 2023, addressed this with compiler-provided dependency scanning. The flow works like this: CMake runs a scanning pass using clang-scan-deps, MSVC’s /scanDependencies, or GCC’s equivalent. The scanner produces a JSON dependency graph. CMake feeds that graph to Ninja (1.11 or later required), which builds the module interface units in the correct order before building implementation units.
cmake_minimum_required(VERSION 3.28)
project(mylib CXX)
add_library(mylib)
target_sources(mylib
PUBLIC
FILE_SET CXX_MODULES
FILES
src/mylib.cppm
src/detail/impl.cppm
)
target_compile_features(mylib PUBLIC cxx_std_20)
The FILE_SET TYPE CXX_MODULES declaration tells CMake which files are module interface units. The Visual Studio generator has only partial support; Ninja is the reliable path. This Ninja dependency is a practical obstacle for teams with existing CMake infrastructure built around other generators. Meson 1.3+ has experimental support. xmake has had module support the longest in the community-maintained space, which is why it appears repeatedly in modules experiments on forums and in blog posts.
Compiler Consistency Is Better, But Not Good Enough
As of 2025, all three major compilers have substantial module support, but they are not equivalent.
MSVC remains the most complete implementation. Microsoft uses modules in their own STL implementation, which gives them real-world validation no other vendor matches. Full import std support ships with VS 2022 17.5 and later.
GCC 14 improved significantly; GCC 15 adds import std support via libstdc++. Complex template hierarchies and constexpr interactions still produce edge-case failures. Module dependency file formats differ from Clang’s, which has historically complicated build system integration.
Clang 18 has the best tooling story because of libclang: clang-scan-deps is the reference scanning tool that CMake uses. Correctness for production code is solid but not perfect. Argument-dependent lookup across module boundaries has produced subtle failures in some template-heavy codebases.
For a library developer, this means testing all three compilers across multiple versions, in both header mode and module mode. The CI matrix grows multiplicatively. Boost.MySQL’s testing surface already covers multiple platforms, compilers, and feature combinations. Adding modules support roughly doubles the configuration space.
import std and the Bootstrapping Dependency
C++23 introduced import std as an optional feature. The qualification “optional” is significant. On Linux with the system GCC that a typical CI runner provides, import std is frequently unavailable. This means a modules-aware library cannot rely on it for the foreseeable future. The global module fragment still needs to #include <string> and #include <vector>, which partially defeats the purpose.
C++26 is expected to make standard library modules mandatory rather than optional. That will shift the calculus, but C++26-compliant toolchains reaching widespread deployment in CI environments probably means 2027 or later as a practical matter. The mismatch between what the standard says should be possible and what CI infrastructure actually provides is one of the quieter frustrations in this space.
What ‘import boost’ Actually Requires
Boost has approximately 170 sub-libraries. They form a directed dependency graph; Boost.MySQL depends on Boost.Asio, which depends on Boost.System, and so on. For import boost.mysql to work cleanly, the transitive closure of its dependencies all need module support. Otherwise, every one of them piles into the global module fragment and the BMI generation time stays high.
Each library also has to maintain its header-only or separate-compilation model for users who cannot use modules yet. The macro configuration story needs an answer per library. The testing infrastructure needs to expand. None of this is insurmountable, but it is not one person’s weekend project. It is a coordinated multi-library effort that requires toolchain support to stabilize underneath it as work progresses.
This is, in miniature, the same problem that held back C++ coroutines, ranges, and concepts after they were standardized. The language features arrive first. Compiler support follows at varying quality levels. Build system integration takes another cycle. Library ecosystem adoption lags by years. Users only see the feature as “ready” when all four layers align, which happens long after the standard ships.
The Comparison Worth Making
It is instructive to look at how other languages handled this transition. Rust’s module system was designed from the ground up with the build system (Cargo) as a first-class part of the story. There is no separate dependency scanning problem because Cargo owns the build graph from the start. The compiler and build tool ship together and evolve together.
Java’s compilation model has always been unit-based in a way that resembles modules more than C++ headers. Go’s package system similarly bakes dependency resolution into the toolchain rather than leaving it to separate build systems.
C++ could not make these choices because it has thirty years of existing code and a design philosophy of not breaking backward compatibility. The modules standard had to accommodate legacy headers through the global module fragment precisely because breaking #include-based code was off the table. The result is a feature that is powerful in the clean-room case and complicated at every boundary with existing code.
Where This Leaves Things
The talk by Pérez Hidalgo serves as a realistic progress report: the work is ongoing, the obstacles are known and not mysterious, and the ecosystem is genuinely improving. CMake 3.28 was a significant unlock. GCC 15 and Clang 18 have reduced the correctness gaps. The dependency scanning problem has a solution, even if it requires Ninja.
But “solvable in principle” and “done” are different things. C++ modules are in the long middle phase between standardization and mainstream adoption. The standard says what the language should do. The compilers mostly implement it. The build systems have routes, if not highways, to the destination. What is missing is the library ecosystem: the hundreds of open-source dependencies that every real project has, none of which expose module interfaces yet.
When Boost starts shipping modules for a meaningful subset of its libraries, that will be the signal that modules have graduated from a feature you can use in new projects to a feature you can use in C++. That transition is visible from here. It is not yet complete.