Meson Build System for C++: A Practical Introduction

Published · Updated · Programming

cover image for article

Meson is a build system that deserves a look if you write C++ and you are tired of build files that feel like archaeology. It is not the only serious answer for C++ builds, and it is not magically better than CMake in every codebase. But for small to medium C++ projects, libraries, tools, and clean new components, Meson has a very nice shape: readable build definitions, fast configuration, Ninja as the common backend, and a good story for tests and dependencies.

The short version:

meson setup builddir
meson compile -C builddir
meson test -C builddir

That is the workflow most people are looking for when they search for the Meson build system. You write meson.build, configure a separate build directory, and let Meson generate the lower-level build files.

The official Meson docs describe the project as a fast, user-friendly open source build system. That sounds like marketing until you compare a small Meson file with the equivalent CMake from a real C++ project that has accumulated a decade of "just one more option."

A Minimal C++ Meson Project

Start with this tiny project:

hello-meson/
  meson.build
  src/
    main.cpp

The C++ file can be boring:

#include <iostream>

int main() {
    std::cout << "Hello from Meson\\n";
    return 0;
}

Then create meson.build at the project root:

project(
  'hello-meson',
  'cpp',
  version : '0.1.0',
  default_options : [
    'cpp_std=c++20',
    'warning_level=3',
    'werror=false',
  ],
)

executable(
  'hello-meson',
  'src/main.cpp',
  install : true,
)

Build it:

meson setup builddir
meson compile -C builddir
./builddir/hello-meson

That is a complete C++ project. No generated file committed to source control, no hand-written Ninja file, no "wait, which CMake cache did I mutate?" moment.

Primary Meson references:

What Goes In meson.build

Meson build definitions are deliberately declarative. You tell Meson what the project is, which targets exist, what sources feed those targets, and what dependencies are required.

A slightly more realistic layout might look like this:

sample/
  meson.build
  include/
    sample/calculator.hpp
  src/
    calculator.cpp
    main.cpp
  test/
    calculator_test.cpp

And the build definition:

project(
  'sample',
  'cpp',
  version : '0.1.0',
  default_options : ['cpp_std=c++20', 'warning_level=3'],
)

inc = include_directories('include')

sample_lib = static_library(
  'sample',
  'src/calculator.cpp',
  include_directories : inc,
)

sample_dep = declare_dependency(
  include_directories : inc,
  link_with : sample_lib,
)

app = executable(
  'sample-app',
  'src/main.cpp',
  dependencies : sample_dep,
)

test_exe = executable(
  'calculator-test',
  'test/calculator_test.cpp',
  dependencies : sample_dep,
)

test('calculator', test_exe)

There is enough structure here to model a library, executable, include path, and test without turning the build file into a programming language puzzle. That is the main appeal.

Meson can absolutely express more complicated builds. It supports generated sources, custom targets, install rules, subprojects, cross files, feature options, and platform-specific logic. But it nudges you toward build definitions that remain readable.

Meson Setup, Compile, Test

The command model is clean once you internalize the separate build directory.

Configure the build:

meson setup builddir

Compile:

meson compile -C builddir

Run tests:

meson test -C builddir

Change an option:

meson configure builddir -Dwarning_level=3
meson configure builddir -Dwerror=true

Create a release-ish optimized build:

meson setup build-release --buildtype=release
meson compile -C build-release

The official built-in options documentation covers common settings such as warning_level, werror, buildtype, and language standards like cpp_std. Those options matter because build policy should be explicit. I would rather see cpp_std=c++20 in a build definition than infer the language mode from whatever compiler flag happened to work on one laptop.

Dependencies In Meson

For system dependencies, Meson uses the dependency() function:

zlib_dep = dependency('zlib', required : true)

executable(
  'compress-tool',
  'src/main.cpp',
  dependencies : zlib_dep,
)

Meson knows how to use common discovery mechanisms such as pkg-config, CMake package files, framework detection on macOS, and dependency-specific logic where supported. The goal is not to make dependencies disappear. The goal is to keep dependency lookup in one obvious place.

