Skip to content
Red-Green Software

· 7 min read

C++ with CMake, Conan and Ninja

A complete, tested walkthrough: choosing packages from ConanCenter, building with CMake and Ninja, linking the libraries, and producing static executables.

#cpp #tools #languages

C++ has no built-in package manager, so for years adding a library meant copying source into your tree, wrestling with git submodules, or hoping your operating system shipped the right version. Three tools working together fix most of that:

ToolIts job
ConanFinds, downloads and builds your dependencies, and tells CMake where they are.
CMakeDescribes what to build: targets, sources and what links to what.
NinjaActually runs the compiler, fast and in parallel.

This post builds a small but real command-line program from scratch. Every command and output shown was run with Conan 2.34, CMake 4.4, Ninja 1.13 and GCC 13 on Ubuntu 24.04.

Installing the tools

All three are Python packages, so uv installs them cleanly, each in its own isolated environment:

uv tool install conan
uv tool install cmake
uv tool install ninja

Your system package manager works too, for example sudo apt install cmake ninja-build. Conan changes quickly, so installing it with uv or pipx keeps it current.

Your Conan profile

A profile tells Conan about your compiler and how to build. Let Conan detect one:

$ conan profile detect
[settings]
arch=x86_64
build_type=Release
compiler=gcc
compiler.cppstd=gnu17
compiler.libcxx=libstdc++11
compiler.version=13
os=Linux

It’s saved to ~/.conan2/profiles/default. Add one more section to the end of that file so every build uses Ninja:

[conf]
tools.cmake.cmaketoolchain:generator=Ninja

Selecting packages

ConanCenter is the public repository, with well over a thousand libraries. Browse it on the web, or search from the terminal:

$ conan search spdlog
Found 14 pkg/version recipes matching spdlog in conancenter
conancenter
  spdlog
    ...
    spdlog/1.14.1
    spdlog/1.15.0
    spdlog/1.15.1
    spdlog/1.15.3
    spdlog/1.16.0
    spdlog/1.17.0

Before committing to a package, look at its options and dependencies:

conan graph info --requires=spdlog/1.17.0

This shows, among much else:

spdlog/1.17.0:
  license: MIT
  description: Fast C++ logging library
  default_options:
    shared: False
    header_only: False
    use_std_fmt: False

and that spdlog depends on fmt/12.1.0. It also tells you whether ConanCenter has a prebuilt binary for your exact compiler and settings (Download (conancenter)), or whether Conan will have to compile it from source (Build).

When choosing a package, check:

  • License. MIT, BSD, Boost and Apache are easy. LGPL has rules about static linking; see below.
  • Maintenance. Recent versions on ConanCenter and an active upstream project.
  • Prebuilt binaries for your compiler, so builds are fast.
  • Options such as shared and header_only, and any dependencies it pulls in.

For our example we’ll use four popular libraries:

PackageWhat it does
cli11/2.7.2Command-line argument parsing
fmt/12.1.0Fast, safe string formatting
nlohmann_json/3.12.0JSON reading and writing
spdlog/1.17.0Logging, built on fmt

Plus gtest/1.18.0 for tests. See Writing tests for more on GoogleTest.

The project

wordcount reads a text file and prints its most common words, as a table or as JSON.

wordcount/
├── conanfile.txt
├── CMakeLists.txt
├── src/
│   ├── main.cpp
│   ├── wordcount.cpp
│   └── wordcount.h
└── tests/
    └── wordcount_test.cpp

conanfile.txt: what you need

[requires]
cli11/2.7.2
fmt/12.1.0
nlohmann_json/3.12.0
spdlog/1.17.0

[test_requires]
gtest/1.18.0

[generators]
CMakeDeps
CMakeToolchain

[layout]
cmake_layout
  • [requires] lists the libraries your program uses.
  • [test_requires] lists libraries only the tests need. They never reach your users.
  • CMakeDeps generates the files that let CMake’s find_package() find each library.
  • CMakeToolchain generates a toolchain file and CMake presets with the right compiler settings.
  • cmake_layout puts build output in build/Release or build/Debug.

We pin fmt/12.1.0 to match what spdlog wants. More on version conflicts later.

CMakeLists.txt: how to build it

