/user/kayd @ devops :~$ cat python-logging.md

Python Logging Cheatsheet Python Logging Cheatsheet

QR Code linking to: Python Logging Cheatsheet
Karandeep Singh
Karandeep Singh
• 7 minutes

Summary

My notes on Python logging done properly, covering loggers vs handlers, dictConfig, structured JSON output for CloudWatch, rotation, and the traps that produce duplicate or missing lines.

Every incident I have ever worked started with someone reading a log line. Python’s logging module is configured once, at the edge of the program, and then never thought about again — which is exactly why the traps in it bite so hard. This sheet is the configuration I actually use, plus the three behaviours (propagation, double filtering, basicConfig no-ops) that produce duplicate or missing lines. For the language itself see the Python Basics & CLI Cheatsheet.

tip
One rule carries most of this sheet: libraries get a logger, applications get handlers. A module calls logging.getLogger(__name__) and nothing else. The entry point decides where the output goes. Configure handlers in a library and you take that choice away from whoever imports you.

The Five Levels

Docs: Logging levels, Python 3 docs

LevelValueUse it for
DEBUG10Values you need while diagnosing; off in production
INFO20Normal progress: started, deployed, finished
WARNING30Surprising but handled: retry, fallback, deprecation
ERROR40This operation failed; the process continues
CRITICAL50The process cannot continue

WARNING is the default. A fresh logger with no configuration prints warnings and above to stderr, which is why your INFO lines vanish before you have configured anything.

Expand your knowledge with Go Logging Cheatsheet (log/slog)

The Shape of Every Script

import logging

log = logging.getLogger(__name__)      # module-level, by name

def deploy(env: str) -> None:
    log.info("deploying to %s", env)   # %s, not an f-string
    log.debug("full config: %r", cfg)

if __name__ == "__main__":             # only the entry point configures
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%S%z",
    )
    deploy("live")

getLogger(__name__) gives you the dotted module path (myapp.deploy.ecs), which is what makes per-module levels possible later.

Deepen your understanding in Bash CLI One-Liners Cheatsheet

basicConfig, and Why It Silently Does Nothing

Docs: logging.basicConfig

logging.basicConfig(level=logging.INFO)   # ignored if handlers already exist
logging.basicConfig(level=logging.INFO, force=True)   # 3.8+: replace them

Here is the trap firing. logging.warning() on line 2 attaches a handler to the root logger, so the basicConfig() below it has nothing to do:

Explore this further in Go Security Cheatsheet

import logging
logging.warning("deploy starting")   # implicit basicConfig happens HERE
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s")
logging.debug("config loaded")       # never appears
logging.info("deployed")             # never appears
the no-op, demonstrated
$ python3 deploy.pyWARNING:root:deploy starting# one line. The DEBUG and INFO calls printed nothing, and the format is# still the default "LEVEL:logger:message" — basicConfig never ran.
warning
basicConfig() returns immediately if the root logger already has a handler. Importing a library that configures logging, or calling logging.info() anywhere before your setup line, attaches one — and your configuration is dropped with no error. force=True removes the existing handlers first.

Loggers, Handlers, Formatters

Docs: Logging HOWTO: advanced

A record passes two level checks: the logger’s, then each handler’s.

log = logging.getLogger("deployer")
log.setLevel(logging.DEBUG)              # check 1

console = logging.StreamHandler()        # stderr by default
console.setLevel(logging.INFO)           # check 2: DEBUG never reaches it

fileh = logging.FileHandler("deploy.log")
fileh.setLevel(logging.DEBUG)            # but it does reach this one

fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
console.setFormatter(fmt)
fileh.setFormatter(fmt)

log.addHandler(console)
log.addHandler(fileh)

Set the logger to the most verbose level you ever want, and let each handler decide what it keeps.

Discover related concepts in Ansible Cheatsheet

FieldGives you
%(asctime)sTimestamp, formatted by datefmt
%(levelname)sINFO, ERROR, … — pad it: %(levelname)-8s
%(name)sThe logger’s dotted name, so myapp.deploy.ecs
%(message)sThe formatted message
%(filename)s %(lineno)dWhere the call was made
%(funcName)s %(module)sWhich function, which module
%(process)d %(threadName)sWhich process, which thread
%(exc_text)sThe traceback, once a handler has formatted it
%(asctime)s %(levelname)-8s %(name)s %(message)s

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

dictConfig: the One I Actually Use

Docs: logging.config.dictConfig

One dictionary at startup beats a scatter of addHandler calls.

Uncover more details in Bash Basics Cheatsheet

import logging.config

logging.config.dictConfig({
    "version": 1,
    "disable_existing_loggers": False,      # see the trap below
    "formatters": {
        "plain": {
            "format": "%(asctime)s %(levelname)-8s %(name)s %(message)s",
            "datefmt": "%Y-%m-%dT%H:%M:%S%z",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "plain",
            "level": "INFO",
            "stream": "ext://sys.stdout",   # stdout, so it is not read as an error
        },
    },
    "loggers": {
        "botocore": {"level": "WARNING"},   # quiet a chatty dependency
        "urllib3": {"level": "WARNING"},
    },
    "root": {"handlers": ["console"], "level": "INFO"},
})
warning
"disable_existing_loggers": True is the default, and it switches off every logger created before this call — which, thanks to module-level getLogger(__name__), means every module you have already imported. Set it to False unless you specifically want that.

