C++20 Modules Don't Spark Joy
C++20 came with a lot of new features, but among the biggest and most anticipated ones were modules. Now, almost six years later, the excitement has changed to healthy cynicism: Are We Modules Yet predicts that all libraries will support modules by 1st of May 2167, and every other week there is a Reddit post on “Well are they usable by now?” (spoiler: The answer is no).
My personal module journey began when I started the th rewrite of my Voxel Game . Little did I know what I was in for.
So I started with Visual Studio
I started out with Visual Studio 2022, and everything was fine for a while. I was protected from Intelli(Non)Sense because I had always used ReSharper++ which “just worked” with modules. What didn’t work, though, was MSVC. With every Visual Studio version bump, something new would break, and I would need to find a way to make the compiler happy again. It didn’t help that reported bugs rarely were addressed and that it took years (sic!) for a fix to actually land upstream.
Not all of this is the compiler’s fault, of course. There have definitely been cases of me “holding it wrong”. For example, consider this:
export module foo:bar; // Partition module interface unit
// ...
module foo:bar; // Partition module implementation unit
// ...
See the bug? Module partitions and module implementation partitions may not have the same name. In my mind, for the longest time, module interface and implementation units clearly mapped to header/ source file pairs, so imagine my surprise when ReSharper suddenly showed me errors. The same can’t be said for the compiler, though: MSVC has an extension that silently allows this.
import std; was also a great idea in theory but would constantly fail for the simplest program:
module;
#include <boost/container/small_vector.hpp>
module foo;
import std;
This would almost routinely break on most MSVC versions. It’s gotten better, and in late 2024 I was finally able to convert my entire codebase to import std;.
I had also finally figured out how to make the compiler happy, and I felt like I had a good intuition for things to avoid to not anger the beast.
In the meantime, new C++ features had come out: Deducing this and the multi-index operator[] with std::mdspan. This worked for normal C++, but not for
modules (MSVC emits a helpful “Sorry, not implemented yet for modules!”), which was mildly annoying. At the same time I wanted to start playing around with
P2300 (std::execution) and NVIDIA’s reference implementation . Not once have I gotten it to compile 1 .
At least there were some workarounds; for example, there is an std::mdspan::operator[] overload with std::array, which feels forbidden but does work.
Other things somewhat surprisingly never made any problems: boost::mp11 was rock solid, as were the vulkan.hpp headers.
Then came Visual Studio 2026
In 2025, rumors spread that VS 2026 was on the horizon. Naively, my hopes were high that bugs that had lingered for years would finally be resolved in the release branch for that version. Eagerly I downloaded the Insiders preview only to be bitterly disappointed. Code that had compiled fine with VS 2022 was throwing Internal Compiler Errors again.
Even on the Insiders channel this never got any better. Frustrated, after having been stalled for literally months (and being fed up with Windows in general 2 ), I decided I needed something to change.
”So let’s use CMake”, I thought
Lurking on Reddit, I had seen that people were happy with Clang. It also seemed to get early implementations for the cool new stuff (reflection, anyone?). I
decided to download CMake, convert my project to it, and then use CLion and Clang. Truth be told, I never got it to configure. C++ module and/ or import std; support in CMake is still experimental, and you need to mess around with random GUIDs. I believe I have tried all possible values for those GUIDs, but
CMake kept complaining about it not supporting it (my best guess would be that it was somehow in the CMakeCache.txt, but I genuinely don’t know why it didn’t
work).
I used Arch, btw
At this point I wanted to see if the code compiled at all with another compiler (to prove that I wasn’t going insane). I was looking for a suitable Linux distribution, and finally settled on Arch, since I had had good experiences with it in university and because it has fairly recent versions of all build tools. So I put another SSD in my system, fired up the Arch Live environment, almost formatted my Windows SSD (like a professional would!), and installed Arch Linux.
On Arch, everything just worked. I cloned my project, installed vcpkg, gave vcpkg a toolchain file to use Clang instead of GCC, hit configure and built the project 3 . It was truly blissful.
I also want to point out that my project is dependency heavy: The vcpkg manifest lists over 30 dependencies, including a dozen Boost libraries, Vulkan, LLVM, ImGui, FlatBuffers, and more. Even compiling something as big as LLVM just worked.
The only problem child was GCC, which — no matter what I did — didn’t want to compile my project. Since Clang always worked, I didn’t care too much, though.
What I learned along the way
With things finally working, I could focus on actually writing code instead of fighting the compiler. Over time, I settled into some patterns that worked well.
I made it a point to wrap all third-party dependencies in their own module. This keeps the #include confined to a single place and gives the rest of the
codebase a clean import interface:
module;
#include <boost/container/deque.hpp>
#include <boost/container/devector.hpp>
#include <boost/container/flat_map.hpp>
#include <boost/container/flat_set.hpp>
#include <boost/container/small_vector.hpp>
#include <boost/container/stable_vector.hpp>
#include <boost/container/static_vector.hpp>
#include <boost/container/vector.hpp>
export module boost.container;
namespace boost::container {
export using boost::container::small_vector;
export using boost::container::static_vector;
export using boost::container::vector;
export using boost::container::deque;
export using boost::container::devector;
export using boost::container::flat_map;
export using boost::container::flat_set;
export using boost::container::stable_vector;
}
The export using trick re-exports just the types you need; macros can be re-defined as constexpr static inline variables.
PIMPL also turned out to be surprisingly helpful for taming compilers, and hasn’t had a measurable impact on runtime performance (but YMMV).
For organizing the modules themselves, I ended up with a folder-per-module convention, similar to how Java packages work:
worldgen/ sandbox.worldgen module
└── biome_provider/ sandbox.worldgen.biome_provider module
├── extrusion/ sandbox.worldgen.biome_provider.extrusion module
└── pipeline/ sandbox.worldgen.biome_provider.pipeline module
Modules on the inside may import modules from the outside (so sandbox.worldgen.biome_provider may depend on sandbox.worldgen, but not vice-versa). This
helps avoid circular imports. The system works well for me, but I don’t know if it is a best practice or potentially even an anti-pattern. This is needed,
because worldgen might have a class like AbstractBiomeProvider that the ExtrusionBiomeProvider in a submodule inherits from.
Inside each module, all *.ixx files have their own partition, where the $module.ixx is responsible for export importing them. Some *.ixx files have
accompanying *.cpp files that are regular implementation partition units. I started out having them just be module implementation units (so no partitions),
but this can lead to undesirable results because implementation units implicitly import the primary interface unit.
// foo.ixx - The module
export module foo;
export import :bar;
// ========================
// bar.ixx
export module foo:bar;
// ========================
// bar.cpp
module foo:bar.impl;
import :bar;
One thing I would caution against is having “uber” modules. In the beginning, I had a util module with a lot of stuff that one occasionally needs. Eventually,
roughly 80% of my project depended on it and recompiling took ages, when I had to change it (which happened often because it was a grab-bag for utility things)
since all dependents would be recompiled as well. Another such module was the serde one, when I was building (de)serialization functionality and needed most
of my classes to use types from that module in order to deserialize themselves.
There is a blog post C++20 Modules: Best Practices from a User’s Perspective from December 2025, which goes into detail on how to use modules in real code. One of the pieces of advice given is this:
A Project Should Declare Only One Module; Use Module Partition Units for Multiple TUs
At least for my project I fundamentally disagree with that statement; In the case of a game engine, it feels strange to have one single engine module, as
opposed to more “obvious” ones like engine.renderer. Again, I don’t claim to have the perfect answers to everything and I would highly recommend you read the
blog post to form your own opinion.
All in all, I loved that modules encapsulate things and that random implementation details don’t leak. They also seemed to significantly speed up my builds (although that is just intuition-based, I have never compiled the project with headers). The project grew to around 250 module files (excluding dependencies), with 36 first-party modules and an average of 6.4 partitions per module. While not huge, it goes well beyond a toy “Hello, World!” example.
But then the fire nation attacked
I would love to complete this blog post with an “I tried modules, it sucked for a while but now I’m happy forever”. Unfortunately, that isn’t the case. I made
the unforgivable error of daring to upgrade my system. One pacman -Syu later and my world was in shambles.
Right now, both Clang and GCC refuse to compile my project. Clang keeps complaining about ambiguous operator new; a known bug not fixed in Clang 22 which is
caused by mixing #includes and imports. Remember the boost.container wrapper I showed earlier? Clang 22 now complains about either a missing or ambiguous
placement new operator in it. Writing an export using ::operator new just makes the compiler fail with an error. Intuitively I don’t really see why the
operator would need exporting — sure, stable_vector might need it in its definition, but certainly, that’s part of how it’s implemented and should just
automagically be resolved? The “fix” is to put the include explicitly in the global module fragment of all importing modules, but that defeats the point of
having the wrapper in the first place. Clang also has an internal compiler error on top of this that I am yet to diagnose.
GCC on the other hand fails to compile because the third-party libraries weren’t designed for modules. Their headers contain TU-local entities (static functions, anonymous-namespace symbols) referenced by exported templates, which GCC thinks violates the modules ODR/visibility rules.
I also get odd errors where I’m not sure if I’m doing something wrong (and newer compilers correctly reject invalid code) or if the compiler has a regression. This problem would be avoidable if libraries came as modules, but the fact of the matter is that not even boost is consumable as modules (save for some libraries).
So what now?
Some of the issues I encountered are completely caused by the libraries (or at least only fixable there). Variables that should be static inline but are just
static come to mind, or overloads of ::operator new. Libraries have gotten better over time, and suppressing the warnings hasn’t led to bad results for
me 4 .
The obvious answer would be for someone (maybe even me) to contribute fixes to the projects and either have them natively support modules or to not get in the
way. @anarthal did a Deep Dive on C++20 modules and boost and came to the following conclusion:
After discussing with maintainers, we’ve decided to park the initiative for now. I expect to revisit it once the MSVC bugs I’ve found are fixed and CMake support for
import stdbecomes stable.
Another battle is header units (import <my_legacy_header.hpp>). They worked perfectly well with MSBuild and MSVC, but as far as I know, CMake just doesn’t
support them yet, at all. Back when I wanted to target Windows/ MSVC, they solved some of the more obscure ICEs.
IDEs still have huge problems with modules, too: IntelliSense still says the support is experimental. CLion has been disastrous in my project, both with the old
as well as the new analysis engine. Reports on Reddit say that clangd isn’t great either, but I am not personally using it. For me the best experience was
Visual Studio with ReSharper++, which “just worked”. I don’t know where the discrepancy with CLion comes from, since both are made by the same company.
All in all, modules are sweet, but they leave a bitter aftertaste. I don’t want to go back to headers, since when modules work the experience is spectacular. But I also don’t want to be afraid to upgrade dependencies, my compiler or my system only to discover what fresh Lovecraftian monster lies ahead.
Footnotes
-
To be fair, I also don’t know if it is meant to compile on Windows/ MSVC. ↩
-
Maybe I’m oldschool, but an operating system should serve me and do what I want. I hate having the feeling that my choices are being disregarded at every step, and that the thing just does what it wants. Windows Recall, the copilot-everything movement are some recent examples. ↩
-
If I remember correctly, I had Claude replace some stray
#includes in the global module fragment withimport std;. ↩ -
Yet. ↩
