Pythonのloggingモジュール実践ガイド:printからの卒業

Pythonのloggingモジュールの基本から実践まで解説。ログレベル、フォーマット、ハンドラー、ロガー階層とpropagation、RotatingFileHandlerの実ローテーション検証、マルチプロセスでのログ破損とQueueHandler/QueueListenerによる解決、structlogによる構造化ログまで網羅します。

はじめに

Pythonでのデバッグや本番監視に print を使い続けていませんか?print はシンプルですが、ログレベルの制御、ファイルへの出力、タイムスタンプの付与、本番での無効化が難しいです。

Pythonの標準ライブラリ logging を使うと、これらすべてを宣言的に制御できます。本記事では基礎から実践パターンまでを解説します。

ログレベルの種類

logging には5段階のログレベルがあります。

レベル数値用途
DEBUG10詳細なデバッグ情報(開発時のみ)
INFO20正常系の処理記録
WARNING30予期しない状況だが処理は継続できる
ERROR40処理が失敗した
CRITICAL50システムが続行不可能な致命的エラー

デフォルトのルートロガーは WARNING 以上のみ出力します。

ロガーの階層構造と伝播(propagation)

logging.getLogger(name) で作られるロガーは、name のドット区切りによって親子関係を持つ「木構造」を形成します。例えば myapp.db.pool というロガーは myapp.db の子であり、myapp.dbmyapp の子、myapp はルートロガー(logging.getLogger() を引数なしで呼んだときに返るもの)の子です。

import logging

app_logger = logging.getLogger("myapp")
db_logger = logging.getLogger("myapp.db")
pool_logger = logging.getLogger("myapp.db.pool")

print(app_logger.parent.name)   # root
print(db_logger.parent.name)    # myapp
print(pool_logger.parent.name)  # myapp.db

各ロガーには propagate という属性があり、デフォルトは True です。True の場合、そのロガー自身のハンドラーで処理された後も、ログレコードは親ロガーへ伝播し、親(さらにその親…ルートまで)に登録されたハンドラーでも処理されます。myapp.db.pool でログを出しても、myapp.db.pool 自体にハンドラーがなければ myapp や root のハンドラーが代わりに出力を担当することがあるのはこのためです。

「ロガーのレベル」と「ハンドラーのレベル」を混同しない

logging を学び始めたときに最もつまずきやすいのが、ロガーのレベルとハンドラーのレベルは別物であり、ログが実際に出力されるにはその両方を通過する必要があるという点です。

  1. まず、ロガー自身の実効レベル(getEffectiveLevel()NOTSET の場合は親を遡って探索)でフィルタされる
  2. 次に、そのレコードを受け取った各ハンドラーの level でさらにフィルタされる

「ロガーを DEBUG に設定したのに DEBUG ログが出ない」という質問の多くは、ハンドラー側のレベルが WARNINGINFO のままになっているケースです。実際に確認してみます。

import logging

demo_logger = logging.getLogger("demo.gate")
demo_logger.setLevel(logging.DEBUG)   # ロガー側のゲート:DEBUG以上を許可
demo_logger.propagate = False

handler = logging.StreamHandler()
handler.setLevel(logging.WARNING)     # ハンドラー側のゲート:WARNING以上のみ許可
handler.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s"))
demo_logger.addHandler(handler)

demo_logger.debug("デバッグメッセージ(ハンドラーで落とされる)")
demo_logger.info("情報メッセージ(ハンドラーで落とされる)")
demo_logger.warning("警告メッセージ(両方のゲートを通過)")
demo_logger.error("エラーメッセージ(両方のゲートを通過)")
demo_logger.critical("致命的メッセージ(両方のゲートを通過)")

実行結果(実際の出力):

[WARNING] demo.gate: 警告メッセージ(両方のゲートを通過)
[ERROR] demo.gate: エラーメッセージ(両方のゲートを通過)
[CRITICAL] demo.gate: 致命的メッセージ(両方のゲートを通過)

