Skip to content
C++
Build System
Updated 2025-01-01T00:00:00.000Z

Meson Build System

Meson build system for C++ — project setup, dependencies, wraps, cross-compilation, and comparison with CMake.

Why Meson

Meson is a fast, human-readable build system that generates Ninja files. It emphasizes correctness by default — out-of-source builds are mandatory, and common patterns like dependency detection, cross-compilation, and wraps have first-class support.

bash
meson setup builddir  # configure (replaces cmake .)
ninja -C builddir     # build (replaces cmake --build)
ninja -C builddir test

Project structure

cpp
myproject/
├── meson.build          # root build file
├── meson.options        # user-configurable options (C++20 equivalent of CMakeCache)
├── src/
│   ├── meson.build
│   └── main.cpp
├── include/
│   └── mylib.h
├── tests/
│   ├── meson.build
│   └── test_main.cpp
└── subprojects/         # vendored dependencies (wraps)

meson.build basics

python
# meson.build
project('myapp', 'cpp',
    version : '1.0.0',
    default_options : [
        'cpp_std=c++20',
        'warning_level=3',       # 0-3 (0=none, 3=everything)
        'werror=true',           # -Werror
        'b_ndebug=if-release',   # -DNDEBUG in release
    ]
)

# Compiler detection
cc = meson.get_compiler('cpp')

# Executable
executable('myapp',
    sources : files('src/main.cpp', 'src/engine.cpp'),
    include_directories : include_directories('include'),
    install : true,
)

# Library
mylib = library('mylib',
    sources : files('src/lib.cpp'),
    include_directories : include_directories('include'),
)

# Static library
static_lib = static_library('mylib_static',
    sources : files('src/lib.cpp'),
)

# Shared library
shared_lib = shared_library('mylib_shared',
    sources : files('src/lib.cpp'),
    soversion : '1',
)

Dependencies

python
# pkg-config dependency
zlib_dep = dependency('zlib')

# CMake package dependency
openssl_dep = dependency('openssl', method : 'cmake')

# System dependency with fallback
fmt_dep = dependency('fmt',
    fallback : ['fmt', 'fmt_dep'],  # use subproject/wrap if not found
    required : true,
)

# Header-only / manual
boost_dep = declare_dependency(
    include_directories : include_directories('/usr/include'),
)

# Use dependencies
executable('myapp',
    sources : 'main.cpp',
    dependencies : [zlib_dep, fmt_dep],
)

Wrap dependencies (vendored)

Meson wraps let you vendor dependencies without committing their source.

bash
# Add a wrap from wrapdb
meson wrap install fmt
meson wrap install gtest
# Creates subprojects/fmt.wrap and subprojects/gtest.wrap
ini
# subprojects/fmt.wrap
[wrap-file]
directory = fmt-10.2.1
source_url = https://github.com/fmtlib/fmt/releases/download/10.2.1/fmt-10.2.1.zip
source_hash = XXXXXXXXXX
python
# In meson.build — use the wrap
fmt_dep = dependency('fmt', fallback: ['fmt', 'fmt_dep'])

Subdirectories

python
# root meson.build
subdir('src')
subdir('tests')

# src/meson.build
mylib = static_library('core',
    sources : files('engine.cpp', 'renderer.cpp'),
)
core_dep = declare_dependency(
    link_with : mylib,
    include_directories : include_directories('.'),
)

# tests/meson.build
gtest_dep = dependency('gtest', required : false)
if gtest_dep.found()
    test('unit_tests',
        executable('test_runner',
            sources : 'test_main.cpp',
            dependencies : [core_dep, gtest_dep],
        )
    )
endif

Compiler arguments and feature detection

python
cc = meson.get_compiler('cpp')

# Add flags if supported
if cc.has_argument('-march=native')
    add_project_arguments('-march=native', language : 'cpp')
endif

# Feature detection
has_avx2 = cc.has_argument('-mavx2')
if has_avx2
    add_project_arguments('-mavx2', language : 'cpp')
    add_project_arguments('-DHAVE_AVX2', language : 'cpp')
endif

# Check for a header
if cc.has_header('execinfo.h')
    add_project_arguments('-DHAVE_EXECINFO_H', language : 'cpp')
endif

# Check for a function
if cc.has_function('mmap')
    add_project_arguments('-DHAVE_MMAP', language : 'cpp')
endif

Options (user-configurable)

ini
# meson.options
option('enable_tests',
    type : 'boolean',
    value : true,
    description : 'Build unit tests',
)
option('max_connections',
    type : 'integer',
    value : 1000,
    min : 1,
    max : 100000,
)
option('backend',
    type : 'combo',
    choices : ['opengl', 'vulkan', 'metal'],
    value : 'opengl',
)
python
# In meson.build
if get_option('enable_tests')
    subdir('tests')
