Intermediate Python 📂 Modules · 6 of 6 33 min read

Python Logging Mastery

Master Python's built-in logging module — the professional replacement for print(). Learn levels, handlers, formatters, the logger hierarchy, log rotation, structured JSON logs, and real-world Flask/production patterns. Every concept comes with runnable examples, diagrams, and battle-tested rules so you never lose another bug to a missing print statement.

Section 01

The Story That Explains Why Logging Exists

The Airplane Black Box vs. A Post-It Note
Imagine two pilots. Pilot A jots quick notes on Post-Its and throws them out the window mid-flight. Pilot B has a flight recorder — a black box silently capturing altitude, engine RPM, warnings, and errors with exact timestamps.

When something goes wrong at 30,000 feet, Pilot A has nothing. Pilot B replays the last 60 seconds and knows exactly which valve failed and when. That black box is identical in role to Python's logging module.

print() is the Post-It note. logging is the black box.

Python's logging module is the standard, thread-safe, level-aware, configurable, production-ready way to record what your program is doing. It ships with the standard library — no pip install required — and every serious Python codebase uses it.

🔊
The Core Insight

Logging is not "printing with extra steps." It's a separate output channel that carries a severity level, timestamp, source location, and context — and can be routed to files, the console, syslog, a network socket, or a JSON aggregator without changing a single line of your business code.


Section 02

Why print() Fails in Production

Every Python developer starts with print(). Every senior Python developer eventually rips them all out. Here's why.

❌ print() — The Naive Way
ProblemConsequence
No severity levelDebug noise mixes with real errors
Always goes to stdoutCannot redirect selectively
No timestampCannot correlate events
No file/line infoGrep the codebase to find source
Cannot silenceMust delete/comment before ship
Not thread-safeInterleaved garbage output
✅ logging — The Professional Way
FeatureBenefit
5 severity levelsFilter noise in one config line
Multiple handlersConsole + file + email at once
Auto timestampsPrecise event ordering
Module/line auto-capturedZero-cost source tracking
Toggle by env variableDEBUG in dev, WARNING in prod
Thread & process safeClean output under load
⚠️
The print() Trap

A codebase with 500 print() calls cannot be "turned down" for production. You either see everything (unreadable) or delete them all (lose observability). With logging, you change ONE line and the entire application quiets down.


Section 03

The Five Log Levels

Every log message carries a severity. Python defines five standard levels — each with a numeric value. When the logger's level is set, anything below that threshold is silently dropped.

🔮
DEBUG
level = 10
Deep diagnostic detail. Variable values, entry/exit of functions, SQL queries. Never enabled in production because it floods logs.
ℹ️
INFO
level = 20
Confirmation that things are working. "Server started on port 8000", "User 42 logged in", "Batch job completed".
⚠️
WARNING
level = 30
Something unexpected happened but the app still works. Deprecated API used, disk 80% full, retrying a request. Default level.
ERROR
level = 40
A specific operation failed. Payment couldn't process, DB write failed. The app keeps running but this request is dead.
🔥
CRITICAL
level = 50
The application itself is going down. Out of memory, database unreachable, catastrophic failure. Wake someone up.
📌
The Rule
threshold filter
Setting logger level to WARNING shows WARNING, ERROR, CRITICAL — but silently discards DEBUG and INFO. One line controls verbosity globally.

Quick Demo of All Five Levels

import logging

# The simplest possible setup
logging.basicConfig(level=logging.DEBUG)

logging.debug("Loop iteration i=5, value=42")
logging.info("Server started on port 8000")
logging.warning("Disk usage at 82%")
logging.error("Failed to send email to user@example.com")
logging.critical("Database connection pool exhausted!")
OUTPUT
DEBUG:root:Loop iteration i=5, value=42 INFO:root:Server started on port 8000 WARNING:root:Disk usage at 82% ERROR:root:Failed to send email to user@example.com CRITICAL:root:Database connection pool exhausted!
💡
Level Rule of Thumb