debug()info() はロガー側のゲート(DEBUG)は通過しているにもかかわらず、ハンドラー側のゲート(WARNING)で静かに破棄され、コンソールには一切現れません。「ロガーのレベルを下げたのにログが出ない」場合は、必ずハンドラー側のレベルも確認してください。

基本的な使い方

basicConfigによる設定

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s %(levelname)s %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

logging.debug("デバッグ情報")
logging.info("正常処理")
logging.warning("警告")
logging.error("エラー発生")
logging.critical("致命的エラー")

出力例:

2026-03-12 10:00:00 DEBUG デバッグ情報
2026-03-12 10:00:00 INFO 正常処理
2026-03-12 10:00:00 WARNING 警告
2026-03-12 10:00:00 ERROR エラー発生
2026-03-12 10:00:00 CRITICAL 致命的エラー

モジュールごとのロガー

本番コードでは logging.getLogger(__name__) でモジュール専用のロガーを作ります。どのモジュールからのログかが一目でわかります。

import logging

logger = logging.getLogger(__name__)

def process_data(data):
    logger.info("処理開始: %d件", len(data))
    try:
        result = [x * 2 for x in data]
        logger.debug("処理結果: %s", result)
        return result
    except Exception as e:
        logger.error("処理失敗: %s", e, exc_info=True)
        raise

exc_info=True を指定するとスタックトレースも記録されます。

フォーマットのカスタマイズ

%(...)s スタイルのフォーマット文字列でログの見た目を自由に変えられます。

import logging

formatter = logging.Formatter(
    fmt="%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d - %(message)s",
    datefmt="%Y-%m-%dT%H:%M:%S",
)

handler = logging.StreamHandler()
handler.setFormatter(formatter)

logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
logger.addHandler(handler)

logger.info("サービス起動")

出力例:

2026-03-12T10:00:00 [INFO    ] myapp:10 - サービス起動

主要なフォーマット変数:

変数内容
%(asctime)sタイムスタンプ
%(levelname)sログレベル名
%(name)sロガー名
%(filename)sファイル名
%(lineno)d行番号
%(funcName)s関数名
%(message)sログメッセージ
%(process)dプロセスID
%(thread)dスレッドID

ハンドラーの種類

ハンドラーはログの出力先を決めます。複数のハンドラーを同時に使えます。

StreamHandler(標準出力/標準エラー)

import logging
import sys

console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)

FileHandler(ファイル出力)

file_handler = logging.FileHandler("app.log", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)

RotatingFileHandler(ログローテーション)

本番環境ではログが肥大化しないよう、ファイルサイズで自動ローテーションします。

from logging.handlers import RotatingFileHandler

rotating_handler = RotatingFileHandler(
    "app.log",
    maxBytes=10 * 1024 * 1024,  # 10MB
    backupCount=5,               # 最大5世代保持
    encoding="utf-8",
)

app.log が 10MB に達すると app.log.1 にリネームされ、新しい app.log が作られます。

実際にローテーションが起きることを、maxBytes を極端に小さくして確認してみます。

from logging.handlers import RotatingFileHandler
import logging

logger = logging.getLogger("demo.size_rotate")
logger.setLevel(logging.DEBUG)
logger.propagate = False

handler = RotatingFileHandler(
    "size.log",
    maxBytes=200,      # 意図的に小さくしてすぐローテーションさせる
    backupCount=3,
    encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)

for i in range(40):
    logger.info("サイズローテーションのテスト行 %03d - バイト数を稼ぐためのパディング", i)

実行後にディレクトリを確認すると、backupCount=3 の通り最大3世代のバックアップが実際に作られています。

$ ls -la
size.log       254 bytes
size.log.1     254 bytes
size.log.2     254 bytes
size.log.3     254 bytes

size.logmaxBytes を超えるたびに size.logsize.log.1size.log.2 → … と世代がスライドし、backupCount を超えた最も古い世代は削除されることが確認できます。

