Python Logging for Unattended Scripts
`print` is fine while you are watching. A script that runs at 3am needs a level you can turn up without editing it, a timestamp, the module the message came from, and output on stderr so it does not corrupt whatever is reading stdout. That is `logging`, in about four lines of setup - plus one widely repeated claim about lazy formatting that this guide takes apart with two captures.
Scripting Foundations Guide 6 of 39 Beginner
- Python3.14.4
- Control nodeUbuntu 26.04 LTS
- Managed hostsRHEL 10.0
- requests2.34.2
- paramiko5.0.0
- pytest9.1.1
- PyYAML6.0.3
- boto3 / botocore1.43.78
- TimeAbout 18 min
- Reviewed24 August 2026
Written against the versions above. `logging` has been stable for many releases. The one behaviour worth pinning: with no configuration at all, a message at WARNING or above still appears - the `lastResort` handler, present since Python 3.2.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| RUNNER01 | 192.168.0.27 | Ubuntu 26.04 LTS | Control node - every script in this path runs here | 2 Core | 4 GB | 50 GB |
Before you start
- guide 5 - the status is for the machine, the log is for the human.
- Nothing else.
-
print goes to stdout, logging goes to stderr
That difference is the first reason to switch, and it is easiest to see by throwing each stream away in turn.
bash Example session cat > ~/auto/streams.py <<'PY'import loggingimport sys logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") print("this is print")logging.info("this is logging")PYpython3 ~/auto/streams.pyINFO this is loggingthis is printpython3 ~/auto/streams.py 2>/dev/nullthis is printpython3 ~/auto/streams.py 1>/dev/nullINFO this is loggingExpected resultBoth lines; then only the
print; then only the log line.Success conditionYou know which stream each one uses.
-
The five levels, and the threshold
DEBUG,INFO,WARNING,ERROR,CRITICAL, in that order.basicConfig(level=...)sets the threshold and anything below it is dropped - so the same script says three different amounts.bash Example session cat > ~/auto/levels.py <<'PY'import loggingimport sys level = sys.argv[1] if len(sys.argv) > 1 else "INFO"logging.basicConfig(level=getattr(logging, level), format="%(levelname)-8s %(message)s")log = logging.getLogger("demo") log.debug("the value was %r", {"host": "web01"})log.info("checked 2 hosts")log.warning("disk at 91%%")log.error("host unreachable")log.critical("giving up")PYpython3 ~/auto/levels.py INFOINFO checked 2 hostsWARNING disk at 91%%ERROR host unreachableCRITICAL giving uppython3 ~/auto/levels.py DEBUGDEBUG the value was {'host': 'web01'}INFO checked 2 hostsWARNING disk at 91%%ERROR host unreachableCRITICAL giving uppython3 ~/auto/levels.py WARNINGWARNING disk at 91%%ERROR host unreachableCRITICAL giving upExpected resultFour lines at INFO, five at DEBUG, three at WARNING.
Success conditionYou can change how much a script says without editing it.
-
With no configuration at all
Worth knowing, because it explains a script that logs less than you expected. With no
basicConfig, the default threshold is WARNING.bash Example session cat > ~/auto/noconfig.py <<'PY'import logging log = logging.getLogger("demo")log.info("an info message")log.warning("a warning")log.error("an error")PYpython3 ~/auto/noconfig.pya warningan errorExpected resultThe warning and the error appear; the
infocall produces nothing.Success conditionYou know why your
log.infocalls are silent. -
The format string is where the useful part is
The default format is bare. For anything unattended you want a timestamp, the level, which logger it came from, and where in the code - and it is one argument.
bash Example session cat > ~/auto/format.py <<'PY'import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d %(message)s", datefmt="%Y-%m-%d %H:%M:%S",) log = logging.getLogger("diskcheck")log.info("checked %d host(s)", 2)log.warning("%s is at %d%%", "web01", 91)PYpython3 ~/auto/format.py2026-08-24 03:33:59 INFO diskcheck format.py:10 checked 2 host(s)2026-08-24 03:33:59 WARNING diskcheck format.py:11 web01 is at 91%Expected resultTimestamped lines with the level, logger name, filename and line number.
Success conditionYour log lines say when, how bad, and where from.
-
Percent-style against an f-string
The usual advice is "use
log.debug("x %s", val)rather than an f-string, because it is lazy". Half of that is true. Here is the half that is not:expensive()is an ordinary argument and gets evaluated either way.bash Example session cat > ~/auto/lazy.py <<'PY'import logging logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")log = logging.getLogger("demo") calls = {"n": 0} def expensive(): calls["n"] += 1 return "computed" log.debug("percent style: %s", expensive())print("after percent style, expensive() was called", calls["n"], "time(s)") log.debug(f"f-string: {expensive()}")print("after the f-string, expensive() was called", calls["n"], "time(s)")PYpython3 ~/auto/lazy.pyafter percent style, expensive() was called 1 time(s)after the f-string, expensive() was called 2 time(s)Expected result
expensive()called 1 time after the percent-style call, and 2 after the f-string - even though the level is WARNING and neither line was logged.Success conditionYou know that percent-style does not skip your function call.
-
What is actually deferred is the rendering
Same experiment, with the cost moved from *producing* the value to *rendering* it - an object whose
__str__is expensive.Now the difference shows.
bash Example session cat > ~/auto/lazy2.py <<'PY'import logging logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")log = logging.getLogger("demo") calls = {"str": 0} class Expensive: def __str__(self): calls["str"] += 1 return "the expensive rendering" obj = Expensive() log.debug("percent style: %s", obj)print("percent style -> __str__ called", calls["str"], "time(s)") log.debug(f"f-string: {obj}")print("f-string -> __str__ called", calls["str"], "time(s)") log.warning("above the threshold: %s", obj)print("and when it IS logged, __str__ called", calls["str"], "time(s)")PYpython3 ~/auto/lazy2.pyWARNING above the threshold: the expensive renderingpercent style -> __str__ called 0 time(s)f-string -> __str__ called 1 time(s)and when it IS logged, __str__ called 2 time(s)Expected result
__str__called 0 times for percent-style, 1 for the f-string, and a second time when the message really is logged.Success conditionYou can state precisely what percent-style saves.
-
Writing to a file as well as the terminal
handlers=takes a list, and each handler is a destination. Two of them means the same records go to both.bash Example session cat > ~/auto/tofile.py <<'PY'import loggingfrom pathlib import Path logfile = Path.home() / "auto" / "run.log" logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(message)s", handlers=[logging.FileHandler(logfile), logging.StreamHandler()],) log = logging.getLogger("job")log.info("starting")log.warning("one host was slow")log.info("done")PYpython3 ~/auto/tofile.py2026-08-24 03:34:00,837 INFO starting2026-08-24 03:34:00,837 WARNING one host was slow2026-08-24 03:34:00,837 INFO donecat ~/auto/run.log2026-08-24 03:34:00,837 INFO starting2026-08-24 03:34:00,837 WARNING one host was slow2026-08-24 03:34:00,837 INFO doneExpected resultThree lines on the terminal and the same three in
run.log.Success conditionYou can keep a log after the terminal has gone.
-
The traceback, kept
log.errorrecords your message.log.exceptionrecords the message and the traceback, and is only valid inside anexceptblock.bash Example session cat > ~/auto/withtb.py <<'PY'import logging logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")log = logging.getLogger("job") try: int("not a number")except ValueError: log.error("could not parse the count") log.exception("the same failure, with the traceback")PYpython3 ~/auto/withtb.pyERROR could not parse the countERROR the same failure, with the tracebackTraceback (most recent call last): File "/home/sysadmin/auto/withtb.py", line 7, in <module> int("not a number") ~~~^^^^^^^^^^^^^^^^ValueError: invalid literal for int() with base 10: 'not a number'Expected resultOne bare
ERRORline, then the same level of line followed by the full traceback.Success conditionYou can log a failure without losing where it came from.
-
One logger per module, named for the module
logging.getLogger(__name__)at the top of every file. The names then form a hierarchy that matches your imports, and each one can be turned up or down independently.bash Example session mkdir -p ~/auto/pkg && cat > ~/auto/pkg/collect.py <<'PY'import logging log = logging.getLogger(__name__) def run(): log.info("collecting")PYcat > ~/auto/pkg/main.py <<'PY'import logging import collect logging.basicConfig(level=logging.DEBUG, format="%(name)-10s %(levelname)-8s %(message)s")log = logging.getLogger(__name__) log.info("starting up")collect.run()logging.getLogger("collect").setLevel(logging.WARNING)log.info("turned collect down to WARNING")collect.run()log.info("finished")PYcd ~/auto/pkg && python3 main.py__main__ INFO starting upcollect INFO collecting__main__ INFO turned collect down to WARNING__main__ INFO finishedExpected result
__main__andcollectlabelling their own lines - and aftercollectis set to WARNING, itsinfocall produces nothing while__main__carries on.Success conditionYou can silence one noisy module without touching the rest.
Troubleshooting
log.infoproduces nothing.Why: No
basicConfig, so the threshold is WARNING - orbasicConfigran after the first log call.Fix:Call
basicConfig(level=logging.INFO)once, early, in the script being run. Never in a library.basicConfigappears to be ignored.Why: It is a no-op once the root logger has a handler - a second call does nothing.
Fix:Call it once.
force=Trueoverrides an existing configuration if you really need to.Log lines appear twice.
Why: A handler added twice, or a handler on both a logger and the root while propagation is on.
Fix:Configure handlers in one place.
log.propagate = Falsestops a logger passing records upward.ValueError: unsupported format characterin a log message.Why: A literal
%in a percent-style message.Fix:Double it:
"disk at 91%%".Progress messages corrupt the data a script writes.
Why: Both are going to stdout.
Fix:Log to stderr, which is the default. Keep stdout for data only.
A third-party library floods the log at DEBUG.
Why: The root level applies to every logger that has not set its own.
Fix:
logging.getLogger("noisy.library").setLevel(logging.WARNING).