Development → DEBUG. Staging → INFO. Production → WARNING (or INFO if disk is cheap). Never ship with DEBUG on — you'll drown in noise and pay for disk.


Section 04

The Anatomy of a Logging System

Python's logging module is built from four cooperating parts. Once you see the wiring, everything else becomes obvious.

🔧 The Four Pillars of Python Logging
Logger
The thing you call. log.info(...) creates a LogRecord and passes it to attached handlers. Loggers form a tree by dotted name.
Handler
The destination. Console, file, rotating file, syslog, HTTP, email. One logger can have many handlers.
Formatter
The layout. Turns a LogRecord into a string. Timestamp, level, module, message — you decide the shape.
Filter
The gatekeeper. Optional. Decides per-record whether the handler should emit it. Useful for suppressing noisy modules.

Visual Flow — From log.info() to Disk

01
You call log.info("User %s logged in", user_id)
The logger checks its own level. If INFO is disabled here → the message dies instantly, arg formatting never runs. Zero cost.
02
A LogRecord is created
Timestamp, level, message, module, filename, line number, thread, process — all captured automatically.
03
Record walks up the logger tree
Named loggers ("myapp.db") propagate to their parent ("myapp") and eventually the root, unless propagate=False.
04
Each handler decides
Handler checks its own level and any filters. If the record passes, it is formatted.
05
Formatter turns record into string, handler writes it
Stream to stderr, append to a file, POST to Sentry, send to Elasticsearch — the handler chooses the sink.

Section 05

Getting a Real Logger — Never Use the Root

Beginners call logging.info(...) directly, which uses the root logger. Professionals never do this. Instead, every module creates its own named logger with logging.getLogger(__name__). This gives you free hierarchy, free filtering, and free source identification.

# myapp/db.py
import logging

log = logging.getLogger(__name__)   # -> 'myapp.db'

def query(sql):
    log.debug("Executing SQL: %s", sql)
    # ... run query ...
    log.info("Query complete in %d ms", 42)
🎁
Why __name__ Is Magic

__name__ resolves to the module's dotted path — e.g. myapp.db. This creates a hierarchical logger tree. You can then set the level of myapp.db to WARNING to silence chatty DB logs while leaving myapp.api at DEBUG. Zero code changes.

The Logger Hierarchy

Logger Name Parent Effective Level (if not set)
myapprootinherits from root
myapp.dbmyappinherits from myapp
myapp.db.poolmyapp.dbwalks up until a level is set
myapp.apimyappcan be set independently

Section 06

Formatters — Making Logs Readable

A raw INFO:root:User logged in line is not enough for production. You want timestamp, level, source, thread, and message. Formatters use placeholders from the LogRecord attributes.

PlaceholderMeaningExample
%(asctime)sHuman-readable timestamp2026-07-12 14:32:07,213
%(name)sLogger namemyapp.db
%(levelname)sLevel as textWARNING
%(levelno)sLevel as number30
%(module)sPython module namedb
%(funcName)sFunction that called logrun_query
%(filename)sSource filedb.py
%(lineno)dLine number147
%(thread)dThread ID139823..1
%(threadName)sThread nameMainThread
%(process)dProcess ID19832
%(message)sThe log text you wroteUser 42 logged in
import logging

FMT = "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s"
DATE = "%Y-%m-%d %H:%M:%S"

logging.basicConfig(level=logging.INFO, format=FMT, datefmt=DATE)

log = logging.getLogger("shop.checkout")
log.info("Order %s placed for $%.2f", "ORD-8821", 129.95)
log.warning("Coupon SUMMER25 expired but was accepted")
OUTPUT
2026-07-12 14:32:07 | INFO | shop.checkout:8 | Order ORD-8821 placed for $129.95 2026-07-12 14:32:07 | WARNING | shop.checkout:9 | Coupon SUMMER25 expired but was accepted
🎯
The %-8s Trick

