Posted on 31 Dec 2025

Logging is a core component of any production-ready Python application. It provides visibility into runtime behavior and allows issues to be diagnosed in environments where debugging is not possible.
In a Flask application, logging spans request handling, business logic, and integrations with external systems. A consistent logging strategy is essential to understand failures and execution flows over time.
This article shows how to configure logging for an entire Flask application, following the same three-tier architecture described in Building a Flask Application Using Three-Tier Architecture with Python and SQLAlchemy.
The focus is on a centralized logging setup and on using logging where it actually adds value, rather than relying on scattered print() statements or ad-hoc debugging.
You should read this article if:
print()Using print() for debugging is common in small scripts, but it does not scale beyond local experimentation. As an application grows, print() quickly becomes a limitation rather than a tool.
print() provides no log levels, no filtering, no structure, and no control over output destinations. It cannot handle rotation, retention, or integration with external logging systems, and it offers no contextual information beyond the raw message.
A proper logging system, instead, allows you to control verbosity, categorize messages by severity, enrich logs with context (timestamps, class and method names), and route output to different destinations such as stdout, files, or centralized logging platforms.
In short, print() shows what is happening now, while logging explains what happened over time and why.
loggingPython’s standard logging module is powerful and flexible, but it often requires verbose configuration and careful handler management. In practice, this makes it easy to introduce subtle misconfigurations, especially in small and medium-sized applications.
The standard logging module offers great flexibility but lacks sensible defaults. Common requirements such as logging to multiple outputs (stdout and/or files), log rotation, retention, and integration with external systems usually require manual handler wiring and duplicated boilerplate.

Loguru was designed to simplify these aspects while remaining fully compatible with the standard logging ecosystem.
loggingStrengths
Limitations
Strengths
bindTrade-offs
For Flask applications running in modern environments such as containers, Kubernetes, or cloud platforms, Loguru simplifies log management by emitting consistent logs to stdout or files. These can be collected by external systems without requiring application-level changes, keeping logging centralized and decoupled from business logic and infrastructure.
In a layered architecture, logging is a low-level infrastructural concern. However, it is not the lowest one.
As explained in Managing Application Configuration in Python with Pydantic Settings, the configuration component sits at the very bottom of the dependency graph. Logging depends on configuration values such as log level, output destinations, and rotation policies.
A typical configuration for logging might look like this:
log:
level: "INFO"
console: true
file: "logs/races.log"
rotation: "10 MB"
retention: "7 days"
compression: "zip"
These parameters define:
Choosing where to send logs is not just a technical decision, but an architectural one.
Containerized applications should generally log to stdout. This allows infrastructure components such as sidecar containers, log collectors, and platforms like IBM Cloud Logs to aggregate, process, and ship logs externally.
CLI applications or local tools benefit more from file logging, to avoid mixing operational logs with user-facing output.
For this reason, logging configuration must be flexible and environment-aware.
The logging implementation is centralized in a single module. In the Running Races application, this logic lives in the core/log.py file.
This module exposes a single public entry point, setup_logging, which is called once during application startup. All configuration parameters are loaded from config.yml, as described in the configuration article referenced earlier.
def setup_logging(
level: str = "INFO",
console: bool = True,
file: str | None = None,
rotation: str = "10 MB",
retention: str = "7 days",
compression: str = "zip",
) -> LoggerManager:
return LoggerManager(
level=level,
console=console,
file=file,
rotation=rotation,
retention=retention,
compression=compression,
)
At application startup, logging is initialized as follows:
setup_logging(
level=settings.log.level,
console=settings.log.console,
file=settings.log.file,
rotation=settings.log.rotation,
retention=settings.log.retention,
compression=settings.log.compression,
)
This startup call wires the configuration layer into the logging layer once, so the rest of the application can rely on a single logging policy.
The LoggerManager acts as an infrastructure component: it is initialized early, depends only on configuration, and is reused throughout the application.
The core of the setup happens inside the LoggerManager constructor.
In the real implementation this class lives inside the logging module together with the supporting imports, format constants, and helper classes. The excerpt below focuses only on the initialization flow.
class LoggerManager:
def __init__(
self,
level: str = "INFO",
console: bool = True,
file: str | None = None,
rotation: str = "10 MB",
retention: str = "7 days",
compression: str = "zip",
) -> None:
self.level = level.upper()
self.console = console
self.file = file
self.rotation = rotation
self.retention = retention
self.compression = compression
logger.remove()
self._configure_logger()
self._intercept_standard_logging()
As you can see, the initialization follows three explicit steps.
When Loguru is imported, it automatically registers a default handler that writes to stderr using a predefined format. While this is convenient for quick scripts, it is not suitable for a real application.
The default handler is removed to:
By removing the default logger, the application takes complete ownership of the logging configuration.
After removing the default handler, a new logger is configured based on application settings.
def _configure_logger(self) -> None:
"""Configure loguru logger based on settings."""
def format_record(record) -> str:
format_map: dict[bool, str] = {
True: APP_LOG_FORMAT,
False: INTERCEPTED_LOG_FORMAT,
}
return format_map["name" in record["extra"]]
if self.console:
logger.add(
sink=sys.stdout,
format=format_record,
level=self.level,
colorize=True,
)
if self.file:
log_path: Path = Path(self.file)
log_path.parent.mkdir(parents=True, exist_ok=True)
logger.add(
sink=self.file,
format=format_record,
level=self.level,
rotation=self.rotation,
retention=self.retention,
compression=self.compression,
enqueue=True,
)
This step defines:
Console logging is typically used in containerized environments, while file logging is useful for CLI tools or local execution. Loguru makes it easy to support both outputs simultaneously without complex handler coordination.
At this stage, the logging policy of the application is fully defined.
Flask, Werkzeug, and many third-party libraries rely on Python’s built-in logging module. Without interception, the application would end up with two separate logging systems, producing logs with inconsistent formats, levels, and outputs.
LoggerManager solves this by intercepting standard logging and redirecting it to Loguru. This ensures that all logs, including application, framework, and external libraries, flow through a single, centralized pipeline.
class InterceptHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame: FrameType | None = logging.currentframe()
depth = 2
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(
level, record.getMessage()
)
class LoggerManager:
def _intercept_standard_logging(self) -> None:
"""Intercept standard library logging and redirect to loguru."""
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
for logger_name in ["werkzeug", "flask.app"]:
log = logging.getLogger(name=logger_name)
log.handlers = [InterceptHandler()]
log.propagate = False
This approach routes logs from your application, Flask, and other libraries through the same pipeline without duplication or conflicts. Exception information and the correct source (file, function, line) are preserved, making the logging pipeline reliable and production-ready.
The key point is that framework logs stop being a separate stream: once intercepted, they follow the same formatting and destination rules as your application logs.

