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.
meson setup builddir # configure (replaces cmake .)
ninja -C builddir # build (replaces cmake --build)
ninja -C builddir testProject structure
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
# 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
# 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.
# Add a wrap from wrapdb
meson wrap install fmt
meson wrap install gtest
# Creates subprojects/fmt.wrap and subprojects/gtest.wrap# 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# In meson.build — use the wrap
fmt_dep = dependency('fmt', fallback: ['fmt', 'fmt_dep'])Subdirectories
# 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],
)
)
endifCompiler arguments and feature detection
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')
endifOptions (user-configurable)
# 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',
)# 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,
)# Set options at configure time
meson setup builddir -Dbackend=vulkan -Denable_tests=false
# Reconfigure existing build
meson configure builddir -Dmax_connections=5000Cross-compilation
# 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'meson setup builddir --cross-file cross/arm.ini
ninja -C builddirMeson vs CMake
| Feature | Meson | CMake |
|---|---|---|
| Syntax | Python-like DSL | Custom script |
| Out-of-source build | Mandatory | Optional (but recommended) |
| Speed | Fast (Ninja always) | Depends on generator |
| Dependency wraps | Built-in wrapdb | FetchContent / ExternalProject |
| Cross-compilation | First-class cross files | Toolchain files |
| IDE support | Good (VS Code, Qt Creator) | Excellent (all IDEs) |
| Ecosystem | Growing | Dominant (CMake required for most packages) |
| Learning curve | Low | High |
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.
# 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.1Ninja 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.
# 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.
# 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.xmlParallel 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.
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.
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'],
)
endifninja -C builddir format # invoke run_target manually
ninja -C builddir docsFor generating many files from many inputs with the same rule, use generator() to avoid repeating custom_target calls.
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.
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.
# 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.
# 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.xzmeson 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.
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.
# 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.
export PKG_CONFIG_PATH=/opt/mylib/lib/pkgconfig:$PKG_CONFIG_PATH
meson setup builddirCMake Subproject
The cmake module lets you consume a CMake-only library from inside a Meson build — without forking or rewriting its build files.
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.
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