endif

conf = configuration_data()
conf.set('MAX_CONNECTIONS', get_option('max_connections'))
configure_file(
    input : 'config.h.in',
    output : 'config.h',
    configuration : conf,
)
bash
# Set options at configure time
meson setup builddir -Dbackend=vulkan -Denable_tests=false
# Reconfigure existing build
meson configure builddir -Dmax_connections=5000

Cross-compilation

ini
# cross/arm.ini — cross file
[binaries]
c = 'arm-linux-gnueabihf-gcc'
cpp = 'arm-linux-gnueabihf-g++'
strip = 'arm-linux-gnueabihf-strip'
exe_wrapper = 'qemu-arm'

[host_machine]
system = 'linux'
cpu_family = 'arm'
cpu = 'armv7'
endian = 'little'
bash
meson setup builddir --cross-file cross/arm.ini
ninja -C builddir

Meson vs CMake

FeatureMesonCMake
SyntaxPython-like DSLCustom script
Out-of-source buildMandatoryOptional (but recommended)
SpeedFast (Ninja always)Depends on generator
Dependency wrapsBuilt-in wrapdbFetchContent / ExternalProject
Cross-compilationFirst-class cross filesToolchain files
IDE supportGood (VS Code, Qt Creator)Excellent (all IDEs)
EcosystemGrowingDominant (CMake required for most packages)
Learning curveLowHigh

When to choose Meson: new projects where you control the build system, no need for CMake interop.

When to choose CMake: any project that needs to integrate with the broader C++ ecosystem, consume CMake-only packages, or where CMake is already the team standard.


Installation

Prefer pip over your system package manager — distro-packaged Meson is often months behind and may lack options from newer releases.

bash
# Ubuntu/Debian (often outdated — use pip instead)
sudo apt install meson ninja-build

# pip — always gets the latest release
pip install meson
pip install ninja        # also installs a recent Ninja

# macOS
brew install meson

# Windows (winget)
winget install mesonbuild.meson
winget install Ninja-build.Ninja

# Verify
meson --version          # e.g. 1.4.0
ninja --version          # e.g. 1.11.1

Ninja is a separate binary; on Ubuntu you install it with apt install ninja-build, but the pip-installed ninja package places a Ninja binary on your PATH automatically. Keep both Meson and Ninja at recent versions — Meson's feature set tracks Ninja improvements closely.


Testing with Meson

Meson's test runner is a first-class feature, not an afterthought. Tests are declared inside meson.build alongside the targets that produce them.

python
# Basic test — pass a pre-built executable target
exe = executable('mytest', 'tests/test_main.cpp',
    dependencies : [core_dep, gtest_dep],
)
test('unit', exe)

# With extra arguments, timeout, and serial execution
test('integration', exe,
    args    : ['--mode=full', '--seed=42'],
    timeout : 120,             # seconds; default is 30
    is_parallel : false,       # run after all parallel tests finish
)

# Drive tests through an interpreter
test('pytest-suite',
    find_program('python3'),
    args : ['run_tests.py', '--tb=short'],
    workdir : meson.source_root(),
)

# Group related tests into named suites
test('parser-unit',  parser_test_exe,  suite : 'unit')
test('render-unit',  render_test_exe,  suite : 'unit')
test('db-connect',   db_test_exe,      suite : 'integration')

Run tests with the meson test subcommand — no need to invoke Ninja directly for this step.

bash
# Run all tests
meson test -C builddir

# Verbose output (shows stdout/stderr of each test)
meson test -C builddir --verbose

# Pass flags through to the test binary
meson test -C builddir --test-args='-v --gtest_color=yes'

# Run only a named suite
meson test -C builddir --suite unit

# Emit a JUnit XML report (useful for CI)
meson test -C builddir --logbase testlog --junit-xml
# Writes builddir/meson-logs/testlog.junit.xml

Parallel test execution is on by default. Set is_parallel: false only for tests that share mutable global state (databases, ports, etc.).


Custom Build Targets

custom_target() fills the gap left by add_custom_command() in CMake — it wires arbitrary commands into the dependency graph so Ninja knows when to re-run them.

python
protoc = find_program('protoc')

# Generate C++ source/header from a .proto file
gen = custom_target('gen-headers',
    input   : 'schema.proto',
    output  : ['schema.pb.h', 'schema.pb.cc'],
    command : [protoc, '--cpp_out=@OUTDIR@', '@INPUT@'],
    build_by_default : true,
)

# Use the generated files directly in a library
mylib = library('mylib',
    sources : ['src/lib.cpp', gen],
)

@INPUT@ and @OUTDIR@ are substitution tokens Meson replaces at configure time. @OUTPUT@ expands to all outputs; @OUTPUT0@, @OUTPUT1@ etc. address individual files.

