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.

Computer monitor showing code
The only time print() belongs in your code.

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.

Source code on a screen
Yes, this is what proper logging looks like. Boring. That's the point.

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 result

When 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.

Python code on screen
Your log output should look like this. Structured. Timestamped. Not a pile of print() garbage.

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.

Programming code on monitor
If your debug output looks like this, you're already losing.

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.