Logging should be used intentionally. Logging everything everywhere leads to noise, not insight.
Logging at these levels rarely adds value and often obscures meaningful signals.
Service classes are a natural place for logging because they implement business logic and orchestrate multiple components.
from app.core.log import LoggerManager
class RaceService:
def __init__(self) -> None:
self.logger = LoggerManager.get_logger(self.__class__.__name__)
def create_new_race(self, race: Race) -> Race:
"""Create a new race and return it as a DTO."""
try:
self.logger.info(f"Created new race '{race.name}' with ID {race_dao.id}")
except SQLAlchemyError as e:
self.logger.error(f"SQLAlchemy error creating race '{race.name}': {e}")
A service can bind a logger once and reuse it across methods, providing consistent context in every log entry.
Controllers are responsible for handling HTTP requests and mapping them to services.
from app.core.log import LoggerManager
class RaceController:
def __init__(self) -> None:
self.logger = LoggerManager.get_logger(self.__class__.__name__)
def delete_race(self, race_id: int) -> WebResponse:
try:
...
except RaceNotFoundError:
...
except Exception as e:
self.logger.error(f"Error deleting race {race_id}: {e}")
Here, logging is particularly useful for:
By keeping logging focused on controllers and services, the logs reflect what the application does, not just how it is implemented.
A well-designed logging system is not an afterthought: it is a core infrastructural component of any serious Python application.
In this article we covered:
print() stops being useful once a Flask application grows beyond local debuggingThe next article will show how to apply the same ideas in a FastAPI application, where the integration points change but the architectural goals stay the same.
If you enjoyed this article, don’t forget to give it a clap 👏, share it with your friends 🔗, and follow me for more tips and tutorials on software development 📘. Your support helps me create more content like this — thank you! 🙌