Using %(levelname)-8s left-pads the level to 8 characters. This makes every log line align in columns so your eye can scan severity down the left edge instantly. Small trick, massive readability win.


Section 07

Handlers — Sending Logs Everywhere

A handler is a destination. The most common ones ship built-in with Python.

💻
StreamHandler
Writes to any stream — stdout, stderr, or a file-like object. Default choice for console output during development.
logging.StreamHandler()
💾
FileHandler
Appends to a single file forever. Simple but grows unbounded — use RotatingFileHandler in real apps.
FileHandler('app.log')
🔄
RotatingFileHandler
Auto-rotates when the file hits a size limit. Keeps N old backups. Essential for long-running servers.
maxBytes + backupCount
📆
TimedRotatingFileHandler
Rotates at time intervals — every midnight, every hour. Standard for daily log files feeding analytics.
when='midnight'
📧
SMTPHandler
Emails critical logs. Powerful but dangerous — a crash loop = 10,000 emails. Wrap with rate limiting.
CRITICAL only
🌐
SysLogHandler / HTTPHandler
Push logs to syslog daemons or a remote HTTP endpoint. Common in containerized deployments.
central aggregation

Full Setup — Console + Rotating File

import logging
from logging.handlers import RotatingFileHandler

# 1. Get a named logger
log = logging.getLogger("myapp")
log.setLevel(logging.DEBUG)   # capture everything at the logger

# 2. Build a formatter
fmt = logging.Formatter(
    "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)

# 3. Console handler -> only WARNING and up on screen
console = logging.StreamHandler()
console.setLevel(logging.WARNING)
console.setFormatter(fmt)

# 4. File handler -> everything DEBUG+ to disk, rotate at 5MB, keep 5 backups
file_h = RotatingFileHandler(
    "myapp.log", maxBytes=5_000_000, backupCount=5
)
file_h.setLevel(logging.DEBUG)
file_h.setFormatter(fmt)

# 5. Attach both handlers
log.addHandler(console)
log.addHandler(file_h)

# Demo
log.debug("This goes to FILE only (console is WARNING+)")
log.info("This goes to FILE only")
log.warning("This goes to BOTH file and console")
log.error("This goes to BOTH file and console")
OUTPUT (console shows only WARNING+)
2026-07-12 14:35:11 | WARNING | myapp | This goes to BOTH file and console 2026-07-12 14:35:11 | ERROR | myapp | This goes to BOTH file and console
🔒
Level Ordering

A record must pass two level checks: (1) the logger's level, then (2) the handler's level. Set the logger permissive (DEBUG) and let each handler restrict what it shows. This is the standard pattern.


Section 08

Logging Exceptions — Get the Full Traceback

A bare log.error("Something failed") loses the exception. Use log.exception() or exc_info=True to capture the full traceback.

import logging
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        # Automatically appends the traceback
        log.exception("Failed to divide %s by %s", a, b)
        return None

divide(10, 0)
OUTPUT
ERROR:__main__:Failed to divide 10 by 0 Traceback (most recent call last): File "example.py", line 8, in divide return a / b ~~^~~ ZeroDivisionError: division by zero
🔑
Three Ways to Attach Traceback

1. log.exception("msg") — implies ERROR level + traceback (use inside except:).
2. log.error("msg", exc_info=True) — same effect, any level.
3. log.error("msg", exc_info=e) — attach a specific exception object.


Section 09

dictConfig — The Production Pattern

For real apps you don't wire handlers with Python code — you describe the entire logging setup as a dictionary and pass it to logging.config.dictConfig(). This lives in one file and can be loaded from YAML/JSON at startup.

import logging.config

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,

    "formatters": {
        "standard": {
            "format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
            "datefmt": "%Y-%m-%d %H:%M:%S",
        },
        "detailed": {
            "format": "%(asctime)s | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s",
        },
    },

    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "standard",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "DEBUG",
            "formatter": "detailed",
            "filename": "app.log",
            "maxBytes": 5_000_000,
            "backupCount": 5,
        },
        "error_file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "ERROR",
            "formatter": "detailed",
            "filename": "errors.log",
            "maxBytes": 5_000_000,
            "backupCount": 10,
        },
    },

    "loggers": {
        "myapp": {
            "handlers": ["console", "file", "error_file"],
            "level": "DEBUG",
            "propagate": False,
        },
        "urllib3": {                # silence chatty 3rd-party libs
            "level": "WARNING",
        },
    },

    "root": {
        "handlers": ["console"],
        "level": "WARNING",
    },
}

