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

AspectHeader (#include)Module (import)
UnitTextual pasteReuse parsed result
DuplicationParsed every timeParsed once, then reused
CyclesControlled 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

ItemHeader modelImpact
Parsing <vector>50 .cpp × 1 = 50 timesFull parse each time
Parsing common_utils.h50 timesIts 10 dependencies also ×50
Circular depsNeed forward decls, include guardsBuild breaks if mishandled
Exposed implementationEven private members in headersABI 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 import modules and #include headers in the same file.
  • Prefer import first, 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

RuleDescription
Partition namemodule modulename:partitionname;
Internal-only partitionNo export on the partition; import mylib:internal; from inside the module
CyclesImport cycles are ill-formed, including between partitions of the same module; move shared declarations into a third partition
Exposing to usersexport 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 .ixx files 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)

ScenarioHeadersModulesImprovement
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
StageHeadersModules
ParsingFull parse per .cppParse once, store .pcm
DependenciesRecomputed oftenLoaded from cache
Template instantiationPer using .cppShared at module scope

Incremental builds

  • Header change: every .cpp that includes it recompiles.
  • Module change: only .cpp files that import it recompile; .pcm is 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


Module syntax, and what the build needs

TopicContent
Declareexport module name;
Exposeexport functions / classes / variables
Useimport name;
Partitionsmodule name:partition; to split
EffectFaster compiles, clearer dependencies
ToolingGCC / 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).