C++20 Modules: export/import, Partitions and Building with GCC, Clang, MSVC and CMake
Introduction: “Even a single include makes the build too slow”
The cost of headers
One large header often includes dozens of others, and the same content is reparsed across many .cpp files. Modules are units that parse once and reuse the result, which helps compile times.
Goals:
- Declare a module and define a public interface with export
- Pull in other modules with import
- Build settings (compiler flags) when using modules
With export, you fix what this module exposes; unlike a wholesale #include, implementation details do not leak, and dependencies are visible from import alone. MSVC, Clang, and GCC support C++20 modules, so starting new libraries as modules is one practical way to adopt them incrementally.
After reading this article you will be able to:
- Author module files (
.cppm/.ixx) - State dependencies with import and reduce compile cost
- Mix modules with existing headers
Requirements: a compiler with C++20 (GCC 11+, Clang 13+, MSVC 2019 16.10+). CMake 3.28+ has stable module build support. Works on Linux, macOS, and Windows.
Production note: this article draws on real issues and fixes from large C++ codebases. It includes practical pitfalls and debugging tips that textbooks often skip.
What is a module?
A module is a C++20 unit that parses once and then reuses the result. Roughly: if #include pastes and reparses text every time, a module reads once, caches, and reuses—helping compile times.
Headers vs modules at a glance:
flowchart LR
subgraph header[#include headers]
H1[.cpp 1] --> H2[parse every time]
H3[.cpp 2] --> H2
H2 --> H4[duplicate parsing]
end
subgraph module[import modules]
M1[.cpp 1] --> M2[parse once]
M3[.cpp 2] --> M2
M2 --> M4[reuse cache]
end
Headers vs modules
| Aspect | Header (#include) | Module (import) |
|---|---|---|
| Unit | Textual paste | Reuse parsed result |
| Duplication | Parsed every time | Parsed once, then reused |
| Cycles | Controlled with forward declarations, etc. | Dependencies are explicit at module granularity |
Basic shape
After module;, the global module fragment may only use legacy headers (#include <vector>, etc.). export module mylib; declares that this file is the mylib module. Only add and Widget marked export are visible to code that imports the module; definitions without export (e.g. internalHelper) stay internal. So unlike #include, which pastes entire headers, only exported declarations are exposed, and the compiler can reuse the one-time parse—often shrinking compile times on large projects. Extensions are commonly .cppm (Clang/GCC) or .ixx (MSVC).
// mylib.cppm (or .ixx)
module;
// Global module fragment: #include only here
#include <vector>
export module mylib;
export int add(int a, int b) {
return a + b;
}
export class Widget {
public:
void draw();
};
Problem scenario: include hell
What it looks like
Scenario: a project with 50 .cpp files, each using #include <vector>, #include <string>, and #include "common_utils.h". Suppose common_utils.h includes 10 more headers, which pull in others in turn.
flowchart TD
subgraph cpp[50 .cpp files]
C1[main.cpp]
C2[parser.cpp]
C3[renderer.cpp]
C50[...]
end
subgraph headers[shared headers]
H1[common_utils.h]
H2[vector]
H3[string]
H4[algorithm]
end
C1 --> H1
C2 --> H1
C3 --> H1
C50 --> H1
H1 --> H2
H1 --> H3
H1 --> H4
Result: the compiler parses the same header contents 50+ times. <vector> alone is thousands of lines; repeating that 50 times grows compile time quickly.
Concrete costs of include hell
| Item | Header model | Impact |
|---|---|---|
Parsing <vector> | 50 .cpp × 1 = 50 times | Full parse each time |
Parsing common_utils.h | 50 times | Its 10 dependencies also ×50 |
| Circular deps | Need forward decls, include guards | Build breaks if mishandled |
| Exposed implementation | Even private members in headers | ABI fragility, recompiles |
How modules help
With modules, import std.vector; (C++23) or import mylib; brings an already parsed result. Even if 50 .cpp files import the same module, the module is parsed once; the rest reuse the cache (.pcm, etc.).
More scenarios
Scenario 2: template header explosion
Including template-heavy headers such as <algorithm>, <memory>, <optional> from 100+ .cpp files causes per-TU template instantiation. Even std::vector<int> may be instantiated many times. Modules share instantiation at module scope and reduce duplication.
Scenario 3: one-line header change, full rebuild
Changing one line in common_utils.h forces every .cpp that includes it to recompile. If 200 files depend on it, all 200 rebuild. With modules, only TUs that import the changed module recompile, so incremental builds are more efficient.
Scenario 4: macro / preprocessor pollution
A macro like #define min(a,b) ((a)<(b)?(a):(b)) in a header redefines min everywhere that header is included, which can clash with std::min. Modules do not leak macros across module boundaries the same way, improving encapsulation.
Scenario 5: ABI instability
Adding a private member to a header can require recompiling every consumer. Modules plus a PIMPL-style split can hide implementation details and limit ABI churn.
Module declaration and export
export module / export
export module math; declares this file as the math module interface. Only add and pi with export are usable from code that does import math;. internalHelper without export is visible only inside the module. That makes the module’s API explicit and reduces “private implementation leaking through headers.”
Example implementation of internalHelper:
export module math;
export int add(int a, int b) {
return a + b;
}
export double pi = 3.14159;
// Without export: internal to this module only
static int internalHelper() {
return 0;
}
Note: Across module boundaries, the export set effectively becomes your API contract. Export the minimal surface you intend to support.
Export blocks
export { ... } exports several declarations at once. Both foo and Bar are available to code that import utils;. For many declarations, a block is easier than repeating export on every line.
Example foo:
export module utils;
export {
void foo();
class Bar {};
}
Exporting templates
You can export templates from modules. Unlike headers, template definitions can live inside the module while importers instantiate them.
// container.cppm
module;
#include <utility>
export module container;
export template<typename T>
class Box {
T value;
public:
explicit Box(T v) : value(std::move(v)) {}
const T& get() const { return value; }
};
Note: Templates still need a visible instantiation context. When splitting .cpp implementation units, follow your compiler’s rules for interface vs implementation units.
Complete module examples
Example 1: math utility module (single file)
math.cppm — interface and implementation in one file:
square (internal detail):
// math.cppm
export module math;
export int add(int a, int b) {
return a + b;
}
export int multiply(int a, int b) {
return a * b;
}
export constexpr double PI = 3.141592653589793;
// Internal only: no export
namespace detail {
int square(int x) { return x * x; }
}
main.cpp — using the module:
// main.cpp
import math;
#include <iostream>
int main() {
std::cout << add(3, 5) << "\n"; // 8
std::cout << multiply(4, 7) << "\n"; // 28
std::cout << PI << "\n"; // 3.14159...
return 0;
}
Example 2: Split interface and implementation
geometry.cppm — declarations only:
// geometry.cppm
export module geometry;
export struct Point {
double x, y;
};
export double distance(const Point& a, const Point& b);
export Point midpoint(const Point& a, const Point& b);
geometry_impl.cpp — implementation:
// geometry_impl.cpp
// Module implementation unit
module geometry;
#include <cmath>
double distance(const Point& a, const Point& b) {
double dx = a.x - b.x;
double dy = a.y - b.y;
return std::sqrt(dx * dx + dy * dy);
}
Point midpoint(const Point& a, const Point& b) {
return Point{(a.x + b.x) / 2, (a.y + b.y) / 2};
}
main.cpp:
// main.cpp
import geometry;
int main() {
Point p1{0, 0}, p2{3, 4};
double d = distance(p1, p2); // 5.0
Point m = midpoint(p1, p2); // {1.5, 2}
return 0;
}
Example 3: Global module fragment with legacy headers
// string_utils.cppm
module;
#include <string>
#include <algorithm>
#include <cctype>
export module string_utils;
export std::string to_upper(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
return s;
}
export std::string trim(const std::string& s) {
auto start = s.find_first_not_of(" \t\n\r");
if (start == std::string::npos) return "";
auto end = s.find_last_not_of(" \t\n\r");
return s.substr(start, end - start + 1);
}
Example 4: Large module with partitions
Splitting a network module into tcp, udp, and http partitions.
network.cppm — main interface:
// network.cppm
export module network;
export import network:tcp;
export import network:udp;
export import network:http;
network_tcp.cppm — TCP partition:
connect example:
// network_tcp.cppm
export module network:tcp;
export class TcpSocket {
public:
void connect(const char* host, int port);
void send(const void* data, size_t len);
size_t receive(void* buf, size_t len);
};
network_udp.cppm — UDP partition:
bind example:
// network_udp.cppm
export module network:udp;
export class UdpSocket {
public:
void bind(int port);
void sendTo(const void* data, size_t len, const char* addr, int port);
};
network_http.cppm — HTTP partition (uses tcp partition):
// network_http.cppm
module;
#include <string>
export module network:http;
import network:tcp;
export class HttpClient {
public:
std::string get(const char* url);
};
main.cpp:
// main.cpp
import network;
int main() {
TcpSocket tcp;
tcp.connect("localhost", 8080);
UdpSocket udp;
udp.bind(9000);
HttpClient http;
auto response = http.get("https://example.com");
return 0;
}
Using import
Importing another module
import math; brings add, pi, and other exported names from math. Unlike #include, import loads already parsed module artifacts, so even a large math keeps main.cpp relatively cheap to compile. The compiler writes .pcm (or the toolchain’s equivalent) and reuses it.
main example:
// main.cpp
import math;
int main() {
int x = add(3, 5);
return 0;
}
Multiple modules
// main.cpp
import math;
import utils;
import geometry;
// C++23 standard library modules (when supported)
// import std;
Mixing import and #include
- You may
importmodules and#includeheaders in the same file. - Prefer
importfirst, then#include, when possible.
// main.cpp
import math;
import geometry;
#include <iostream>
#include <vector>
int main() {
std::cout << add(1, 2) << "\n";
return 0;
}
Module partitions
What is a partition?
Partitions let you split one logical module across files while still exposing one module. Users can import mylib; once and get exports from all partitions.
flowchart TB
subgraph mylib[mylib module]
M1[mylib.cppm - main]
P1[part1.cppm - partition 1]
P2[part2.cppm - partition 2]
P3[part3.cppm - partition 3]
end
M1 --> P1
M1 --> P2
M1 --> P3
User[main.cpp] -->|import mylib| M1
Basic partition layout
mylib.cppm — main interface, re-exporting partitions:
// mylib.cppm
export module mylib;
export import mylib:part1;
export import mylib:part2;
part1.cppm — partition 1:
doSomething example:
// part1.cppm
export module mylib:part1; // interface partition (needs export to be re-exported)
import mylib:part2; // May import other partitions
export class Part1 {
public:
void doSomething();
};
part2.cppm — partition 2:
// part2.cppm
export module mylib:part2;
export class Part2 {
public:
int value = 0;
};
part1_impl.cpp — Part1 implementation:
// part1_impl.cpp
module mylib; // implementation unit: implicitly imports the primary interface
void Part1::doSomething() {
// implementation
}
Partition rules
| Rule | Description |
|---|---|
| Partition name | module modulename:partitionname; |
| Internal-only partition | No export on the partition; import mylib:internal; from inside the module |
| Cycles | Import cycles are ill-formed, including between partitions of the same module; move shared declarations into a third partition |
| Exposing to users | export import mylib:part1; re-exports |
Internal-only partition (hide implementation details)
// mylib.cppm
export module mylib;
export import mylib:public_api;
// Do not export internal partition → not visible outside
// public_api.cppm
export module mylib:public_api;
import mylib:internal; // Internal use only
export void publicFunction() {
internalHelper(); // From internal partition
}
internalHelper implementation:
// internal.cppm
module mylib:internal;
void internalHelper() {
// implementation details
}
Builds and tooling
GCC 11+
# Single module
g++ -std=c++20 -fmodules-ts -c math.cppm -o math.o
g++ -std=c++20 -fmodules-ts main.cpp math.o -o app
# Module with partitions (order matters)
g++ -std=c++20 -fmodules-ts -c mylib:part2 -o part2.o
g++ -std=c++20 -fmodules-ts -c mylib:part1 -o part1.o
g++ -std=c++20 -fmodules-ts -c mylib -o mylib.o
g++ -std=c++20 -fmodules-ts main.cpp part2.o part1.o mylib.o -o app
Clang 13+
# Clang: C++20 modules without needing -fmodules-ts for the same workflow
clang++ -std=c++20 -c math.cppm -o math.o
clang++ -std=c++20 main.cpp math.o -o app
MSVC
- Compile
.ixxfiles as module interfaces. - In Visual Studio, enable C++ modules in project settings.
- Command line:
cl /std:c++20 /interface math.ixx
CMake 3.28+
cmake_minimum_required(VERSION 3.28)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
add_executable(app main.cpp geometry_impl.cpp)
target_sources(app
PUBLIC
FILE_SET CXX_MODULES FILES
math.cppm
geometry.cppm
)
The file set must be of type CXX_MODULES. When the set is simply named CXX_MODULES, the name doubles as the type; a custom name needs an explicit type, e.g. FILE_SET mods TYPE CXX_MODULES FILES .... Only module interface units and partitions go in the file set. A plain implementation unit (module geometry; with no export) is an ordinary source, so it is listed in add_executable/target_sources normally. Module dependency scanning also needs a generator that supports it; Ninja 1.11+ and Visual Studio 2022 17.4+ are the safe choices, so if CMake reports that the generator does not support C++ modules, switch to Ninja.
With partitions:
add_executable(app main.cpp mylib/part1_impl.cpp)
target_sources(app
PUBLIC
FILE_SET CXX_MODULES FILES
mylib/part1.cppm
mylib/part2.cppm
mylib/mylib.cppm
)
Common errors
Error 1: “module not found” / undefined reference
Cause: The module interface (.cppm) was not compiled, or link / compile order is wrong.
Fix:
# Bad order: compiling main first
g++ -std=c++20 main.cpp math.cppm -o app # may fail
# Good order: module first, then main
g++ -std=c++20 -fmodules-ts -c math.cppm -o math.o
g++ -std=c++20 -fmodules-ts main.cpp math.o -o app
Error 2: export in the global module fragment
Cause: You cannot use export inside the module; block.
// Wrong
module;
export int foo() { return 0; } // error!
#include <vector>
export module mylib;
// Right
module;
#include <vector>
export module mylib;
export int foo() { return 0; }
Error 3: Using non-exported symbols
Cause: Calling a function or class defined without export from outside the module.
// math.cppm
export module math;
int internalAdd(int a, int b) { return a + b; } // not exported
// main.cpp
import math;
int x = internalAdd(1, 2); // error: internalAdd is not exported
Fix: export only what external code should use.
Error 4: Circular imports
Cause: Module A imports B while B imports A.
// mod_a.cppm
export module mod_a;
import mod_b; // circular if mod_b imports mod_a
Fix: Split with partitions or extract a shared interface module.
Error 5: #include before import
Cause: On some compilers, including headers before import lets macros affect later imports.
// Not recommended
#include <iostream>
import math;
// Recommended: import first
import math;
#include <iostream>
Error 6: Wrong extension for interface units
Cause: Saving a module interface as .cpp may compile it as a normal source file.
Fix: Use .cppm (Clang/GCC) or .ixx (MSVC).
Error 7: Wrong partition compile order
Cause: If partition B imports A, compiling B before A fails.
# Wrong order
g++ -std=c++20 -fmodules-ts -c mylib.cppm -o mylib.o # may need part1, part2 first
g++ -std=c++20 -fmodules-ts -c part1.cppm -o part1.o # may need part2 first
# Right order: leaf partitions first
g++ -std=c++20 -fmodules-ts -c part2.cppm -o part2.o
g++ -std=c++20 -fmodules-ts -c part1.cppm -o part1.o
g++ -std=c++20 -fmodules-ts -c mylib.cppm -o mylib.o
Error 8: export in a private implementation unit
Cause: Using export in module mylib; (implementation unit) is invalid.
// Wrong — geometry_impl.cpp implementation unit
module geometry;
export double distance(...) { ... } // error: no export in impl unit
// Right — geometry.cppm interface unit
export module geometry;
export double distance(const Point& a, const Point& b);
Error 9: Macros leaking into imports
Cause: If #include runs before import, header macros can affect imported modules.
// Bad: Windows.h min/max macros may affect math
#include <windows.h>
import math;
int x = min(1, 2); // macro min, not std::min
// Better: import first
import math;
#include <windows.h>
#define NOMINMAX // or disable min/max before windows.h
Best practices
Minimize what you export
Rule: Export only the API consumers need; keep helpers and implementation details unexported.
// Good: public API only
export module config;
export struct Config {
int timeout;
std::string host;
};
export Config loadConfig(const std::string& path);
// Internal — no export
namespace detail {
std::string parseEnv(const std::string& key);
}
One responsibility per module
Rule: One module, one concern. Use clear names like math, geometry, string_utils.
// Good: clear roles
export module math; // math ops
export module geometry; // geometry types and functions
export module logging; // logging
// Bad: kitchen-sink module
export module utils; // math, strings, dates, JSON, ...
Consistent import order
Rule: Standard library → third party → project modules makes dependencies easy to scan.
// Suggested order
// import std; // C++23 (when available)
import third_party;
import myproject.math;
import myproject.geometry;
#include <iostream> // Headers without modules last
Split large modules with partitions
Rule: Modules beyond ~500 lines often benefit from partitions for maintenance.
// mylib.cppm — main file only re-exports
export module mylib;
export import mylib:core;
export import mylib:io;
export import mylib:algorithm;
Minimize the global module fragment
Rule: Put only necessary #includes in module;. Prefer import inside the module when possible.
// Only when needed
module;
#include <windows.h> // macros and platform APIs
#include <legacy_header.h> // unmigrated legacy
export module mylib;
import std; // C++23 std module when available instead of wide includes
Performance comparison
Compile-time benchmark (illustrative)
| Scenario | Headers | Modules | Improvement |
|---|---|---|---|
10 .cpp, one shared util | ~5 s | ~2 s | ~60% |
50 .cpp, five large headers | ~45 s | ~12 s | ~73% |
200 .cpp, heavy templates | ~5 min | ~1 m 20 s | ~73% |
Actual numbers depend on project layout, header size, and compiler.
Why modules are faster
flowchart LR
subgraph header[Headers]
A1[file1] --> P[parse]
A2[file2] --> P
A3[file3] --> P
P --> R1[50x repeat]
end
subgraph mod[Modules]
B1[module] --> Q[parse once]
Q --> C[cache]
C --> D1[reuse]
C --> D2[reuse]
C --> D3[reuse]
end
| Stage | Headers | Modules |
|---|---|---|
| Parsing | Full parse per .cpp | Parse once, store .pcm |
| Dependencies | Recomputed often | Loaded from cache |
| Template instantiation | Per using .cpp | Shared at module scope |
Incremental builds
- Header change: every
.cppthat includes it recompiles. - Module change: only
.cppfiles that import it recompile;.pcmis regenerated.
Production patterns
Pattern 1: Incremental migration
Do not rewrite all headers at once; write new code as modules first.
process example:
// legacy_code.cpp
#include "old_header.h"
import new_module; // new code as a module
void process() {
oldFunction(); // header-based
newFunction(); // module-based
}
Pattern 2: Modules + PIMPL
Use PIMPL inside the module for ABI stability.
draw example:
// widget.cppm
export module widget;
export class Widget {
public:
Widget();
~Widget();
void draw();
private:
struct Impl;
Impl* pimpl;
};
// widget_impl.cpp
module widget;
#include <memory>
struct Widget::Impl {
int state = 0;
};
Widget::Widget() : pimpl(new Impl) {}
Widget::~Widget() { delete pimpl; }
void Widget::draw() { /* ... */ }
Pattern 3: Wrapping standard headers
Until import std; is available everywhere, wrap frequently used headers in a project module.
// std_vector.cppm (project-local)
module;
#include <vector>
export module std_vector;
export template<typename T>
using vector = std::vector<T>;
// Export only what you need from std::vector
Pattern 4: Module dependencies in build scripts
# When module graphs get complex
add_library(mylib MODULE
mylib/part1.cppm
mylib/part2.cppm
mylib/mylib.cppm
)
target_compile_features(mylib PUBLIC cxx_std_20)
Pattern 5: Caching .pcm in CI
# GitHub Actions example
- name: Cache module artifacts
uses: actions/cache@v4
with:
path: build/modules
key: modules-${{ hashFiles('**/*.cppm') }}
Pattern 6: Header units for gradual transition
When you cannot modularize a header immediately, compile it as a header unit and import it (MSVC: import "header.h";).
use example:
// MSVC: build legacy_utils.h as a header unit, then
import "legacy_utils.h";
void use() {
legacyFunction(); // still header-based, but imported
}
Pattern 7: Split interface and implementation (large libraries)
Keeping declarations in .cppm and definitions in .cpp limits recompilation when implementation changes.
start example:
// api.cppm — declarations change rarely
export module api;
export class Service {
public:
void start();
void stop();
};
// api_impl.cpp — implementation changes often; fewer importers rebuild
module api;
void Service::start() { /* ... */ }
void Service::stop() { /* ... */ }
Pattern 8: Test-oriented module layout
When tests need internals, add a test-only partition or export test fixtures only in test builds.
// mylib.cppm
export module mylib;
export import mylib:public_api;
#ifdef ENABLE_TEST_API
export import mylib:test_support; // test builds only
#endif
Production checklist
- C++20+, GCC 11+ / Clang 13+ / MSVC 16.10+
- CMake 3.28+ or another build system with module support verified
- New libraries as modules; legacy code migrated gradually
- Minimal export surface
- Partitions for large modules
- Consider caching module artifacts (
.pcm) in CI
Related reading
Module syntax, and what the build needs
| Topic | Content |
|---|---|
| Declare | export module name; |
| Expose | export functions / classes / variables |
| Use | import name; |
| Partitions | module name:partition; to split |
| Effect | Faster compiles, clearer dependencies |
| Tooling | GCC / Clang / MSVC each need module-aware flags |
The language side of modules is small; the build side is where adoption stalls. A module interface must be compiled before any file that imports it, so the build system has to scan sources for import statements and order the compilation accordingly. In CMake that works from version 3.28 with the Ninja or Visual Studio generators, not with the Unix Makefiles generator. The compiled module interfaces are also tied to the compiler, its version and many of the flags used, so unlike headers they cannot be shipped prebuilt to users of a library; consumers build them from the interface sources. Check that every compiler and build tool your project supports can do this before converting shared code, and start with a leaf library rather than a widely included header.
FAQ
Q. My build fails with “module not found” or undefined references. What is wrong?
A. Usually the module interface unit (.cppm) was not compiled before the file that imports it, or its object file was not linked. Compile the interface first (for GCC, g++ -std=c++20 -fmodules-ts -c math.cppm -o math.o), then compile main.cpp and link math.o. With CMake 3.28+, list the interface units in a module file set with target_sources so CMake works out the order for you.
Q. Can I still use #include in a file that imports modules?
A. Yes. Standard and third-party headers can be mixed with import, and headers that must be included inside a module belong in the global module fragment (module; before export module). Keep macros in mind: modules do not export macros, and on some compilers placing #include lines before import lets macros affect what follows, so importing first is the safer order.
One-line summary: module, export, and import speed up builds and clarify dependencies. Next, read module migration (#24-2).