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.
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:
DATABASE_URL=postgresql+psycopg://release_tracker:release_tracker@localhost:5432/release_tracker
DEBUG=true
Call the helper once when the application module loads:
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:
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.