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

Summary
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.
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
| Level | Value | Use it for |
|---|---|---|
DEBUG | 10 | Values you need while diagnosing; off in production |
INFO | 20 | Normal progress: started, deployed, finished |
WARNING | 30 | Surprising but handled: retry, fallback, deprecation |
ERROR | 40 | This operation failed; the process continues |
CRITICAL | 50 | The 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
$ 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.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
| Field | Gives you |
|---|---|
%(asctime)s | Timestamp, formatted by datefmt |
%(levelname)s | INFO, ERROR, … — pad it: %(levelname)-8s |
%(name)s | The logger’s dotted name, so myapp.deploy.ecs |
%(message)s | The formatted message |
%(filename)s %(lineno)d | Where the call was made |
%(funcName)s %(module)s | Which function, which module |
%(process)d %(threadName)s | Which process, which thread |
%(exc_text)s | The traceback, once a handler has formatted it |
%(asctime)s %(levelname)-8s %(name)s %(message)s
dictConfig: the One I Actually Use
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"},
})
"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
$ 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)
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
f-strings in log calls.
log.debug(f"{expensive()}")runsexpensive()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):.@@ -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 implicitbasicConfig(), which is what later makes yourbasicConfig()a no-op.Handlers added at import time. Import order then decides your logging configuration.
Levels as strings.
setLevel("info")raises; it is"INFO"orlogging.INFO.
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
Related Cheatsheets
- Python Basics & CLI Cheatsheet — the syntax underneath this
- Python Security Cheatsheet — what must never reach a log line
- Python Threading & Concurrency Cheatsheet — logging from threads, and why
QueueHandlerexists
Similar Articles
Related Content
More from development
Python basics and CLI cheatsheet: core syntax, f-strings, comprehensions, pathlib, venv, pip, …
Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …
You Might Also Like
Python threading and concurrency cheatsheet: Thread, Lock, Queue, ThreadPoolExecutor, …
Go log/slog cheatsheet: structured logging, text and JSON handlers, levels, With and Group, context …
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.
Question 1 of 5
Quiz Complete!
Your score: 0 out of 5
Loading next question...
Contents
- The Five Levels
- The Shape of Every Script
- basicConfig, and Why It Silently Does Nothing
- Loggers, Handlers, Formatters
- dictConfig: the One I Actually Use
- Exceptions and Tracebacks
- Structured JSON Logs
- Rotation
- Propagation and Duplicate Lines
- Capturing Warnings and Third-Party Noise
- Common Pitfalls
- Related Cheatsheets