logging.config.dictConfig(LOGGING)

log = logging.getLogger("myapp.orders")
log.info("Order #100 processed")
log.error("Payment gateway timeout for order #101")
📈
Why dictConfig Wins

One dictionary describes formatters, handlers, and loggers together. It can be loaded from a YAML file, differ per environment, and reviewed in code review as a single unit. Frameworks like Django and Flask both use this pattern natively.


Section 10

Real-World Example — Flask Web App Logging

import logging
import logging.config
import time
from flask import Flask, request, g

# --- Logging setup ---
logging.config.dictConfig({
    "version": 1,
    "formatters": {"default": {
        "format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
    }},
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "default",
        },
        "file": {
            "class": "logging.handlers.TimedRotatingFileHandler",
            "filename": "access.log",
            "when": "midnight",
            "backupCount": 14,
            "formatter": "default",
        },
    },
    "root": {"handlers": ["console", "file"], "level": "INFO"},
})

app = Flask(__name__)
log = logging.getLogger("shop.api")

@app.before_request
def _start_timer():
    g.start = time.perf_counter()

@app.after_request
def _log_request(response):
    elapsed_ms = (time.perf_counter() - g.start) * 1000
    log.info(
        "%s %s -> %d in %.1fms (ip=%s)",
        request.method, request.path, response.status_code,
        elapsed_ms, request.remote_addr,
    )
    return response

@app.route("/order/<int:order_id>")
def get_order(order_id):
    log.debug("Fetching order %s from DB", order_id)
    if order_id < 0:
        log.warning("Rejecting negative order id: %s", order_id)
        return {"error": "bad id"}, 400
    return {"id": order_id, "total": 42.00}

@app.errorhandler(Exception)
def _unhandled(err):
    log.exception("Unhandled error on %s", request.path)
    return {"error": "internal"}, 500

if __name__ == "__main__":
    app.run(port=8000)
OUTPUT (a sample of access.log)
2026-07-12 14:42:03 | INFO | shop.api | GET /order/42 -> 200 in 3.2ms (ip=127.0.0.1) 2026-07-12 14:42:07 | WARNING | shop.api | Rejecting negative order id: -1 2026-07-12 14:42:07 | INFO | shop.api | GET /order/-1 -> 400 in 1.1ms (ip=127.0.0.1)

Section 11

Structured (JSON) Logging

Modern log aggregators (Datadog, Splunk, ELK, Loki) prefer JSON — one object per line, keys as searchable fields. Ship it with the python-json-logger package.

# pip install python-json-logger
import logging
from pythonjsonlogger import jsonlogger

log = logging.getLogger("shop")
log.setLevel(logging.INFO)

handler = logging.StreamHandler()
handler.setFormatter(jsonlogger.JsonFormatter(
    "%(asctime)s %(levelname)s %(name)s %(message)s"
))
log.addHandler(handler)

# Attach extra structured fields via `extra=`
log.info("Order placed", extra={
    "order_id": "ORD-8821",
    "user_id": 42,
    "amount": 129.95,
    "currency": "USD",
})
OUTPUT (one JSON object per line)
{"asctime": "2026-07-12 14:45:22,013", "levelname": "INFO", "name": "shop", "message": "Order placed", "order_id": "ORD-8821", "user_id": 42, "amount": 129.95, "currency": "USD"}
📂
Why JSON Logs Win at Scale

