· 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.
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:
| Tool | Its job |
|---|---|
| Conan | Finds, downloads and builds your dependencies, and tells CMake where they are. |
| CMake | Describes what to build: targets, sources and what links to what. |
| Ninja | Actually 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=NinjaSelecting 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
sharedandheader_only, and any dependencies it pulls in.
For our example we’ll use four popular libraries:
| Package | What it does |
|---|---|
cli11/2.7.2 | Command-line argument parsing |
fmt/12.1.0 | Fast, safe string formatting |
nlohmann_json/3.12.0 | JSON reading and writing |
spdlog/1.17.0 | Logging, 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.cppconanfile.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.CMakeDepsgenerates the files that let CMake’sfind_package()find each library.CMakeToolchaingenerates a toolchain file and CMake presets with the right compiler settings.cmake_layoutputs build output inbuild/Releaseorbuild/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 thespdlogConfig.cmakefile that Conan generated, which defines an imported target calledspdlog::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 33. 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:
- Are your dependencies (fmt, spdlog) static or shared libraries?
- 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.
The executable: three ways to link
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| Mode | Linker flags | Shared libraries needed | Size | Stripped |
|---|---|---|---|---|
default | none | libc, libm, libstdc++, libgcc_s | 1,011 KB | 779 KB |
runtime | -static-libstdc++ -static-libgcc | libc only | 2,680 KB | 2,004 KB |
full | -static | none | 3,624 KB | 2,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?
defaultis right for software installed through a distribution’s packages, where the system provides matching libraries.runtimeis 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.fullgives you a single file with no dependencies at all: ideal for containers builtFROM 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 3Keeping 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
| Task | Command |
|---|---|
| Create a profile | conan profile detect |
| Find a package | conan search fmt |
| See options and dependencies | conan graph info --requires=fmt/12.1.0 |
| Install dependencies | conan install . --build=missing |
| Debug build | conan install . --build=missing -s build_type=Debug |
| Shared dependencies | conan install . -o "*:shared=True" |
| Configure | cmake --preset conan-release |
| Build | cmake --build --preset conan-release |
| Test | ctest --preset conan-release |
| Everything at once | conan build . --build=missing |
| Pin versions | conan lock create . |
| Static C++ runtime | -static-libstdc++ -static-libgcc |
| Fully static | -static |
| Check what’s linked | ldd ./wordcount |
Where to learn more
- Conan 2 documentation, especially the tutorial.
- ConanCenter to browse packages.
- CMake documentation and its presets guide.
- Ninja manual.