I used print() for everything until about two years ago. Debugging, monitoring, error tracking, all of it. Just print() scattered everywhere like confetti. Then I had a script running on a remote server that crashed at 3am and I had nothing. No log file, no timestamp, no clue what happened. That's when I finally learned the logging module.
Here's the thing ( and I know you know this but you keep doing it anyway ): print() is not logging. It's a debugging tool you leave in production code because you're lazy. I was lazy too. It's fine. Let's fix it.
The Basic Setup
Here is the minimum viable logging setup that replaces every print() in your code:
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
logger.info("Server started on port 8000")
logger.warning("Cache miss for key: user_42")
logger.error("Database connection failed")
logger.debug("This won't show up unless level is DEBUG")That's it. You now have timestamps, log levels, and a module name so you know where the message came from. You already have more than print() ever gave you.
The levels matter. DEBUG for stuff you only care about during development. INFO for normal operation. WARNING when something is off but not broken. ERROR when something failed. CRITICAL when everything is on fire. Use them properly and you can filter log output by level without changing code.
Log to a File
print() goes to stdout and disappears. If you're running on a server, that output is gone. Logging to a file means you can go back and figure out what happened:
import logging
from logging.handlers import RotatingFileHandler
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
handler = RotatingFileHandler(
'/var/log/myapp.log',
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5
)
handler.setFormatter(logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
))
logger.addHandler(handler)
logger.info("App started")
logger.error("Something broke", exc_info=True)RotatingFileHandler keeps your log file from growing forever. It rotates when it hits maxBytes and keeps backupCount old files. Five 10MB files is usually enough. exc_info=True on error logs dumps the full traceback, which is what you actually need when something crashes at 3am.
Stop Using print() for Debugging
Here is the pattern I use instead of sprinkling print() everywhere:
import logging
logger = logging.getLogger(__name__)
def process_order(order_id):
logger.debug(f"Processing order {order_id}")
order = fetch_order(order_id)
if not order:
logger.warning(f"Order {order_id} not found")
return None
try:
result = charge_payment(order)
logger.info(f"Order {order_id} charged: {result.amount}")
except PaymentError as e:
logger.error(f"Payment failed for order {order_id}: {e}", exc_info=True)
raise
logger.debug(f"Order {order_id} done")
return resultWhen you're debugging, set the level to DEBUG and you see everything. In production, set it to INFO or WARNING and the debug noise disappears. No more commenting out print() statements. No more forgetting to remove them before committing.
One Logger Per Module
This is the part most people get wrong. They create one global logger and use it everywhere. Don't. Each module should have its own logger:
# utils.py
import logging
logger = logging.getLogger(__name__) # logger name: "utils"
# api.py
import logging
logger = logging.getLogger(__name__) # logger name: "api"
# main.py
import logging
logging.basicConfig(level=logging.DEBUG)
# Now you see which module logged what:
# 2026-09-12 10:23:45 - api - INFO - Received request
# 2026-09-12 10:23:45 - utils - DEBUG - Cache hit__name__ as the logger name means each file gets its own logger with the module name. When something breaks, you know exactly which file to look at. It costs you one line per file. Do it.
DictConfig for Anything Serious
For a real application, basicConfig is not enough. Use dictConfig and put it in a separate config:
import logging.config
LOGGING_CONFIG = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'standard',
'level': 'INFO',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'formatter': 'standard',
'level': 'DEBUG',
'filename': '/var/log/myapp.log',
'maxBytes': 10485760,
'backupCount': 5,
},
},
'root': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
},
}
logging.config.dictConfig(LOGGING_CONFIG)Now you have console output for quick checks and a file with everything including DEBUG. You can adjust levels per handler without touching code. This is the setup I use on every project now.
Conclusion
The logging module is not complicated. It just feels like overkill when you're used to print(). It's not. It takes five minutes to set up and saves you hours when something breaks in production. I wish I'd switched years earlier.
Stop using print(). Start logging. That's it.