Logging & Debugging: Beyond print()
1 · The lesson
readprint is a debugger. The logging module is observability. The difference matters the day your script becomes a service: print has no levels, no per-module silencing, no timestamps, no rotation, no routing to a file or a log aggregator. By the time you wish you had those things, you've usually just had a 3 a.m. incident.
This lesson covers the mental model — loggers, handlers, formatters, levels — the production setup pattern, structured logging for log aggregators, the small set of debugging tools that beat sprinkling print calls, and the mistakes that cause "my logs are duplicated" or "my logs are missing" tickets in every Python codebase.
1. The Mental Model — Four Pieces
The logging module separates four concerns. Once you see them as distinct, the API stops being confusing.
- Loggers — named channels. Code calls
logger.info(...). By convention every module doeslogger = logging.getLogger(__name__)at the top, giving each module its own logger named after its dotted path. - Handlers — destinations. A handler routes log records to stderr, a file, syslog, a network socket, or an email. A logger can have multiple handlers.
- Formatters — how each record turns into a string (or a JSON object). Attached to handlers, not loggers.
- Levels — filter.
DEBUG < INFO < WARNING < ERROR < CRITICAL. Set on loggers and handlers; a record has to clear both.
Records flow: module code → logger → (level filter) → handlers → (level filter) → formatter → destination. Once that picture is in your head, every config decision has an obvious place.
2. Levels — Use Them Right
Five levels, in order:
| Level | Use for |
|---|---|
DEBUG | Verbose diagnostic detail — variable dumps, "entered function" |
INFO | Normal operational events — "started", "processed 200 rows" |
WARNING | Weird but recoverable — "retry succeeded after 2 attempts" |
ERROR | An operation failed — request returned 500, job aborted |
CRITICAL | The system is broken — disk full, can't reach database |
The most common mistake is over-using ERROR. "User not found" on a "check whether this user exists" code path is INFO at most — it's not an error, it's an expected outcome. Reserve ERROR for things that genuinely failed and probably need human attention.
In production you typically run at INFO and drop to DEBUG only when investigating. That's why module-level loggers and per-module level control matter — you want to crank myapp.payments to DEBUG without drowning in myapp.cache chatter.
3. Basic Setup — logging.basicConfig
For a single-file script, one call is enough:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger(__name__) logger.info("started") logger.warning("disk at %d%%", 92)
basicConfig attaches a StreamHandler (writes to stderr) with the formatter you supplied to the root logger. Every other logger inherits from root by default, so every module's logger.info(...) is now formatted and printed.
Caveats:
basicConfigis a no-op if the root logger already has handlers. Call it once, at the entry point.- It's fine for scripts and quick experiments. For libraries, do not call
basicConfig— let the application owner configure logging.
4. Real Setup — Module-Level Loggers
Every non-trivial module starts with the same two lines:
# payments.py import logging logger = logging.getLogger(__name__) # → "myapp.payments" if module is myapp.payments def charge(user, amount): logger.info("charging user %s amount=%s", user.id, amount) ...
Three benefits:
- The log line shows
myapp.paymentsas the source — you can grep, filter, or route by module. - The application owner can dial this module up or down independently:
logging.getLogger("myapp.payments").setLevel(logging.DEBUG). - Your library doesn't impose handlers on the consumer. If they never configure logging, nothing prints — which is the right default for a library.
Configure handlers exactly once, in the entry point (main.py, manage.py, wsgi.py). Configuring in two places stacks handlers and you'll see every log line twice. See Section 11.
5. Handlers — Where Records Go
The standard library ships everything you need:
import logging from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler # Stderr — the default console = logging.StreamHandler() # Plain file — grows forever (don't ship this to production without rotation) plain = logging.FileHandler("app.log", encoding="utf-8") # Size-based rotation — 10 MB per file, keep 5 backups rotating = RotatingFileHandler("app.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8") # Time-based rotation — new file each midnight, keep 14 days daily = TimedRotatingFileHandler("app.log", when="midnight", backupCount=14, encoding="utf-8")
RotatingFileHandler flips to a new file when the current one hits maxBytes; older files are renamed app.log.1, app.log.2, etc., and anything past backupCount is deleted. TimedRotatingFileHandler does the same on a time interval.
Always use rotation in production. A non-rotating file fills the disk eventually — a 100 GB app.log at 4 a.m. is a real incident category.
6. Formatters — One Line of Code, Hours Saved
Formatters define the shape of each log line. The format string uses %-style fields:
import logging fmt = logging.Formatter( "%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d - %(message)s" ) h = logging.StreamHandler() h.setFormatter(fmt) logging.getLogger().addHandler(h)
The fields you'll actually use:
| Field | What it gives you |
|---|---|
%(asctime)s | Timestamp |
%(levelname)s | INFO, WARNING, ... |
%(name)s | Logger name (i.e. __name__) |
%(message)s | The actual log message |
%(filename)s | Source file |
%(lineno)d | Line number |
%(funcName)s | Function that emitted the log |
%(process)d | Process ID |
%(threadName)s | Thread name |
A formatter that includes file + line is a debugging superpower — when you grep production logs at 3 a.m., the source location is exactly what you need.
7. Lazy Formatting — The %s Habit
Two equivalent-looking calls; one is wasteful:
logger.info("user %s did %s", user, action) # lazy — formats only if INFO is enabled logger.info(f"user {user} did {action}") # eager — formats now, even if filtered out
setup added so this can run · defines user, action, logger
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) user = _AutoMock('user') action = _AutoMock('action') logger = _AutoMock('logger')
If INFO is below the current level, the lazy form skips the string interpolation entirely. The f-string form has already built the message before logger.info decides to drop it. For hot paths or DEBUG-level statements in tight loops, the difference is measurable.
For complex log calls you can also short-circuit:
if logger.isEnabledFor(logging.DEBUG): logger.debug("state: %s", expensive_repr(big_object))
setup added so this can run · defines logger, logging, expensive_repr, big_object
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logger = _AutoMock('logger') logging = _AutoMock('logging') def expensive_repr(*_a, **_kw): print('-> expensive_repr() called') return _AutoMock('expensive_repr()') big_object = _AutoMock('big_object')
Defaults: f-strings are fine for occasional logs in cold paths; the %s form is the right default for library code and anything in a loop.
8. logger.exception — Always Use It Inside except
The right way to log an exception:
try: risky() except Exception: logger.exception("risky() failed") # includes the full traceback
setup added so this can run · defines risky, logger
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def risky(*_a, **_kw): print('-> risky() called') return _AutoMock('risky()') logger = _AutoMock('logger')
logger.exception(msg) is logger.error(msg, exc_info=True) — it logs at ERROR level and appends the formatted traceback. The traceback is the part that actually tells you where the failure came from; print(e) or logger.error(str(e)) throws it away.
This is the single biggest "I wish I'd known this earlier" item in the whole logging API. Use it religiously in every except block that catches without re-raising. See exceptions Section 8 for more.
9. Production Pattern — dictConfig
For real apps, configuring handlers in code gets unwieldy. logging.config.dictConfig takes a single dict that describes everything:
import logging.config LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "standard": { "format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s", }, }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "INFO", "formatter": "standard", }, "file": { "class": "logging.handlers.RotatingFileHandler", "filename": "logs/app.log", "maxBytes": 10_000_000, "backupCount": 5, "level": "DEBUG", "formatter": "standard", "encoding": "utf-8", }, }, "loggers": { "": { # root logger "handlers": ["console", "file"], "level": "DEBUG", }, "urllib3": {"level": "WARNING"}, # quieten chatty deps "boto3": {"level": "WARNING"}, }, } logging.config.dictConfig(LOGGING)
That same dict can come from a YAML or JSON file via yaml.safe_load / json.load. The dict form is the production standard — it's declarative, fully featured, and the same shape across every framework.
10. Structured Logging — JSON for Aggregators
Log aggregators (Datadog, ELK, Loki, CloudWatch) want structured records, not free-form text. The cleanest stdlib-friendly approach is python-json-logger:
# pip install python-json-logger from pythonjsonlogger import jsonlogger handler = logging.StreamHandler() handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")) logger = logging.getLogger("myapp") logger.addHandler(handler) logger.info("user logged in", extra={"user_id": 42, "ip": "10.0.0.1"})
setup added so this can run · defines logging
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logging = _AutoMock('logging')
Output:
{"asctime": "2026-05-14 09:30:00", "levelname": "INFO", "name": "myapp",
"message": "user logged in", "user_id": 42, "ip": "10.0.0.1"}The extra= dict gets merged into the JSON record. The aggregator indexes those fields, so you can query "all user_id=42 events in the last hour" without grepping text.
For full pipelines — context binding, processors, async-friendly — structlog is the deeper library. The mental model is the same; the API is more pleasant.
11. Per-Module Levels and Quietening Dependencies
Third-party libraries are noisy. urllib3 logs every retry at DEBUG, botocore logs every API call, matplotlib has an entire personality. Turn them down:
logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("botocore").setLevel(logging.WARNING) logging.getLogger("matplotlib").setLevel(logging.WARNING)
setup added so this can run · defines logging
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logging = _AutoMock('logging')
Same trick in reverse — turn one of your modules up to DEBUG for a focused debug session without touching the rest:
logging.getLogger("myapp.payments").setLevel(logging.DEBUG)
setup added so this can run · defines logging
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logging = _AutoMock('logging')
Per-logger level + module-named loggers (Section 4) is the entire trick to readable logs.
12. Don't Log Secrets
API keys, passwords, JWTs, credit-card numbers, and full request bodies are constantly leaked into logs by well-meaning logger.debug("request: %s", request) calls. Logs end up in S3, in support tickets, in screenshots in Slack. Once a secret is in a log, assume it's been seen by everyone with log access — and that's usually a lot of people.
A tiny redactor goes a long way:
SENSITIVE_KEYS = {"password", "token", "secret", "api_key", "authorization", "card"}
def redact(d):
"""Return a shallow copy with sensitive values masked."""
return {k: ("***" if k.lower() in SENSITIVE_KEYS else v) for k, v in d.items()}
logger.info("incoming request body=%s", redact(payload)) setup added so this can run · defines logger, payload
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) logger = _AutoMock('logger') payload = _AutoMock('payload')
For full coverage, a custom logging.Filter can scrub records before they reach the formatter. Either way: assume nothing scrubs by default. You have to.
13. Correlation IDs — Tracing One Request Across Many Logs
When 200 requests are in flight, the only way to follow one is to tag every log line with a request-scoped ID. The pattern uses a LoggerAdapter or a Filter that pulls the ID from a thread-local or contextvars:
import contextvars, logging request_id_var = contextvars.ContextVar("request_id", default="-") class RequestIdFilter(logging.Filter): def filter(self, record): record.request_id = request_id_var.get() return True # Format string then includes %(request_id)s: # "%(asctime)s %(levelname)s [%(request_id)s] %(name)s: %(message)s"
In your web framework's middleware, set request_id_var.set(uuid4().hex) at the start of each request. Every log line from anywhere in that request now carries the same ID. Open-source: most frameworks have a ready-made middleware (asgi-correlation-id, django-log-request-id). Roll your own only if you must.
14. Debugging Beyond Logs — breakpoint()
Python 3.7 added breakpoint() as a stdlib entrypoint to whichever debugger you have configured (PDB by default):
def transform(payload): cleaned = sanitize(payload) breakpoint() # drops into PDB right here return enrich(cleaned)
setup added so this can run · defines sanitize, enrich
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def sanitize(*_a, **_kw): print('-> sanitize() called') return _AutoMock('sanitize()') def enrich(*_a, **_kw): print('-> enrich() called') return _AutoMock('enrich()')
The PDB commands you need:
| Command | What it does |
|---|---|
n | Next line (step over) |
s | Step into a call |
c | Continue until next breakpoint |
l | List source around current line |
p expr | Print expr |
pp expr | Pretty-print |
w | Where am I — show the stack |
q | Quit |
Power-ups: ipdb (IPython's PDB — tab-complete, syntax highlight), pudb (full-screen TUI). Set the env var PYTHONBREAKPOINT=ipdb.set_trace and breakpoint() uses it.
For "verbose warnings + checks on" development runs: python -X dev or PYTHONDEVMODE=1. Surfaces resource-warning leaks, unawaited coroutines, and other latent bugs.
15. Common Mistakes
1. print in libraries
A library that prints can't be silenced and can't be routed. Always use a module-level logger; let the consumer configure output.
2. Configuring logging multiple times
Two basicConfig calls (or two addHandler calls) means two handlers, means every line is logged twice. Configure exactly once, in the entry point.
3. Wrong levelERROR for "user not found" on a probing check is noise. INFO for "started up" is right. Match the level to whether a human needs to do anything.
4. Catching exception and logging only str(e)
You lose the traceback — i.e. the part that tells you where. Use logger.exception(msg) inside except. See exceptions Section 8.
5. Logging to both stdout and stderr
Pipes interleave the two streams unpredictably. Pick one — stderr is the conventional choice for logs — and keep stdout for actual program output.
6. Eager formatting below thresholdlogger.debug(f"big: {expensive()}") calls expensive() even when DEBUG is filtered out. Use lazy %s form or guard with isEnabledFor.
7. No rotation
The 100 GB app.log problem. Always use RotatingFileHandler or TimedRotatingFileHandler for production files.
🎯 Your Turn — setup_logging
Write a setup_logging(app_name, level="INFO", log_dir="logs") function that:
1. Creates log_dir if it doesn't exist.
2. Configures a console handler (stderr, human-readable format) at INFO.
3. Configures a daily-rotating file handler (log_dir/{app_name}.log) at DEBUG with JSON formatting.
4. Returns the logger named app_name.
5. Quietens urllib3 and botocore to WARNING.
Then demonstrate it by logging at three levels including an exception inside an except block.
import logging def setup_logging(app_name, level="INFO", log_dir="logs"): # TODO 1: ensure log_dir exists # TODO 2: build dictConfig with console + rotating-file JSON handlers # TODO 3: apply with logging.config.dictConfig # TODO 4: return logging.getLogger(app_name) ...
Hint 1 — Daily rotation
logging.handlers.TimedRotatingFileHandler with when="midnight" rolls the file every day and appends a date suffix to the rotated file. Set backupCount to e.g. 30 to keep a month of history.
Hint 2 — JSON format
Usepython-json-logger's JsonFormatter. In the dict-config form, set "()": "pythonjsonlogger.jsonlogger.JsonFormatter" and pass the format string as "format".
Show full solution
import logging.config from pathlib import Path def setup_logging(app_name, level="INFO", log_dir="logs"): """Configure console + daily-rotating-JSON-file logging for an app.""" log_path = Path(log_dir) log_path.mkdir(parents=True, exist_ok=True) config = { "version": 1, "disable_existing_loggers": False, "formatters": { "console": { "format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s", }, "json": { "()": "pythonjsonlogger.jsonlogger.JsonFormatter", "format": "%(asctime)s %(levelname)s %(name)s %(message)s %(filename)s %(lineno)d", }, }, "handlers": { "console": { "class": "logging.StreamHandler", "level": level, "formatter": "console", }, "file": { "class": "logging.handlers.TimedRotatingFileHandler", "filename": str(log_path / f"{app_name}.log"), "when": "midnight", "backupCount": 30, "level": "DEBUG", "formatter": "json", "encoding": "utf-8", }, }, "loggers": { app_name: { "handlers": ["console", "file"], "level": "DEBUG", "propagate": False, # don't double-emit via root }, "urllib3": {"level": "WARNING"}, "botocore": {"level": "WARNING"}, }, } logging.config.dictConfig(config) return logging.getLogger(app_name) # Demo logger = setup_logging("myapp") logger.info("service starting up") logger.warning("retry attempt %d after transient error", 2) try: 1 / 0 except ZeroDivisionError: logger.exception("math went off the rails")
Console output (human-readable):
2026-05-14 09:30:00,123 INFO myapp: service starting up 2026-05-14 09:30:00,124 WARNING myapp: retry attempt 2 after transient error 2026-05-14 09:30:00,125 ERROR myapp: math went off the rails Traceback (most recent call last): File "...", line ..., in <module> 1 / 0 ZeroDivisionError: division by zero
File output logs/myapp.log (JSON, one record per line):
{"asctime": "2026-05-14 09:30:00,123", "levelname": "INFO", "name": "myapp",
"message": "service starting up", "filename": "demo.py", "lineno": 32}
{"asctime": "2026-05-14 09:30:00,125", "levelname": "ERROR", "name": "myapp",
"message": "math went off the rails", "filename": "demo.py", "lineno": 37,
"exc_info": "Traceback (most recent call last):\n File ...\nZeroDivisionError: division by zero"}What this gives you:
- Two destinations, two formats — humans read the console; the aggregator parses the JSON file.
- Different levels per destination — console at
INFOkeeps the terminal calm; file atDEBUGkeeps full detail for post-mortem. propagate=False— without it, records would also bubble up to the root logger; if root has its own handlers, you'd get duplicate lines. Belt and braces.- Daily rotation with 30-day retention — no manual cleanup, no disk-full incidents.
- Third-party quietening —
urllib3andbotocoredon't drown out your own signal.
You'd extend this with: a correlation-ID filter (Section 13), a redactor filter for secrets (Section 12), and a separate error.log handler at ERROR for fast triage. All optional; this is the production baseline.
What You Learned
- Loggers route, handlers destination, formatters shape, levels filter. Records flow through both logger and handler level checks.
- Module-level
logger = logging.getLogger(__name__)at the top of every module. Configure handlers once, in the entry point. - Levels are a discipline —
INFOfor normal,WARNINGfor weird,ERRORfor failed,CRITICALfor down. logger.exception(msg)insideexcept— captures the traceback. Use it always.logging.config.dictConfigis the production setup. JSON/YAML-loadable.- Always rotate files —
RotatingFileHandlerorTimedRotatingFileHandler. No exceptions. - Lazy
%sformatting in hot paths; checkisEnabledForfor expensive payloads. - Quieten chatty deps —
logging.getLogger("urllib3").setLevel(logging.WARNING). - JSON logs for aggregators —
python-json-loggerorstructlog. breakpoint()drops into PDB;n,s,c,l,p,w,q.- Don't log secrets. Build a
redact()helper; assume logs are public.
Next: Testing with pytest — once your logs tell you what broke, tests tell you what was meant to work.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.