Skip to content

spdlog for C++

spdlog is a C++ logging library with {fmt}-style formatting, named loggers, runtime log levels, multiple output sinks, rotating files, and synchronous or asynchronous operation. This tutorial starts with a minimal Ubuntu 24.04 program, then builds a synchronous component logger controlled by environment variables and a separate CSV telemetry writer.

Install on Ubuntu 24.04

sudo apt update
sudo apt install build-essential cmake libspdlog-dev

Ubuntu 24.04 supplies spdlog 1.12.0 and its {fmt} dependency. Confirm the installed version with:

dpkg-query -W libspdlog-dev

The examples use only the Ubuntu package. No manual installation is required.


The four concepts to know

  • A log record is one event: level, time, logger name, and message.
  • A logger receives records from one application component, such as camera or network.
  • A sink sends records to a destination, such as the console or a file.
  • A pattern controls how a sink converts each record to text.

One logger can share several sinks:

flowchart LR
    C[camera logger] --> O[colored console sink]
    C --> F[rotating file sink]
    N[network logger] --> O
    N --> F
    T[telemetry logger] --> CSV[CSV file sink]

The diagnostic log explains what the program is doing. Telemetry stores fixed-schema measurements for analysis. They are related, but they are not the same data product.


Minimal CMake example

main.cpp
#include <spdlog/spdlog.h>

int main()
{
    spdlog::set_level(spdlog::level::debug);
    spdlog::set_pattern("[%H:%M:%S.%e] [%^%l%$] %v");

    spdlog::debug("Connecting to camera {}", 0);
    spdlog::info("Application started");
    spdlog::warn("This is a warning");
    spdlog::error("Example error code: {}", 42);
}
CMakeLists.txt
1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.16)
project(spdlog_minimal LANGUAGES CXX)

find_package(spdlog CONFIG REQUIRED)

add_executable(spdlog_minimal main.cpp)
target_compile_features(spdlog_minimal PRIVATE cxx_std_17)
target_link_libraries(spdlog_minimal PRIVATE spdlog::spdlog)

Build and run from the downloaded example directory:

1
2
3
cmake -S . -B build
cmake --build build
./build/spdlog_minimal

color section

%^ and %$ mark the colored portion of the console pattern. They have an effect only when the sink supports colors.


Log levels

From most detailed to most severe, spdlog levels are:

Level Typical use
trace Very detailed execution flow.
debug Values useful while developing or diagnosing.
info Normal lifecycle events.
warn Unexpected condition from which the program can recover.
error An operation failed.
critical The process or an essential subsystem cannot continue safely.
off Disable the logger.

A logger emits a record only when its level is at least as severe as its configured threshold. With an info threshold, trace and debug records are filtered out.

Runtime and compile-time filtering differ

The examples call logger methods such as camera->debug(...), which remain available for runtime filtering. Projects using macros such as SPDLOG_DEBUG must also set SPDLOG_ACTIVE_LEVEL at compile time if they want lower-level macro calls compiled into the binary.


Console and rotating-file sinks

The configured example creates one colored console sink and one rotating-file sink. All three component loggers share them:

1
2
3
4
5
6
7
camera  ─┬─> console
network ─┼─> console
control ─┘

camera  ─┬─> logs/application.log
network ─┼─> logs/application.log
control ─┘

The file rotates after 5 MiB and retains three older files:

1
2
3
4
application.log
application.1.log
application.2.log
application.3.log

Rotation limits storage growth. It does not archive logs permanently; the oldest rotated file is deleted.


Control levels from the environment

The configured program calls:

spdlog::cfg::load_env_levels();

Set one global level:

SPDLOG_LEVEL=debug ./build/spdlog_configured

Set a global level and override individual components:

SPDLOG_LEVEL="info,camera=debug,network=warn,control=off" \
  ./build/spdlog_configured

This configuration means:

  • camera emits debug and more severe records;
  • network starts at warn;
  • control emits nothing;
  • every other diagnostic logger uses info.

Unlisted components inherit the global level