Exceptions and Tracebacks

Docs: logging.Logger.exception

try:
    deploy("live")
except Exception:
    log.exception("deploy failed")        # ERROR + traceback, inside except
    raise

log.error("deploy failed", exc_info=True) # same thing, usable anywhere
log.warning("retrying", exc_info=True)    # traceback at another level

log.error("deploy failed: %s", e)         # loses the traceback entirely

log.exception() only belongs inside an except block. Called anywhere else it still logs at ERROR, and then tells you there was nothing to attach:

Journey deeper into this topic with Python Basics & CLI Cheatsheet

exception() with no active exception
$ python3 oops.pyERROR deployer deploy failedNoneType: None# "NoneType: None" is the traceback it could not find. If you see this in# your logs, the except block moved and the log call did not.

Extra context on a record

log.info("deployed", extra={"env": "live", "replicas": 3})
# the keys become attributes on the record: %(env)s in a formatter,
# or fields in a JSON formatter. "message", "asctime" and "args" are
# reserved and raise KeyError if you reuse them.

Structured JSON Logs

CloudWatch Logs Insights, Loki and friends all want one JSON object per line. No dependency needed:

import json, logging

class JsonFormatter(logging.Formatter):
    RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__)

    def format(self, record: logging.LogRecord) -> str:
        out = {
            "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        if record.exc_info:
            out["exc"] = self.formatException(record.exc_info)
        # anything passed via extra=
        for k, v in record.__dict__.items():
            if k not in self.RESERVED and not k.startswith("_"):
                out[k] = v
        return json.dumps(out, default=str)

h = logging.StreamHandler()
h.setFormatter(JsonFormatter())
logging.getLogger().addHandler(h)

default=str stops a stray datetime or Decimal from raising inside the logger, which would otherwise take down the call site.

Enrich your learning with Go Logging Cheatsheet (log/slog)

Rotation

Docs: logging.handlers

Gain comprehensive insights from Bash Basics Cheatsheet

from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

RotatingFileHandler("app.log", maxBytes=10_000_000, backupCount=5)
TimedRotatingFileHandler("app.log", when="midnight", backupCount=14, utc=True)
note
On a container, do not rotate at all — log to stdout and let the platform collect it. Rotation matters on a long-lived VM. Two processes writing one RotatingFileHandler will also corrupt each other’s rollover; use WatchedFileHandler with external logrotate instead.

Propagation and Duplicate Lines

Docs: Logger.propagate

A record walks up the dotted name to the root, hitting every handler on the way. Add a handler to both myapp and the root and each line prints twice.

log = logging.getLogger("myapp.deploy")
log.propagate = False       # stop here; do not also hand it to root

logging.getLogger().handlers          # what is actually attached
logging.getLogger("botocore").level   # what a dependency is set to

Duplicate lines are nearly always one of three things: a handler on both a child and the root, basicConfig() called twice, or a module adding a handler at import time.

Master this concept through Bash CLI One-Liners Cheatsheet

Capturing Warnings and Third-Party Noise

logging.captureWarnings(True)          # warnings module -> "py.warnings"
logging.getLogger("botocore").setLevel(logging.WARNING)
logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)

Common Pitfalls

caution
Logging secrets. A request object, an env dict or a boto3 response logged at DEBUG will happily print tokens and keys into CloudWatch, where they are retained and searchable. Log the fields you need by name, never the whole object. There is more on handling them in the Python Security Cheatsheet.
  • f-strings in log calls. log.debug(f"{expensive()}") runs expensive() even when DEBUG is off. log.debug("%s", expensive()) still evaluates the argument — only the formatting is deferred. For genuinely costly work, guard it: if log.isEnabledFor(logging.DEBUG):.

    git diff — deploy.pythe guard, not the f-string
    @@ -8,7 +8,8 @@ def deploy(env: str) -> None:-    log.debug(f"config: {json.dumps(cfg, indent=2)}")-    log.info(f"deploying to {env}")+    if log.isEnabledFor(logging.DEBUG):          # skip the dumps() entirely+        log.debug("config: %s", json.dumps(cfg, indent=2))+    log.info("deploying to %s", env)

  • print() in a library. It cannot be levelled, filtered, routed or turned off by the caller.

  • The root logger as the app logger. logging.info(...) at module scope calls the root logger and triggers an implicit basicConfig(), which is what later makes your basicConfig() a no-op.

  • Handlers added at import time. Import order then decides your logging configuration.

  • Levels as strings. setLevel("info") raises; it is "INFO" or logging.INFO.

Question

Pick the last ERROR your service logged. Does the line say which host, which environment and which request it came from — or only that something broke?

Deepen your understanding in Bash CLI One-Liners Cheatsheet

0

Similar Articles

More from development

Go (Golang) Cheatsheet

Go cheatsheet covering the go toolchain, syntax essentials, goroutines and channels, table-driven …

Bash Basics Cheatsheet

Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

🍪 We use cookies for analytics. Read the privacy policy