DEV Community

Kai Thorne
Kai Thorne

Posted on

Stop Using print() for Debugging — How to Set Up Python Logging Properly

Every Python developer starts with print(). It's the first debugging tool we learn, and for small scripts, it works fine. But once your project grows beyond 500 lines — or worse, once it runs unattended in production — print statements become a liability.

I learned this the hard way. I had a cron job running daily that processed about 20,000 records. When it failed at 3 AM, my "debugging" was staring at a terminal that had already closed. No timestamps. No log levels. No way to know which record caused the crash.

So I rebuilt it with proper logging. Here's exactly what I learned.

Why print() Falls Short

Before we get into the how, let's be precise about why print() doesn't scale:

  • No log levels — You can't separate INFO from WARNING from ERROR at a glance.
  • No timestamps — Good luck figuring out which operation took 30 seconds versus 30 minutes.
  • No file output — When your app crashes, stdout disappears.
  • No formatting control — You're writing ad-hoc f-strings that look different in every module.

Logging solves all of these. And it's in the standard library — zero dependencies.

The Bare Minimum Setup

Here's the logging equivalent of "hello world":

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

logger = logging.getLogger(__name__)
logger.info("Application started")
logger.warning("Disk space below 10%%")
logger.error("Failed to connect to database")
Enter fullscreen mode Exit fullscreen mode

Output:

2026-06-12 09:15:22 [INFO] __main__: Application started
2026-06-12 09:15:22 [WARNING] __main__: Disk space below 10%
2026-06-12 09:15:22 [ERROR] __main__: Failed to connect to database
Enter fullscreen mode Exit fullscreen mode

Every message has a timestamp, a severity level, and a source. You can grep for [ERROR] in a 10,000-line log file and find the failures in seconds.

Choosing the Right Log Level

This is where most people get it wrong. Here's the rule of thumb I use:

Level When to use
DEBUG Anything you only care about while actively developing
INFO Normal operations: "Started processing file X", "User Y logged in"
WARNING Something unexpected but recoverable: "Disk at 85%, continuing"
ERROR Something failed but the app keeps running: "Failed to send email, retrying"
CRITICAL The app cannot continue: "Database unreachable, shutting down"

The mistake I made early on was using logger.info() for everything. When something went wrong, I was drowning in noise. Now I reserve INFO for state transitions and ERROR for actual failures.

Logging to a File (With Rotation)

Once your app runs unattended, you need logs to survive a crash. Here's a production-ready setup:

import logging
from logging.handlers import RotatingFileHandler

handler = RotatingFileHandler(
    "app.log",
    maxBytes=5_242_880,  # 5MB
    backupCount=5,
)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[handler],
)
Enter fullscreen mode Exit fullscreen mode

This creates app.log, and when it hits 5MB, renames it to app.log.1 and starts a fresh file. You'll never wake up to a server that ran out of disk space because of a log file.

For long-running servers, also add the console handler so you can tail logs during development:

import sys

console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.DEBUG)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[handler, console],
)
Enter fullscreen mode Exit fullscreen mode

Structured JSON Logging (For Production)

When you have multiple services or use log aggregation tools (ELK, Datadog, Grafana), plain text logs become hard to parse. JSON logs solve that:

import json
import logging

class JSONFormatter(logging.Formatter):
    def format(self, record):
        log_entry = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info and record.exc_info[0]:
            log_entry["exception"] = self.formatException(record.exc_info)
        if hasattr(record, "extra_data"):
            log_entry["extra_data"] = record.extra_data
        return json.dumps(log_entry)

handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())

logging.basicConfig(level=logging.INFO, handlers=[handler])
Enter fullscreen mode Exit fullscreen mode

Now your logs look like:

{"timestamp": "2026-06-12 09:15:22", "level": "ERROR", "logger": "payments", "message": "Stripe charge failed", "extra_data": {"customer_id": "cus_123", "amount": 2999}}
Enter fullscreen mode Exit fullscreen mode

You can grep, jq, or feed this directly into log analytics.

Structuring Loggers in a Multi-Module Project

In a real project, you don't use a single logger. Each module gets its own:

# myapp/database.py
import logging
logger = logging.getLogger(__name__)  # "myapp.database"

def connect():
    logger.info("Connecting to database...")
    # ...

# myapp/api.py
logger = logging.getLogger(__name__)  # "myapp.api"

def handle_request():
    logger.info("Handling request...")
    # ...
Enter fullscreen mode Exit fullscreen mode

The beauty of Python's logging hierarchy is that setting level=logging.INFO on the "myapp" logger automatically applies to all children. But you can override individual modules:

logging.getLogger("myapp.database").setLevel(logging.DEBUG)
logging.getLogger("myapp.api").setLevel(logging.WARNING)
Enter fullscreen mode Exit fullscreen mode

This means you can crank up database logging while keeping the API layer quiet.

Capturing Exception Tracebacks

This is the single biggest upgrade from print(). Instead of:

try:
    process_order(order_id)
except Exception as e:
    print(f"Failed: {e}")  # You lose the traceback!
Enter fullscreen mode Exit fullscreen mode

Use:

try:
    process_order(order_id)
except Exception:
    logger.exception("Failed to process order %s", order_id)
Enter fullscreen mode Exit fullscreen mode

logger.exception() automatically includes the full traceback. You'll know exactly which line failed and why.

The One Pattern I Wish I'd Known Sooner

The biggest anti-pattern I see (and used myself) is f-string interpolation in log messages:

logger.info(f"Processing {user_id} — bad habit")
Enter fullscreen mode Exit fullscreen mode

Even if the log level is WARNING and the message is never emitted, Python still evaluates the f-string. For expensive operations like str(large_dataset), this wastes CPU.

The correct pattern uses lazy formatting — Python only evaluates it if the log level is active:

logger.info("Processing %s — correct", user_id)
logger.info("Processing %s with %d records", user_id, len(data))
Enter fullscreen mode Exit fullscreen mode

For logging.debug() calls inside hot loops, this can save measurable CPU time.

Putting It All Together

Here's the config template I copy into every new project:

import logging
import sys
from logging.handlers import RotatingFileHandler

def setup_logging(
    name: str = "app",
    level: int = logging.INFO,
    log_file: str | None = "app.log",
    json_format: bool = False,
):
    logger = logging.getLogger(name)
    logger.setLevel(level)

    formatter = (
        JSONFormatter()
        if json_format
        else logging.Formatter(
            "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
            datefmt="%Y-%m-%d %H:%M:%S",
        )
    )

    handlers = []
    console = logging.StreamHandler(sys.stdout)
    console.setFormatter(formatter)
    handlers.append(console)

    if log_file:
        file_handler = RotatingFileHandler(
            log_file, maxBytes=5_242_880, backupCount=5
        )
        file_handler.setFormatter(formatter)
        handlers.append(file_handler)

    for h in handlers:
        logger.addHandler(h)

    return logger

# Usage
log = setup_logging("myapp")
log.info("Application ready")
Enter fullscreen mode Exit fullscreen mode

The switch from print() to logging was one of those small changes that had an outsized impact on my development velocity. Bugs that used to take 45 minutes to track down now take 5. Cron jobs that ran silently for months now tell me exactly what happened.

Your future self — the one debugging at 2 AM while your site is down — will thank you.

Top comments (0)