Everyone learns the advice that globals are bad. They're unpredictable, they hide dependencies, and they rot codebases from the inside out. And then, right after nodding along to this good advice, someone says: "Well, except for logging. You need a global logger."

No, stop, this is not an exception. It's exhibit A...


Hidden dependencies

A function looks pure and self contained, but surprise inside it's quietly spitting to a global logger. That means you can't reason about the function in isolation anymore. It has invisible side effects.

That's the whole reason globals are toxic: you can't look at a piece of code and know what it actually does. With a global logger, you've just injected that problem into every single function in your system.

Testing Hell

Ever tried testing code that writes to a global logger? Congratulations, your tests are now all coupled together by the same output stream.

  • Want to assert on logs? You're capturing global state
  • Want to run tests in parallel? Enjoy having your logs interleaved and confusing to reason about.
  • Want deterministic logs? Too bad, everything is going to be bleeding logs and you have no way to control this.

Testing with globals is already bad enough. Testing with a global side effect generator is basically impossible (Unless you completely ignore logs, but this is an entirely different blog post and I won't get into it here)

Runtime Inflexibility

Say you want to start with std::cout but later you need structured JSON logs. Or you want one subsystem logging to a file, another logging to stdout. With a global logger, you're either hacking together configuration flags or somehow going to be rewriting the entire codebase. A decision with loggers that was "supposed to make life easier" just turned into the most rigid piece of your entire architecture.

Having a single global logger means you have one format and one policy. Forever. The second you want more, you're breaking the singleton contract and adding a dozen hacks on top of it. You've reinvented dependency injection badly...

"But passing around a logger is so much work!"

No, it isn't. You're a developer, you type for a living, typing is literally part of the job.

Passing an object explicitly is not some unbearable burden, it's called clarity. It tells you what a function needs, right there is the signature. If your main concern is saving keystrokes, maybe programming isn't the right career path. Another thing I've found is, if you're thinking passing a logger around is too much work I ask, have you actually tried doing this? Before knocking it and saying "it's too much work" give it a shot, it's much less work than you likely expect.

The Illusion of Simplicity

Global loggers are a siren call because they look simple on the box, you don't have to think about them, you can just call log("Hi!"); and be done with it.

Except the simplicity is fake, that function call is not doing some trivial operation, it might be sending a network request, could be saving to a file, could just be printing to stdout, you don't know. The complexity of passing a logger around and being explicit about things didn't disappear by making it a global. It just got shoved into a hidden global state machine that every single line of code depends on. You've traded being explicit in your code for a silent landmine.

Let's explore this code

Logger g_logger = Logger();

struct SystemB {
  void run() {
    g_logger.info("starting to run");

    if (might_misbehave() != 0) { // For the sake of the example
      g_logger.warn("something weird happened");
    }

    g_logger.info("finished running");
  }
};

struct SystemA {
  void run() {
    g_logger.info("Starting system B");
    auto b = SystemB();
    b.run();
    g_logger.info("system B finished");
  }
};

int main() {
  auto a = SystemA();
  a.run();
}

Looks great doesn't it? Just sprinkle a g_logger->info(...) anywhere and you've got logs!

But, what happens if you want to do something more? Like say System A owns a UI. It should:

  • Always display the info logs from System B.
  • Only show a "warnings" tab if System B actually produced warnings.

With a global logger, this is practically impossible to do nicely (I invite the reader to make an attempt at this even in this simple code, and then attempt to extend their solution to a codebase that's hundreds of thousands of lines of code and see if it will still hold up). The moment System B writes to the global, System A has no idea if a warning happened. Did it? Didn't it? You can't tell, because you handed responsibility to a global side effect.

If we flip this to explicit logging, the relationship is clear:

struct SystemB {
  void run(Logger *log) {
    log->info("starting to run");

    if (might_misbehave() != 0) { // For the sake of the example
      log->warn("something weird happened");
    }

    log->info("finished running");
  }
};

struct SystemA {
  void run() {
    auto log = Logger();
    log.info("Starting system B");

    auto capture_logger = OtherLogger();
    auto b = SystemB();
    b.run(&capture_logger);
    log.info("system B finished");
    if (capture_logger.has_warnings()) {
      // Do something here
    }
  }
};

int main() {
  auto a = SystemA();
  a.run();
}

Now System A knows whether warnings happened, because it owns the logger. It can decide how to reflect that in the UI. And what if you don't need this? You just want the logs to go through and not worry about it? Well, then just pass System A's logger to System B and call it a day.

Globals make code look simpler by hiding relationships. But those relationships are still there, you just gave up on your ability to reason about them.

The Alternative

It's not rocket science, pass a logger around.

Yes, it's a few extra characters in per function signature. But no, it will not kill you, you will be ok. Globals are bad in your code, loggers aren't an exception, they're the most pathological case for why globals are bad in the first place: hidden state, brittle tests, runtime inflexibility, and lazy design.


Disclaimer

I understand some codebases are already made with a global logger in mind. Changing them might not be something you can even be allowed to do due to the time sink. After all, we're developers, we talk for hours about best practices and then write shitty code under the lie of "it's temporary". But we should strive for clean, maintainable, explicit code anyways.

Globals Are Always Bad (Even Loggers)