In SPDLOG_LEVEL="info,camera=debug", network and control inherit info. A component is disabled only when it is explicitly assigned off or the global level is off.

The example validates configuration before passing it to spdlog. Unknown components, duplicate entries, empty tokens, and invalid levels stop startup:

SPDLOG_LEVEL="info,camrea=debug" ./build/spdlog_configured
Logging configuration error: unknown logging component: camrea

This validation matters because spdlog's native environment parser ignores unrecognized levels instead of reporting them as errors.


Control formatting per sink

The console should be compact and easy to scan. The file should preserve more context. The program therefore reads two environment variables:

1
2
3
export APP_CONSOLE_LOG_PATTERN="[%H:%M:%S.%e] [%^%l%$] [%n] %v"
export APP_FILE_LOG_PATTERN="[%Y-%m-%d %H:%M:%S.%e] [%l] [%n] [thread %t] %v"
./build/spdlog_configured

Common flags are:

Flag Meaning
%Y-%m-%d Date.
%H:%M:%S Time.
%e Milliseconds.
%l Log level.
%n Logger or component name.
%t Thread ID.
%v User message.
%^ ... %$ Start and end console color range.

If a variable is unset, the application uses the pattern shown above as its default. If a supplied pattern is invalid, set_pattern throws and the program exits with a configuration error.


Complete configured example

Download main.cpp and CMakeLists.txt.

CMakeLists.txt
1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.16)
project(spdlog_configured LANGUAGES CXX)

find_package(spdlog CONFIG REQUIRED)

add_executable(spdlog_configured main.cpp)
target_compile_features(spdlog_configured PRIVATE cxx_std_17)
target_link_libraries(spdlog_configured PRIVATE spdlog::spdlog)
main.cpp
#include <spdlog/cfg/env.h>
#include <spdlog/logger.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>

#include <array>
#include <chrono>
#include <cctype>
#include <cstdlib>
#include <filesystem>
#include <iomanip>
#include <iostream>
#include <memory>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <vector>