TimedRotatingFileHandler(時刻ローテーション)

日次でローテーションする場合:

from logging.handlers import TimedRotatingFileHandler

timed_handler = TimedRotatingFileHandler(
    "app.log",
    when="midnight",   # 毎日深夜にローテーション
    interval=1,
    backupCount=30,    # 30日分保持
    encoding="utf-8",
)

when="midnight" は日次ローテーションなので実行確認に1日待つ必要がありますが、when="S", interval=1(1秒間隔)にすれば同じロジックを数秒で検証できます。

from logging.handlers import TimedRotatingFileHandler
import logging
import time

logger = logging.getLogger("demo.timed_rotate")
logger.setLevel(logging.DEBUG)
logger.propagate = False

handler = TimedRotatingFileHandler(
    "timed.log",
    when="S",
    interval=1,        # 意図的に1秒間隔にしてすぐローテーションさせる
    backupCount=5,
    encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)

for i in range(4):
    logger.info("時刻ローテーションのテスト行、バッチ %d", i)
    time.sleep(1.1)  # 1秒境界をまたぐ

実行結果:

$ ls -la
timed.log                          86 bytes
timed.log.2026-07-19_09-56-19      86 bytes
timed.log.2026-07-19_09-56-20      86 bytes
timed.log.2026-07-19_09-56-22      86 bytes

RotatingFileHandler はファイルの連番サフィックス.1, .2, …)でローテーションするのに対し、TimedRotatingFileHandlerタイムスタンプサフィックス.2026-07-19_09-56-19 のような形式)でローテーションする点が実務上の大きな違いです。連番方式は「常に直近N世代」を保持し、タイムスタンプ方式は「いつのログか」がファイル名から一目で分かります。

実践:アプリケーション全体の設定

複数モジュールにまたがるアプリでは、専用の設定関数を用意します。

import logging
import sys
from logging.handlers import RotatingFileHandler


def setup_logging(log_level: str = "INFO", log_file: str = "app.log") -> None:
    """アプリケーション全体のログ設定を初期化する"""
    level = getattr(logging, log_level.upper(), logging.INFO)

    formatter = logging.Formatter(
        fmt="%(asctime)s [%(levelname)-8s] %(name)s - %(message)s",
        datefmt="%Y-%m-%dT%H:%M:%S",
    )

    # コンソール: INFO以上
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setLevel(logging.INFO)
    console_handler.setFormatter(formatter)

    # ファイル: DEBUG以上(ローテーション付き)
    file_handler = RotatingFileHandler(
        log_file,
        maxBytes=10 * 1024 * 1024,
        backupCount=5,
        encoding="utf-8",
    )
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(formatter)

    # ルートロガーに設定
    root_logger = logging.getLogger()
    root_logger.setLevel(level)
    root_logger.addHandler(console_handler)
    root_logger.addHandler(file_handler)


if __name__ == "__main__":
    setup_logging(log_level="DEBUG")

    logger = logging.getLogger(__name__)
    logger.info("アプリケーション起動")
    logger.debug("デバッグ情報(ファイルのみ)")

例外のログ記録

例外処理で logger.exception() を使うと、メッセージとスタックトレースを同時に記録します。

import logging

logger = logging.getLogger(__name__)


def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        logger.exception("ゼロ除算エラー: a=%s, b=%s", a, b)
        return None


divide(10, 0)

出力例:

2026-03-12T10:00:00 [ERROR   ] __main__ - ゼロ除算エラー: a=10, b=0
Traceback (most recent call last):
  File "example.py", line 7, in divide
    return a / b
ZeroDivisionError: division by zero

辞書設定(dictConfig)

大規模アプリでは logging.config.dictConfig を使い、設定を辞書(またはYAML/JSON)で管理します。

import logging
import logging.config

LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "standard": {
            "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        },
        "detailed": {
            "format": "%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d %(funcName)s() - %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "standard",
            "stream": "ext://sys.stdout",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "DEBUG",
            "formatter": "detailed",
            "filename": "app.log",
            "maxBytes": 10485760,
            "backupCount": 5,
            "encoding": "utf-8",
        },
    },
    "loggers": {
        "myapp": {
            "handlers": ["console", "file"],
            "level": "DEBUG",
            "propagate": False,
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "WARNING",
    },
}

logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("myapp")
logger.info("dictConfigで設定したロガー")

構造化ログ(structlog)

JSONログはログ収集基盤(Datadog, CloudWatch, ELK)との連携が容易です。外部ライブラリ structlog を使うと構造化ログを簡単に実装できます。

# pip install structlog
import structlog

structlog.configure(
    processors=[
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.stdlib.add_log_level,
        structlog.processors.JSONRenderer(),
    ],
)

log = structlog.get_logger()
log.info("ユーザーログイン", user_id=42, ip="192.168.1.1")

出力例(JSON):

{
  "timestamp": "2026-03-12T10:00:00Z",
  "level": "info",
  "event": "ユーザーログイン",
  "user_id": 42,
  "ip": "192.168.1.1"
}

structlog なしでJSONログを出す場合、logging.Formatter を継承したカスタムフォーマッターを書く方法もあります。

import json
import logging


class JsonFormatter(logging.Formatter):
    def format(self, record):
        log_data = {
            "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            log_data["exception"] = self.formatException(record.exc_info)
        return json.dumps(log_data, ensure_ascii=False)


handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("myapp")
logger.addHandler(handler)
logger.info("JSONフォーマットのログ")

実践でハマりやすい落とし穴(エッジケース)

落とし穴1: basicConfig() が「何もしていない」典型的な事故

logging.basicConfig() には、意外と知られていない重要な仕様があります。ルートロガーに既に1つでもハンドラーが設定されていると、basicConfig() は(force=True を指定しない限り)何もせず黙って終了します。 これは仕様どおりの動作ですが、実務では次のようなシナリオで静かにハマります。

  • 使っているライブラリ(クラウドSDK、Jupyter、一部のWebフレームワークなど)が import 時にルートロガーへハンドラーを追加している
  • 別のモジュールが自分より先に logging.basicConfig()logging.warning(...) を呼んでいる(logging.warning() などのモジュールレベル関数は、内部で暗黙的に一度だけ basicConfig() を呼び出します)

この状態で自分のコードが logging.basicConfig(level=logging.DEBUG, format=...) を呼んでも、レベルもフォーマットも一切反映されません。実際に確認してみます。

import logging


def third_party_library_import_side_effect():
    """サードパーティ製ライブラリが import 時にルートロガーへ
    ハンドラーを追加してしまうケースを再現する。"""
    root = logging.getLogger()
    h = logging.StreamHandler()
    h.setFormatter(logging.Formatter("[サードパーティのハンドラー] %(levelname)s %(message)s"))
    root.addHandler(h)


# ステップ1: サードパーティのimportがルートロガーにハンドラーを追加
third_party_library_import_side_effect()
print("handlers:", logging.getLogger().handlers)

# ステップ2: 自アプリがbasicConfig()でDEBUGレベルを設定したつもり
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s %(levelname)s %(message)s",
    force=False,  # デフォルト値
)
print("level:", logging.getLevelName(logging.getLogger().level))

logging.debug("DEBUGとして出力されるはず")
logging.info("INFOとして出力されるはず")

実行結果(実際の出力。debug()/info() の行は何も出力されない):

handlers: [<StreamHandler <stderr> (NOTSET)>]
level: WARNING

ルートロガーのレベルは WARNING のまま変わらず、debug()info() の呼び出しは静かに握りつぶされています。basicConfig() の呼び出し自体はエラーにならないため、原因に気づきにくいのが厄介な点です。

