When developers first look at a modern C++ logging library, one thing often surprises them: the number of macros.
At first glance, this raises an obvious question: why are there so many of them? Wouldn’t one clean Log() function be enough? Yet libraries such as logme provide dozens of variants, including LogmeI, LogmeW, LogmePV, fLogmeD, CH, SID, CHINT, and others.
At first glance, this can look like a relic from the C89 era. However, once you try to build a logging system that is both convenient and fast enough for production applications, an uncomfortable fact becomes clear: many C++ language features work against you.
That is why serious logging libraries almost inevitably end up with a substantial macro layer.
A Simple Logging Function Looks Good Only in Small Examples
Most logging systems start with something like this:
void Log(LogLevel level, const std::string& text);
Or, in a more modern style:
template<typename... Args>
void Log(LogLevel level, std::format_string<Args...> fmt, Args&&... args);
In a small test project, this looks great. Problems appear very quickly in a real codebase.
For example, you usually want every log entry to include:
- source file
- line number
- function name
- subsystem
- channel
- thread ID
Of course, you can pass __FILE__ and __LINE__ manually. However, after a few days, nobody wants to write code like this:
Log(
INFO
, __FILE__
, __LINE__
, __FUNCTION__
, "Connected to {}"
, host
);
A macro solves the same problem much more naturally:
LogmeI("Connected to %s", host.c_str());
Now the library can determine automatically:
- where the call came from
- which log level was used
- which subsystem is active
- which output flags apply
This is where the number of macros starts to grow.
Why C++ Logging Macros Are Still Used
Macros have a poor reputation in C++, and often for good reason. However, logging is one of the areas where they remain genuinely useful.
The main reason is the cost of disabled logging.
Consider this code:
LogmeD("Result: %s", ExpensiveOperation().c_str());
When DEBUG logging is disabled, developers expect the overhead to be close to zero. Without a macro, that is difficult to achieve.
A regular function receives arguments only after they have already been evaluated. In other words, ExpensiveOperation() still runs even when the resulting log message will never be written.
A macro can check whether the level is enabled before evaluating the arguments:
if (Channel->CanWrite(DEBUG))
{
...
}
This may seem like a small detail until an application starts processing millions of log calls per second.
logme pays particular attention to this path. Disabled logging should be as cheap as possible, which is why a significant part of its logic lives in macros.
Why There Are So Many Logging Macros
This is where things become more interesting.
Once a logging library grows beyond a simple utility, one calling style is no longer enough.
Some developers prefer printf-style formatting:
LogmeI("Socket %d connected to %s", socket, host.c_str());
Others prefer stream-style logging:
LogmeI() << "Socket " << socket << " connected to " << host;
logme supports both styles through the same macro.
When a format string is supplied, the macro uses formatted logging. When it is called without arguments, it returns an object that supports stream output.
In large codebases, this is very convenient. Different components often use different styles for historical reasons, and a logging library should not force an unnecessary rewrite.
At the same time, this flexibility introduces additional complexity:
- single-statement semantics
- temporary objects
- stream builder lifetime
- empty
__VA_ARGS__ - MSVC preprocessor quirks
All of that has to be handled inside the macro implementation.
Compiler Preprocessors Behave Differently Too
This is another major source of pain for any serious C++ logging library.
Many developers do not encounter these issues until they try to create a complex variadic macro.
For example:
#define MYLOG(...)
looks simple. Then the differences start to appear:
- older MSVC versions behave differently from Clang
__VA_OPT__is not available everywhere- IntelliSense may parse code differently from the real compiler
- different MSVC preprocessor modes can produce different results
At some point, a logging library can contain almost as much compatibility code for preprocessors as it does logging code.
This is especially noticeable in logme because it supports many macros and multiple calling modes.
Why Inline Functions Do Not Replace Logging Macros
After std::source_location appeared, many developers thought the problem had finally been solved.
Now it is possible to write code like this:
Log(
std::source_location::current(),
"Connected"
);
This definitely helps. However, it cannot completely replace macros.
Several problems remain:
- disabled logging still has to avoid evaluating arguments
- compile-time shortcuts are still useful
- log levels should remain convenient to specify
- call sites should have minimal syntactic noise
- stream-style logging still needs support
In real production code, the difference between:
LogmeW("Connection timeout");
and:
Log(
LogLevel::Warning,
std::source_location::current(),
"Connection timeout"
);
matters more than it may initially seem.
That difference becomes especially important when a project contains tens of thousands of log calls.
At Some Point, Logging Becomes a DSL
This is an interesting effect that becomes obvious in larger logging frameworks.
Macros gradually form a small language inside C++.
For example, logme provides:
- subsystem macros
- channel shortcuts
- profiling macros
- conditional logging
- once-only and interval-based logging
- both stream-style and formatted calls
The code starts to look less like a collection of function calls and more like a description of system events:
LogmePV();
LogmeD(CH("NET"), "connected to %s", host.c_str());
LogmeW_EVERY(1000, "retrying...");
This is not accidental.
A good logging system should create as little friction as possible. Developers should be able to add useful diagnostics quickly, without thinking about formatting internals, call-site metadata, cache lifetime, or the disabled path.
That is why libraries such as logme gradually evolve toward a larger set of specialized macros.
The Hard Part Is Making It Fast
From the outside, logging macros look simple. In practice, a modern logging library usually hides a large amount of optimization behind them.
For example, logme uses ContextCache: a static object at the call site that stores already parsed formatting state.
This avoids parsing the same format string repeatedly and reduces overhead during heavy logging.
For the developer, the call remains simple:
LogmeI("value=%d", value);
Internally, however, the library already:
- knows the call site
- caches formatting information
- reuses state
- minimizes allocations
- keeps the disabled path inexpensive
A significant part of this mechanism depends on macros.
There Is No Perfect Solution
Sometimes people say that a “proper” C++ library should not need macros.
In practice, the situation is more complicated.
When a logging library must be:
- fast
- convenient
- portable
- compatible with multiple calling styles
- capable of near-zero-cost disabled logging
- able to collect context automatically
the number of macros will almost inevitably grow.
That is not necessarily a sign of poor design. More often, it is a consequence of solving real production problems rather than merely looking elegant in small documentation examples.