namespace
{
constexpr std::array<std::string_view, 3> component_names{
    "camera", "network", "control"};

std::string trim(std::string value)
{
    const auto first = value.find_first_not_of(" \t\r\n");
    if (first == std::string::npos)
    {
        return {};
    }
    const auto last = value.find_last_not_of(" \t\r\n");
    return value.substr(first, last - first + 1);
}

bool known_component(const std::string &name)
{
    for (const auto component : component_names)
    {
        if (name == component)
        {
            return true;
        }
    }
    return false;
}

bool known_level(const std::string &level)
{
    static const std::set<std::string> levels{
        "trace", "debug", "info", "warn", "warning", "error", "err",
        "critical", "off"};
    return levels.count(level) != 0;
}

void validate_level_environment()
{
    const char *raw = std::getenv("SPDLOG_LEVEL");
    if (raw == nullptr || std::string_view(raw).empty())
    {
        return;
    }

    std::istringstream input(raw);
    std::string token;
    bool found_global = false;
    std::set<std::string> configured_components;

    while (std::getline(input, token, ','))
    {
        token = trim(token);
        if (token.empty())
        {
            throw std::runtime_error("SPDLOG_LEVEL contains an empty entry");
        }

        const auto separator = token.find('=');
        if (separator == std::string::npos)
        {
            if (found_global)
            {
                throw std::runtime_error(
                    "SPDLOG_LEVEL contains more than one global level");
            }
            if (!known_level(token))
            {
                throw std::runtime_error("unknown global log level: " + token);
            }
            found_global = true;
            continue;
        }

        const auto component = trim(token.substr(0, separator));
        const auto level = trim(token.substr(separator + 1));
        if (!known_component(component))
        {
            throw std::runtime_error("unknown logging component: " + component);
        }
        if (!known_level(level))
        {
            throw std::runtime_error(
                "unknown log level for " + component + ": " + level);
        }
        if (!configured_components.insert(component).second)
        {
            throw std::runtime_error(
                "duplicate SPDLOG_LEVEL component: " + component);
        }
    }
}

std::string environment_or(const char *name, const char *fallback)
{
    const char *value = std::getenv(name);
    return value == nullptr || std::string_view(value).empty() ? fallback : value;
}

void validate_pattern(const std::string &pattern, const char *variable)
{
    constexpr std::string_view flags =
        "+nlLtvaAbhBcCYDxmdHIMSefFEprRTXzP^$@sg#!%uioO";

    for (std::size_t index = 0; index < pattern.size(); ++index)
    {
        if (pattern[index] != '%')
        {
            continue;
        }
        ++index;
        if (index == pattern.size())
        {
            throw std::runtime_error(std::string(variable) +
                                     " ends with an incomplete % flag");
        }
        if (pattern[index] == '-' || pattern[index] == '=')
        {
            ++index;
        }
        while (index < pattern.size() &&
               std::isdigit(static_cast<unsigned char>(pattern[index])))
        {
            ++index;
        }
        if (index < pattern.size() && pattern[index] == '!' && index > 0 &&
            std::isdigit(static_cast<unsigned char>(pattern[index - 1])))
        {
            ++index;
        }
        if (index == pattern.size() || flags.find(pattern[index]) == flags.npos)
        {
            const auto bad_flag = index == pattern.size()
                                      ? std::string("<missing>")
                                      : std::string(1, pattern[index]);
            throw std::runtime_error(std::string(variable) +
                                     " contains unknown flag %" + bad_flag);
        }
    }
}

std::string run_timestamp()
{
    const auto now = std::chrono::system_clock::now();
    const auto time = std::chrono::system_clock::to_time_t(now);
    std::tm local{};
    localtime_r(&time, &local);
    std::ostringstream output;
    output << std::put_time(&local, "%Y-%m-%d_%H-%M-%S");
    return output.str();
}

std::string csv_timestamp()
{
    const auto now = std::chrono::system_clock::now();
    const auto time = std::chrono::system_clock::to_time_t(now);
    std::tm local{};
    localtime_r(&time, &local);
    std::ostringstream output;
    output << std::put_time(&local, "%Y-%m-%dT%H:%M:%S");
    return output.str();
}

std::vector<spdlog::sink_ptr> make_diagnostic_sinks()
{
    const auto console_pattern = environment_or(
        "APP_CONSOLE_LOG_PATTERN", "[%H:%M:%S.%e] [%^%l%$] [%n] %v");
    const auto file_pattern = environment_or(
        "APP_FILE_LOG_PATTERN",
        "[%Y-%m-%d %H:%M:%S.%e] [%l] [%n] [thread %t] %v");
    validate_pattern(console_pattern, "APP_CONSOLE_LOG_PATTERN");
    validate_pattern(file_pattern, "APP_FILE_LOG_PATTERN");

    auto console = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
    console->set_pattern(console_pattern);

    constexpr std::size_t five_megabytes = 5 * 1024 * 1024;
    auto file = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
        "logs/application.log", five_megabytes, 3);
    file->set_pattern(file_pattern);
    return {console, file};
}

void register_component_loggers(const std::vector<spdlog::sink_ptr> &sinks)
{
    for (const auto name : component_names)
    {
        auto logger = std::make_shared<spdlog::logger>(
            std::string(name), sinks.begin(), sinks.end());
        logger->set_level(spdlog::level::info);
        logger->flush_on(spdlog::level::warn);
        spdlog::register_logger(logger);
    }
}

std::shared_ptr<spdlog::logger> make_telemetry_logger()
{
    std::filesystem::create_directories("logs");
    const auto path = "logs/telemetry_" + run_timestamp() + ".csv";
    auto sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>(path, true);
    sink->set_pattern("%v");
    auto logger = std::make_shared<spdlog::logger>("telemetry", sink);
    logger->info("timestamp,component,sequence,fps,latency_ms");
    return logger;
}
} // namespace