対処法force=True を指定すると、既存のハンドラーをすべて閉じて除去したうえで新しい設定を適用します。

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s %(levelname)s %(message)s",
    force=True,
)
logging.debug("DEBUGとして出力されるはず")
logging.info("INFOとして出力されるはず")
2026-07-19 09:56:17,214 DEBUG DEBUGとして出力されるはず
2026-07-19 09:56:17,214 INFO INFOとして出力されるはず

force=True によって、期待通りのフォーマットとレベルでログが出力されるようになりました。

落とし穴2: マルチプロセス/マルチスレッドでのログ破損と QueueHandler/QueueListener による解決

複数のプロセス(あるいはスレッド)が同じログファイルに、それぞれ独自の FileHandler で書き込もうとするのは典型的なアンチパターンです。Python公式の Logging Cookbook でも次のように明記されています。

logging to a single file from multiple processes is not supported, because there is no standard way to serialize access to a single file across multiple processes in Python. (複数プロセスから単一ファイルへログを出力することはサポートされていません。Pythonには複数プロセス間でファイルアクセスを直列化する標準的な方法がないためです。)

各プロセスの FileHandler が持つロック(threading.Lock)は、あくまでそのプロセス内の複数スレッド間でしか排他制御ができません。別プロセスの書き込みとは何も同期されないため、複数プロセスが同時に書き込むと、書き込みのタイミング次第でログの行が途中で混ざり合う(文字化けする)ことがあります。

以下のデモでは、1レコードをあえて「ヘッダー部分」と「本体部分」の2回の write() 呼び出しに分けて書き込みます(これは、メッセージがストリームの内部バッファより大きい場合や、フォーマッターがヘッダーと本文を分けて書き込む場合に実際に起こりうる状況です)。これにより、レースコンディションが再現しやすくなります。

import multiprocessing

LOG_PATH = "naive_multiproc.log"
N_WORKERS = 6
N_LINES_PER_WORKER = 60


def worker(worker_id: int):
    tag = chr(ord("A") + worker_id)
    # 各プロセスが同じファイルを個別にopenする
    # (=各プロセスが個別にFileHandler(LOG_PATH)を作るのと同じ状況)
    f = open(LOG_PATH, "a", encoding="utf-8")
    for i in range(N_LINES_PER_WORKER):
        f.write(f"[{tag}#{i:03d}] ")   # write呼び出し1
        f.flush()
        f.write(tag * 40 + "\n")       # write呼び出し2(ここで競合しうる)
        f.flush()
    f.close()