For optional dependencies:

openssl_dep = dependency('openssl', required : false)

if openssl_dep.found()
  executable('tls-tool', 'src/tls_tool.cpp', dependencies : openssl_dep)
endif

For dependencies that may need to be built as subprojects, Meson has a subprojects and wrap system. A wrap file can tell Meson how to obtain a dependency when the system does not provide it. That is useful, especially on developer machines and Windows builds, but it is also a place where teams should make policy decisions deliberately.

Primary dependency references:

My rule of thumb: use system dependencies when that matches your deployment model, use wraps when you need a reproducible source-based fallback, and document the choice. Surprise dependency downloads are not charming in CI.

Meson vs CMake

CMake is still the default answer in a lot of C++ ecosystems. It has enormous ecosystem coverage, IDE support, package integration, and a long tail of examples. If you are consuming libraries that already assume CMake, or joining a large existing C++ organization, CMake may be the boring and correct choice.

Meson is attractive when:

  • You are starting a new C++ project.
  • You want build files that are easier to read in code review.
  • You are building tools, libraries, or services without a massive legacy matrix.
  • You want first-class testing commands without ceremony.
  • You prefer a build definition that does not slowly become a second application.

Meson can be less attractive when:

  • Your ecosystem already standardizes on CMake.
  • You depend heavily on CMake-only package exports.
  • Your IDE or platform workflow expects CMake project files.
  • You need to onboard contributors who already know CMake but not Meson.

This is not a moral question. Build tools are coordination tools. Choose the one that reduces confusion for your team.

For a broader build-tool comparison, see Bazel vs. Make vs. Just: Choosing Build Tools for Real Engineering Teams.

Meson vs Bazel

Bazel and Meson solve different shapes of problem.

Meson is great when you want a clean, fast, readable build for a project or a set of related native components. Bazel is more compelling when you have a large multi-language repository, expensive builds, remote caching needs, hermeticity requirements, and enough ownership to maintain the build platform.

If your C++ project is painful because the build files are messy, Meson might be the right medicine. If your organization is struggling with cross-language dependency graphs, remote cache misses, and CI cost across a monorepo, you may be looking at a Bazel-shaped problem instead.

The trap is using Bazel because the build feels annoying. Annoying is not a technical requirement. Sometimes the right move is a smaller, cleaner Meson project with boring commands.

Practical Meson Recommendations

If I were starting a C++ project with Meson today, I would do this:

  • Put all build definitions in code review like application code.
  • Set cpp_std explicitly.
  • Keep warning policy visible with warning_level and werror.
  • Use meson test from the first test, not after the project is "big enough."
  • Avoid global compiler flags until you have a real reason.
  • Treat wrap files and subprojects as dependency policy, not random convenience.
  • Keep generated build directories out of source control.
  • Add a short README.md section with the exact meson setup, meson compile, and meson test commands.

For CI, keep it boring:

meson setup builddir --buildtype=debugoptimized
meson compile -C builddir
meson test -C builddir --print-errorlogs

The --print-errorlogs flag is one of those small things that saves time when a test fails in CI and the useful output would otherwise be hidden in a log file.

When Meson Is A Good Fit

Meson is a good fit for C++ projects where humans still need to understand the build without becoming build-system specialists. That includes command-line tools, libraries, developer utilities, native services, embedded-adjacent projects, and clean new components inside a larger codebase.

It is especially nice when your project has enough structure that raw compiler commands are silly, but not enough organizational scale to justify a full build platform.

That is the middle ground where Meson shines.

If you are choosing between Meson, CMake, Make, Ninja, and Bazel, do not start with brand loyalty. Start with the shape of the repository, the dependency model, the CI requirements, and the people who will maintain the build two years from now.

Build systems are not there to impress anyone. They are there to make the next compile, test run, release, and debugging session less annoying.

For more practical software engineering notes, visit Slaptijack.

Slaptijack's Koding Kraken