Text logs force regex parsing. JSON logs are native fields — you can query user_id:42 or amount:>100 in your log tool instantly. Kubernetes, Docker, and Lambda all emit stdout to log aggregators automatically, so shipping JSON to stdout is the modern default.


Section 12

Performance — The Two Golden Rules

❌ Slow & Wasteful
Bad Pattern
log.debug("User " + str(user) + " age=" + str(age))
log.debug(f"Query result: {expensive_call()}")
String is built even when DEBUG is disabled.
✅ Lazy & Correct
Good Pattern
log.debug("User %s age=%d", user, age)
if log.isEnabledFor(logging.DEBUG): log.debug("Query: %s", expensive_call())
Formatting happens only if the record will be emitted.
⚠️
f-strings vs %-Formatting in Logs

f-strings are lovely everywhere except in logging calls. They force evaluation immediately, even when the log level would drop the record. Use %-style placeholders — the logger only interpolates if it will actually emit. This can matter enormously for hot loops with disabled DEBUG.


Section 13

Common Pitfalls & How to Avoid Them

PitfallSymptomFix
Calling logging.info(...) directly Uses root logger; hard to filter per module log = logging.getLogger(__name__)
Duplicate log lines Every message printed twice or more Handlers added multiple times, or propagate=True feeds parents. Add handlers once; set propagate=False.
Silent logs Nothing appears anywhere No handler on any ancestor. Root has none by default until you call basicConfig() or dictConfig().
basicConfig after another log call Config is silently ignored basicConfig only works if no handlers exist. Pass force=True in Python 3.8+ to override.
Third-party library floods logs urllib3, botocore, sqlalchemy noise logging.getLogger("urllib3").setLevel(logging.WARNING)
Logging inside __del__ or shutdown Errors about closed file handles Avoid logging during interpreter shutdown; use atexit if needed.
Missing traceback ERROR line but no stack Use log.exception(...) inside except:.

Section 14

logging vs print vs loguru — Quick Comparison

Featureprint()logging (stdlib)loguru (3rd party)
Severity levelsNo5 built-inFully custom
TimestampsManualAutomaticAutomatic
Multiple destinationsNoYes (handlers)Yes (sinks)
ConfigurationN/AVerbose (dictConfig)One-liner
Thread/process safeNoYesYes
Ships with PythonYesYespip install
Framework supportNoneUniversalGrowing
Best when…REPL playAny real projectSmall scripts + rapid dev
🏆
The Practitioner's Rule

For any library, framework, or team project — use the standard logging module. It's universally understood, integrates with every framework, and needs no extra dependency. Save loguru for personal scripts and CLI tools where you want beauty over convention.


Section 15

Golden Rules

🛡️ Python Logging — Non-Negotiable Rules
1
In every module write log = logging.getLogger(__name__) at the top. Never call logging.info at module scope. This gives you free hierarchy and per-module filtering forever.
2
Configure logging once, at application startup, using dictConfig. Libraries must never call basicConfig — that's the application's job.
3
Use %-style placeholders, not f-strings, inside log calls. log.debug("x=%s", x) defers formatting until the record is actually emitted. f-strings evaluate eagerly and waste CPU on disabled DEBUG.
4
Inside an except: block, always call log.exception(...) — never log.error(str(e)). Only exception() attaches the full traceback.
5
In long-running services use RotatingFileHandler or TimedRotatingFileHandler. A plain FileHandler will silently fill your disk and take down the box.
6
Silence chatty third-party libraries explicitly: logging.getLogger("urllib3").setLevel(logging.WARNING). One line saves you from megabytes of noise per hour.
7
Ship JSON logs to stdout in containerized deployments. The platform (Docker, K8s, Lambda) captures stdout and forwards it to your log aggregator with structured fields intact.
8
Never log passwords, tokens, credit cards, or PII. Add a filter that redacts or drops sensitive keys. A single leaked log file has ended more careers than any bug.
You have completed Modules. View all sections →