python
clang_format = find_program('clang-format', required : false)
doxygen      = find_program('doxygen',      required : false)

# run_target() creates a Ninja alias but never runs automatically
if clang_format.found()
    run_target('format',
        command : [clang_format, '-i',
                   files('src/main.cpp', 'src/lib.cpp')],
    )
endif

if doxygen.found()
    run_target('docs',
        command : [doxygen, 'Doxyfile'],
    )
endif
bash
ninja -C builddir format   # invoke run_target manually
ninja -C builddir docs

For generating many files from many inputs with the same rule, use generator() to avoid repeating custom_target calls.

python
gen = generator(protoc,
    output  : ['@BASENAME@.pb.h', '@BASENAME@.pb.cc'],
    arguments : ['--cpp_out=@BUILD_DIR@', '@INPUT@'],
)

generated = gen.process('a.proto', 'b.proto', 'c.proto')
mylib = library('mylib', sources : ['src/lib.cpp', generated])

Install Rules

install: true is all you need for executables and libraries — Meson infers the correct destination (bindir, libdir) from the target type.

python
executable('myapp',
    sources : 'src/main.cpp',
    install : true,             # installs to ${prefix}/bin/myapp
)

shared_library('mylib',
    sources     : 'src/lib.cpp',
    soversion   : '1',
    install     : true,         # installs to ${prefix}/lib/
)

Headers, data files, and man pages have dedicated helpers.

python
# Install a header into ${prefix}/include/mylib/mylib.h
install_headers('include/mylib.h', subdir : 'mylib')

# Install a data file
install_data('config.json',
    install_dir : get_option('datadir') / 'myapp',
)

# Install a man page
install_man('doc/myapp.1')

Run the install step directly with meson install; use DESTDIR for packaging workflows.

bash
# Install into the configured prefix
meson install -C builddir

# Staged install for packaging (rpm, deb, etc.)
DESTDIR=/tmp/staging meson install -C builddir

# Build a release tarball (runs dist hooks, verifies build)
meson dist -C builddir
# Writes builddir/meson-dist/myapp-1.0.0.tar.xz

meson dist also runs the test suite against the unpacked tarball, catching "works in my tree but not from a clean checkout" mistakes before the tarball is published.


pkg-config Integration

Meson auto-generates .pc files through the pkgconfig module — no manual configure_file gymnastics required.

python
pkgconfig = import('pkgconfig')

mylib = shared_library('mylib',
    sources  : 'src/lib.cpp',
    install  : true,
)

pkgconfig.generate(mylib,
    name        : 'MyLib',
    description : 'A high-performance C++ utility library',
    version     : meson.project_version(),
    filebase    : 'mylib',           # pkg-config name: mylib.pc
    subdirs     : 'mylib',           # header subdir passed to -I
)

After meson install, the generated mylib.pc lands in ${prefix}/lib/pkgconfig/. Any Meson project can then consume it with a plain dependency() call.

python
# In a downstream Meson project
mylib_dep = dependency('mylib')      # finds mylib.pc via pkg-config

executable('consumer',
    sources      : 'main.cpp',
    dependencies : mylib_dep,
)

If the library is installed to a non-standard prefix, point pkg-config at it before configuring.

bash
export PKG_CONFIG_PATH=/opt/mylib/lib/pkgconfig:$PKG_CONFIG_PATH
meson setup builddir

CMake Subproject

The cmake module lets you consume a CMake-only library from inside a Meson build — without forking or rewriting its build files.

python
cmake = import('cmake')

# Pull in a CMake project located at subprojects/somelib/
sub = cmake.subproject('somelib')

# Retrieve a CMake target as a Meson dependency
dep = sub.dependency('SomeLib')

executable('myapp',
    sources      : 'main.cpp',
    dependencies : dep,
)

Meson runs CMake internally and maps the requested target to link flags and include paths. Use cmake.subproject_options() when you need to pass CMake cache variables.

python
opts = cmake.subproject_options()
opts.add_cmake_defines({
    'BUILD_SHARED_LIBS' : 'OFF',
    'SOMELIB_ENABLE_TESTS' : 'OFF',
})

sub = cmake.subproject('somelib', options : opts)
dep = sub.dependency('SomeLib')

For the reverse direction — using a Meson project from a CMake build — the cleanest approach is to install the Meson project first and let CMake find it via find_package using the generated pkg-config or CMake config files. Alternatively, wrap it in a CMake ExternalProject call if you need a single unified CMake superbuild.


See Also

  • Ninja — the low-level build executor Meson always generates for
  • CMake — the dominant alternative; required when consuming most third-party packages
  • build2 — another modern build system with integrated package management
  • Cross-Compilation — platform-agnostic guide covering Meson cross files in broader context
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z