int main()
{
    try
    {
        validate_level_environment();
        const auto sinks = make_diagnostic_sinks();
        register_component_loggers(sinks);
        spdlog::cfg::load_env_levels();

        const auto camera = spdlog::get("camera");
        const auto network = spdlog::get("network");
        const auto control = spdlog::get("control");
        const auto telemetry = make_telemetry_logger();

        camera->debug("Opening camera {}", 0);
        camera->info("Camera stream started at {} FPS", 30);
        network->warn("Packet delay is {} ms", 18.4);
        control->info("Control loop ready");

        for (int sequence = 0; sequence < 3; ++sequence)
        {
            telemetry->info("{},{},{},{:.2f},{:.2f}", csv_timestamp(),
                            "camera", sequence, 29.97, 8.4 + sequence);
        }
        telemetry->flush();
        spdlog::shutdown();
    }
    catch (const std::exception &error)
    {
        std::cerr << "Logging configuration error: " << error.what() << '\n';
        spdlog::shutdown();
        return 1;
    }
}

Build and run:

1
2
3
4
cmake -S . -B build
cmake --build build
SPDLOG_LEVEL="info,camera=debug,control=off" \
  ./build/spdlog_configured

The example is synchronous: the calling thread formats each accepted record and writes it to its sinks before returning.


Use spdlog for CSV telemetry

spdlog can write CSV lines, but it is not a CSV database or schema library. The application owns:

  • the filename and schema;
  • the header and column order;
  • value formatting and units;
  • quoting and escaping;
  • flushing and retention.

The configured example creates one file per run:

logs/telemetry_2026-08-08_14-32-10.csv

It uses a dedicated file-only logger whose pattern is %v, so spdlog does not add a level or logger name around the CSV row:

1
2
3
4
timestamp,component,sequence,fps,latency_ms
2026-08-08T14:32:10,camera,0,29.97,8.40
2026-08-08T14:32:10,camera,1,29.97,9.40
2026-08-08T14:32:10,camera,2,29.97,10.40

The sequence column helps detect missing samples. The component column records which subsystem produced the measurement.

CSV strings must be escaped

The example uses a fixed component name and numeric values, none of which contains commas, quotes, or newlines. General text fields require correct CSV quoting. A pattern such as "{},{},{}" does not escape arbitrary input safely.

The telemetry logger is intentionally separate from the registered diagnostic loggers, so SPDLOG_LEVEL cannot accidentally disable measurement collection. It writes the header once, appends data rows, and flushes before shutdown.


Synchronous versus asynchronous logging

Synchronous flow:

application thread -> format -> write sinks -> continue

Asynchronous flow:

application thread -> queue -> continue
                              background worker -> format -> write sinks

Asynchronous pros

  • Reduces time spent doing file I/O on application threads.
  • Can reduce latency spikes when the disk is temporarily slow.
  • Helps applications producing many diagnostic records from several threads.

Asynchronous cons

  • Requires a thread pool, bounded queue, overflow policy, and careful shutdown.
  • Queued records may be lost if the process crashes.
  • Background errors are harder to report to the application.
  • Queueing adds overhead and may not help at low log rates.
  • More than one worker can complicate ordering.

When the queue is full, a blocking policy preserves records but can stall the producer. overrun_oldest keeps the producer moving by discarding older records.

Do not silently discard required telemetry

overrun_oldest may be acceptable for verbose diagnostic messages. It is a dangerous default for CSV telemetry because missing rows can invalidate analysis. Keep telemetry synchronous or use a blocking queue when every sample matters.

Start with synchronous logging. Move diagnostics to async only after measuring a real logging bottleneck. Always flush important loggers and call spdlog::shutdown() during normal application exit.


Optional FetchContent setup

Ubuntu packages are the supported path for these examples. A project that must pin and build another spdlog release can use CMake FetchContent instead:

include(FetchContent)

FetchContent_Declare(
    spdlog
    GIT_REPOSITORY https://github.com/gabime/spdlog.git
    GIT_TAG v1.17.0
)
FetchContent_MakeAvailable(spdlog)

target_link_libraries(my_application PRIVATE spdlog::spdlog)

Pin a tag rather than following a moving branch. Do not combine the Ubuntu spdlog target and a fetched spdlog target in the same executable.

References