Skip to content

Configuring Logging

logging.basicConfig(level=logging.INFO) is the smallest possible config. It works, but the format is bare (INFO:release_tracker.main:GET /projects ...) and the level is hardcoded. Production setups typically want timestamps, the source module, and a level that's controlled by an environment variable so dev runs get DEBUG and prod runs get INFO.

A small helper in config.py keeps that wiring in one place.

src/release_tracker/config.py
import logging
from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict

DEFAULT_DATABASE_URL = "postgresql+psycopg://release_tracker:release_tracker@localhost:5432/release_tracker"
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"


class Settings(BaseSettings):
    database_url: str = DEFAULT_DATABASE_URL
    debug: bool = False

    model_config = SettingsConfigDict(
        env_file=".env", env_file_encoding="utf-8"
    )


def configure_logging(*, debug: bool) -> None:
    level = logging.DEBUG if debug else logging.INFO
    logging.basicConfig(level=level, format=LOG_FORMAT)


@lru_cache
def get_settings() -> Settings:
    return Settings()  # type: ignore[call-arg]

configure_logging takes debug as an explicit keyword argument rather than reading settings internally. That keeps the helper testable in isolation.

Development vs Production

A common pattern is a DEBUG=true flag in a .env file, used to flip the log level to logging.DEBUG locally:

.env
DATABASE_URL=postgresql+psycopg://release_tracker:release_tracker@localhost:5432/release_tracker
DEBUG=true

Call the helper once when the application module loads:

src/release_tracker/main.py
import logging

from .config import configure_logging, get_settings

configure_logging(debug=get_settings().debug)
logger = logging.getLogger(__name__)

A typical log line looks like this:

Text
2026-04-30 12:00:01,234 INFO release_tracker.main GET /projects completed in 12.34ms with status code 200

Anywhere else in the codebase, logger = logging.getLogger(__name__) at the top of the module is enough. The basic config runs once; every logger inherits from it.

Safe to call more than once

basicConfig does nothing if the root logger already has handlers attached. That makes it safe to call from a module that could be imported multiple times during testing.

Out of scope

The standard library's logging machinery goes deeper than what's covered here: handlers, formatters, filters, propagation rules, and dictConfig for configuring everything via a Python dict. Two libraries that build on top of it are worth knowing about for production work:

  • structlog (~5k stars on GitHub) produces structured (often JSON) log records and integrates with the stdlib loggers.
  • loguru (~24k stars on GitHub) wraps the stdlib API in something more ergonomic, with a single import and sensible defaults.

If you find yourself fighting the stdlib, check them out.