cmake_minimum_required(VERSION 3.23)
project(wordcount LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(CLI11 REQUIRED)
find_package(fmt REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(spdlog REQUIRED)

# The counting logic lives in a library so the tests can use it too.
add_library(wordcount_lib src/wordcount.cpp)
target_include_directories(wordcount_lib PUBLIC src)

add_executable(wordcount src/main.cpp)
target_link_libraries(wordcount PRIVATE
    wordcount_lib
    CLI11::CLI11
    fmt::fmt
    nlohmann_json::nlohmann_json
    spdlog::spdlog)

# How to link the final executable:
#   default  - shared C and C++ runtimes from the system
#   runtime  - bundle libstdc++ and libgcc, keep the system C library
#   full     - a completely static executable, no shared libraries at all
set(WORDCOUNT_LINK "default" CACHE STRING "default, runtime or full")
if(WORDCOUNT_LINK STREQUAL "runtime")
    target_link_options(wordcount PRIVATE -static-libstdc++ -static-libgcc)
elseif(WORDCOUNT_LINK STREQUAL "full")
    target_link_options(wordcount PRIVATE -static)
endif()

include(CTest)
if(BUILD_TESTING)
    find_package(GTest REQUIRED)
    add_executable(wordcount_test tests/wordcount_test.cpp)
    target_link_libraries(wordcount_test PRIVATE wordcount_lib GTest::gtest_main)
    include(GoogleTest)
    gtest_discover_tests(wordcount_test)
endif()

Notice there’s nothing Conan-specific in here. It’s plain modern CMake, so the same file works with dependencies from your system, vcpkg or anywhere else.

Linking the packages

The two lines that matter are find_package() and target_link_libraries():

  • find_package(spdlog REQUIRED) loads the spdlogConfig.cmake file that Conan generated, which defines an imported target called spdlog::spdlog.
  • target_link_libraries(wordcount PRIVATE spdlog::spdlog) links it. The target carries everything else with it: include paths, compiler definitions, the library file, and its own dependencies. spdlog brings fmt along automatically.

You never write include paths or library paths by hand. The Namespace::name target names come from each package’s ConanCenter page and are printed when CMake configures.

PRIVATE means “wordcount uses this, but code that uses wordcount doesn’t need it”. Use PUBLIC when a library’s own headers include the dependency’s headers.

The code

src/main.cpp:

#include "wordcount.h"

#include <CLI/CLI.hpp>
#include <fmt/format.h>
#include <nlohmann/json.hpp>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>

#include <fstream>
#include <iterator>
#include <string>

int main(int argc, char** argv) {
    CLI::App app{"Count the most common words in a text file"};
    std::string path;
    std::size_t limit = 5;
    bool as_json = false;
    app.add_option("file", path, "Text file to read")->required()->check(CLI::ExistingFile);
    app.add_option("-n,--top", limit, "How many words to show");
    app.add_flag("--json", as_json, "Print JSON instead of a table");
    CLI11_PARSE(app, argc, argv);

    // Log to stderr so --json output on stdout stays clean for other tools.
    spdlog::set_default_logger(spdlog::stderr_color_mt("wordcount"));

    std::ifstream in(path);
    std::string text{std::istreambuf_iterator<char>(in), {}};
    spdlog::info("read {} bytes from {}", text.size(), path);

    auto words = top_words(text, limit);
    if (as_json) {
        nlohmann::json out = nlohmann::json::array();
        for (const auto& [word, count] : words) {
            out.push_back({{"word", word}, {"count", count}});
        }
        fmt::print("{}\n", out.dump(2));
    } else {
        for (const auto& [word, count] : words) {
            fmt::print("{:>6}  {}\n", count, word);
        }
    }
}

src/wordcount.h declares the function the program and the tests share:

#pragma once
#include <string>
#include <string_view>
#include <utility>
#include <vector>

// Count words (case-insensitive, letters and digits only) and return the
// most common ones, highest count first, ties in alphabetical order.
std::vector<std::pair<std::string, int>> top_words(std::string_view text,
                                                   std::size_t limit);

and tests/wordcount_test.cpp checks it:

#include "wordcount.h"

#include <gtest/gtest.h>

TEST(TopWords, CountsCaseInsensitively) {
    auto words = top_words("Red red GREEN", 5);
    ASSERT_EQ(words.size(), 2u);
    EXPECT_EQ(words[0], (std::pair<std::string, int>{"red", 2}));
    EXPECT_EQ(words[1], (std::pair<std::string, int>{"green", 1}));
}

TEST(TopWords, RespectsLimit) {
    EXPECT_EQ(top_words("a b c d e f", 3).size(), 3u);
}

TEST(TopWords, EmptyTextHasNoWords) {
    EXPECT_TRUE(top_words("", 5).empty());
}

Building

1. Install the dependencies

conan install . --build=missing
======== Computing necessary packages ========
Requirements
    cli11/2.7.2 - Download (conancenter)
    fmt/12.1.0 - Download (conancenter)
    nlohmann_json/3.12.0 - Download (conancenter)
    spdlog/1.17.0 - Download (conancenter)
Test requirements
    gtest/1.18.0 - Download (conancenter)
======== Installing packages ========
Downloading binary packages in 5 parallel threads
...
Generators folder: wordcount/build/Release/generators

Every package had a prebuilt binary, so nothing was compiled. --build=missing means “build from source only if there’s no binary for my settings”, which is what you want almost every time. Packages are cached in ~/.conan2, so the next project that needs fmt gets it instantly.

For a debug build, add -s build_type=Debug.

2. Configure, build and test with the presets

Conan wrote a CMakeUserPresets.json, so CMake already knows where everything is:

$ cmake --preset conan-release
-- Conan: Target declared 'CLI11::CLI11'
-- Conan: Component target declared 'fmt::fmt'
-- Conan: Target declared 'nlohmann_json::nlohmann_json'
-- Conan: Component target declared 'spdlog::spdlog'
-- Conan: Component target declared 'GTest::gtest_main'
...
-- Build files have been written to: wordcount/build/Release

$ cmake --build --preset conan-release
[1/6] Building CXX object CMakeFiles/wordcount_lib.dir/src/wordcount.cpp.o
[2/6] Linking CXX static library libwordcount_lib.a
[3/6] Building CXX object CMakeFiles/wordcount_test.dir/tests/wordcount_test.cpp.o
[4/6] Linking CXX executable wordcount_test
[5/6] Building CXX object CMakeFiles/wordcount.dir/src/main.cpp.o
[6/6] Linking CXX executable wordcount

$ ctest --preset conan-release
1/3 Test #1: TopWords.CountsCaseInsensitively ...   Passed    0.00 sec
2/3 Test #2: TopWords.RespectsLimit .............   Passed    0.00 sec
3/3 Test #3: TopWords.EmptyTextHasNoWords .......   Passed    0.00 sec

100% tests passed out of 3

3. Run it

$ ./build/Release/wordcount sample.txt -n 4
[2026-11-09 21:03:00.358] [wordcount] [info] read 120 bytes from sample.txt
     3  it
     3  make
     3  the
     2  test

$ ./build/Release/wordcount sample.txt -n 2 --json 2>/dev/null
[
  {
    "count": 3,
    "word": "it"
  },
  {
    "count": 3,
    "word": "make"
  }
]

$ ./build/Release/wordcount missing.txt
file: File does not exist: missing.txt
Run with --help for more information.

Ninja: why and how

CMake doesn’t compile anything itself. It generates build files for another tool. The default on Linux is Make; Ninja is the faster choice:

  • Parallel by default. Ninja uses all your CPU cores without needing -j.
  • Fast no-op builds. When nothing changed, Ninja figures that out almost instantly, even in huge projects.
  • Clean output. One line per step, like [5/6] Building CXX object ..., and full commands only when something fails.

With Conan, the [conf] line in your profile selects Ninja. You can also pass it per command:

conan install . --build=missing -c tools.cmake.cmaketoolchain:generator=Ninja

Without Conan, ask CMake for it directly:

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build        # or: ninja -C build

Two handy extras: ninja -C build -t clean removes build output, and adding -DCMAKE_EXPORT_COMPILE_COMMANDS=ON writes the compile_commands.json that editors and clangd use for accurate code completion.

Static and shared linking

There are two separate questions here, and it helps to keep them apart:

  1. Are your dependencies (fmt, spdlog) static or shared libraries?
  2. Is your executable linked against the system’s shared C and C++ runtimes, or does it carry its own?

Dependencies: static by default

Most ConanCenter packages default to shared=False, so fmt and spdlog are compiled straight into the executable. Here’s what our default build depends on at runtime:

$ ldd build/Release/wordcount
	linux-vdso.so.1
	libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6
	libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
	/lib64/ld-linux-x86-64.so.2
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6

No fmt or spdlog: they’re already inside. Only the standard system libraries remain.

To use shared libraries instead, set the option for every package with * (or name one, like fmt/*:shared=True):

conan install . --build=missing -o "*:shared=True"
$ ldd build/Release/wordcount
	libspdlog.so.1.17 => ~/.conan2/p/spdlod807e2feb1e31/p/lib/libspdlog.so.1.17
	libfmt.so.12 => ~/.conan2/p/fmt1f40c1bbd988a/p/lib/libfmt.so.12
	libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6
	...

It runs on your machine because CMake bakes a search path pointing into the Conan cache into the executable. On any other machine, that path doesn’t exist, so you’d have to ship the .so files alongside it. Conan’s runtime_deploy deployer can collect them, though it’s still marked experimental:

conan install . -o "*:shared=True" --deployer=runtime_deploy --deployer-folder=dist

For most command-line tools and services, static dependencies are simpler. Keep shared libraries for plugins, or for big libraries shared by many programs.

Our CMakeLists.txt offers three modes through the WORDCOUNT_LINK option:

cmake --preset conan-release -DWORDCOUNT_LINK=runtime   # or full, or default
cmake --build --preset conan-release
ModeLinker flagsShared libraries neededSizeStripped
defaultnonelibc, libm, libstdc++, libgcc_s1,011 KB779 KB
runtime-static-libstdc++ -static-libgcclibc only2,680 KB2,004 KB
full-staticnone3,624 KB2,853 KB

The runtime build depends only on the C library:

$ ldd build/Release/wordcount
	linux-vdso.so.1
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
	/lib64/ld-linux-x86-64.so.2

The full build depends on nothing at all:

$ ldd build/Release/wordcount
	not a dynamic executable
$ file build/Release/wordcount
ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked

Copy that file to almost any x86-64 Linux machine and it runs.

Which should you choose?

  • default is right for software installed through a distribution’s packages, where the system provides matching libraries.
  • runtime is a great default for tools you hand to other people. It avoids “wrong libstdc++ version” errors on older systems, while still using the system’s C library. Build on the oldest system you support, because glibc is backward-compatible but not forward-compatible.
  • full gives you a single file with no dependencies at all: ideal for containers built FROM scratch, rescue tools and simple deployment.

Growing up: conanfile.py

conanfile.txt is perfect for simple projects. When you need logic, switch to conanfile.py:

from conan import ConanFile
from conan.tools.cmake import CMake, cmake_layout


class WordcountConan(ConanFile):
    name = "wordcount"
    version = "0.1.0"
    settings = "os", "compiler", "build_type", "arch"
    generators = "CMakeDeps", "CMakeToolchain"

    # Build every dependency as a static library, whatever its own default.
    default_options = {"*:shared": False}

    def requirements(self):
        self.requires("cli11/2.7.2")
        self.requires("fmt/12.1.0")
        self.requires("nlohmann_json/3.12.0")
        self.requires("spdlog/1.17.0")
        self.test_requires("gtest/1.18.0")

    def layout(self):
        cmake_layout(self)

    def build(self):
        cmake = CMake(self)
        cmake.configure(variables={"WORDCOUNT_LINK": "runtime"})
        cmake.build()
        cmake.test()

Now one command installs dependencies, configures, builds with Ninja and runs the tests:

$ conan build . --build=missing
======== Calling build() ========
[1/6] Building CXX object CMakeFiles/wordcount_lib.dir/src/wordcount.cpp.o
...
[6/6] Linking CXX executable wordcount
[0/1] Running tests...
100% tests passed out of 3

Keeping versions under control

Version conflicts

spdlog 1.17.0 depends on fmt 12.1.0. If you ask for the newer fmt 12.2.0 yourself, Conan refuses rather than guessing:

ERROR: Version conflict: Conflict between fmt/12.1.0 and fmt/12.2.0 in the graph.
Conflict originates from spdlog/1.17.0

If you’re sure the newer version is compatible, override it in conanfile.py:

def requirements(self):
    self.requires("spdlog/1.17.0")
    self.requires("fmt/12.2.0", override=True)
Overrides
    fmt/12.1.0: ['fmt/12.2.0']

There’s a cost: ConanCenter has no prebuilt spdlog for that combination, so --build=missing compiles spdlog from source. Usually the simplest fix is to match the version your other dependencies already want.

Lockfiles

A lockfile records the exact version and recipe revision of every package, so your teammates and your CI build exactly what you built:

conan lock create .
"requires": [
    "spdlog/1.17.0#bcbaaf7147bda6ad24ffbd1ac3d7142c",
    "nlohmann_json/3.12.0#2d634ab0ec8d9f56353e5ccef6d6612c",
    "gtest/1.18.0#e2507fc4efe2ec9e2db9e5c91eefab9f",
    "fmt/12.1.0#6baf0fb8351783472b94ee6e36232391",
    "cli11/2.7.2#704b6fc8383dbb05a6cf698c01ed74a2"
],

Commit conan.lock. conan install uses it automatically when it’s present.

Cheat sheet

TaskCommand
Create a profileconan profile detect
Find a packageconan search fmt
See options and dependenciesconan graph info --requires=fmt/12.1.0
Install dependenciesconan install . --build=missing
Debug buildconan install . --build=missing -s build_type=Debug
Shared dependenciesconan install . -o "*:shared=True"
Configurecmake --preset conan-release
Buildcmake --build --preset conan-release
Testctest --preset conan-release
Everything at onceconan build . --build=missing
Pin versionsconan lock create .
Static C++ runtime-static-libstdc++ -static-libgcc
Fully static-static
Check what’s linkedldd ./wordcount

Where to learn more