PythonMastery
intermediate 18 min read · lesson 7 of 12 in Python How-To

Logging & Debugging: Beyond print()

1 · The lesson

read

print 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 does logger = 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:

LevelUse for
DEBUGVerbose diagnostic detail — variable dumps, "entered function"
INFONormal operational events — "started", "processed 200 rows"
WARNINGWeird but recoverable — "retry succeeded after 2 attempts"
ERRORAn operation failed — request returned 500, job aborted
CRITICALThe 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:

python
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:

  • basicConfig is 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:

python
# 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.payments as 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:

python
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:

python
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:

FieldWhat it gives you
%(asctime)sTimestamp
%(levelname)sINFO, WARNING, ...
%(name)sLogger name (i.e. __name__)
%(message)sThe actual log message
%(filename)sSource file
%(lineno)dLine number
%(funcName)sFunction that emitted the log
%(process)dProcess ID
%(threadName)sThread 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:

python
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:

python
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:

python
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:

python
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:

python
# 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:

json
{"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:

python
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:

python
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:

python
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:

python
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):

python
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:

CommandWhat it does
nNext line (step over)
sStep into a call
cContinue until next breakpoint
lList source around current line
p exprPrint expr
pp exprPretty-print
wWhere am I — show the stack
qQuit

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 level
ERROR 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 threshold
logger.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.

python
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 Use python-json-logger's JsonFormatter. In the dict-config form, set "()": "pythonjsonlogger.jsonlogger.JsonFormatter" and pass the format string as "format".
Show full solution
python
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):

python
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):

json
{"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 INFO keeps the terminal calm; file at DEBUG keeps 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 — urllib3 and botocore don'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 — INFO for normal, WARNING for weird, ERROR for failed, CRITICAL for down.
  • logger.exception(msg) inside except — captures the traceback. Use it always.
  • logging.config.dictConfig is the production setup. JSON/YAML-loadable.
  • Always rotate files — RotatingFileHandler or TimedRotatingFileHandler. No exceptions.
  • Lazy %s formatting in hot paths; check isEnabledFor for expensive payloads.
  • Quieten chatty deps — logging.getLogger("urllib3").setLevel(logging.WARNING).
  • JSON logs for aggregators — python-json-logger or structlog.
  • 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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.