if __name__ == "__main__":
    procs = [multiprocessing.Process(target=worker, args=(i,)) for i in range(N_WORKERS)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

実行結果(実際の出力。360行を期待して書き込み、実際に文字化けした行数を検出):

期待される行数           : 360
実際にファイルにある行数 : 360
正常な行数               : 246
破損/文字化けした行数    : 114

--- 破損した行のサンプル(先頭6件、複数ワーカーのタグが混ざっている)---
'[B#003] [C#000] BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n'
'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n'
'[B#004] [C#001] BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n'
'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n'
'[B#005] [C#002] BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB\n'
'CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC\n'

360行を期待して書き込んだのに、100行以上で別のプロセスの書き込みと混ざり合っていることが分かります([B#003] の直後に本来 B の本文が続くべきところに [C#000] が割り込んでいる、など)。なお、この破損の再現率は実行環境・タイミング依存で、メッセージサイズやOSのバッファリング挙動によっては0件になることもあります(実際、単純に1回の write() で書き切る短いメッセージだと、macOS上では今回の環境で破損が観測できないこともありました)。「たまたま開発環境では起きない」からこそ、本番の負荷やメッセージ長の違いで突然発覚しやすい、厄介なバグです。

正しい解決策は、各プロセス(各スレッド)にはファイルを直接触らせず、QueueHandler でログレコードをキューに送るだけにし、実際のファイル書き込みは1つの QueueListener に一任することです。ファイルを開くオブジェクトが常に1つしか存在しないため、原理的に競合が起こりえません。

QueueHandler/QueueListenerアーキテクチャ図:上段はナイーブな複数プロセス直接書き込みで文字化けが発生する様子、下段はQueueHandlerで各プロセスがキューに送信しQueueListenerが単一のFileHandlerで書き込む安全な構成
import logging
import logging.handlers
import multiprocessing

LOG_PATH = "queue_fixed.log"
N_WORKERS = 6
N_LINES_PER_WORKER = 60


def worker(worker_id: int, queue: multiprocessing.Queue):
    # 各プロセスはキューに送るだけで、ファイルには一切触れない
    qh = logging.handlers.QueueHandler(queue)
    logger = logging.getLogger(f"worker{worker_id}")
    logger.setLevel(logging.INFO)
    logger.addHandler(qh)
    logger.propagate = False

    tag = chr(ord("A") + worker_id)
    for i in range(N_LINES_PER_WORKER):
        logger.info("[%s#%03d] %s", tag, i, tag * 40)


if __name__ == "__main__":
    log_queue: multiprocessing.Queue = multiprocessing.Queue(-1)

    # ファイルを開くのはこれ1つだけ。QueueListenerが所有する。
    file_handler = logging.FileHandler(LOG_PATH, mode="a", encoding="utf-8")
    file_handler.setFormatter(logging.Formatter("%(message)s"))

    listener = logging.handlers.QueueListener(log_queue, file_handler)
    listener.start()

    procs = [
        multiprocessing.Process(target=worker, args=(i, log_queue))
        for i in range(N_WORKERS)
    ]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

    listener.stop()  # 残りのレコードをフラッシュしてリスナースレッドをjoin

実行結果(実際の出力):

期待される行数           : 360
実際にファイルにある行数 : 360
正常な行数               : 360
破損/文字化けした行数    : 0

同じワークロード・同じ並列度でも、破損した行が0件になりました。QueueHandler/QueueListener パターンは、マルチプロセスだけでなくマルチスレッド構成(threading + queue.Queue)でもそのまま使え、さらに「重いI/Oハンドラー(ネットワーク経由のログ収集基盤への送信など)をメインスレッドから切り離してブロッキングを防ぐ」という副次的なメリットもあります。

Python 3.11以降の logging モジュールの新機能

本記事の内容は概ねどのPython 3系でも動作しますが、近年のバージョンでは logging モジュールにも地味に便利な追加が入っています。

バージョン追加機能内容
3.11logging.getLevelNamesMapping()レベル名 → 数値のマッピングを取得する公式API。以前は非公開の logging._nameToLevel を参照するしかなかった
3.12logging.getHandlerByName() / logging.getHandlerNames()名前付きハンドラーを名前から取得・列挙できるAPI。dictConfig で設定したハンドラーを後から参照する際に便利
3.12dictConfig()QueueHandler/QueueListener 対応dictConfighandlersqueue / listener キーを指定するだけで、本記事のエッジケース2で解説したQueueHandler/QueueListener構成を宣言的に設定できるようになった(ただし listener.start() の呼び出しは引き続き手動)
3.13LoggerAdaptermerge_extra パラメータ個々のログ呼び出しで渡した extra と、LoggerAdapter 自身が持つ extra をマージするかどうかを制御できるようになった(デフォルトはマージしない)

特に3.12の dictConfig での QueueHandler/QueueListener 宣言的設定は、本記事で手書きした配線をそのまま設定ファイルに落とし込めるようになったという点で実務的なインパクトが大きい変更です。

printとloggingの比較

観点printlogging
ログレベル制御不可5段階で制御可
ファイル出力リダイレクトのみFileHandler で簡単
タイムスタンプ手動で追加フォーマッターで自動
スタックトレース手動で tracebackexc_info=True で自動
本番での無効化削除または条件分岐レベル設定で一括制御
ログローテーション不可RotatingFileHandler で対応
構造化ログ不可dictConfig + structlog で対応

関連記事

参考文献