I have never once, in production, wished I had more print() statements to grep through. What I have wished for, repeatedly, at 2am, is a log line that included a request ID, a timestamp in a sane timezone, and a severity level I could filter on. The standard library’s logging module gives you all of that for free. Most people just don’t use it that way, they call logging.info("here") a few times, never touch the configuration, and end up with something barely better than print. This article is about the difference between using logging and using it well.

Why not just use print()

print() is fine for a quick script. It falls apart the moment your code runs somewhere you can’t watch a terminal:

  • No severity levels, you can’t turn down the noise without deleting code.
  • No structure, every line is just a string, so searching or filtering it later means regex archaeology.
  • No routing, you can’t send warnings to stderr and info to a file without hand-rolling it.
  • No context, you don’t get the module, line number, or timestamp unless you type it every time.
  • It bypasses whatever the rest of your application (or the libraries you depend on) uses for logging, so your output doesn’t compose with anyone else’s.

logging solves all five, and it’s already installed. There’s no dependency to add and no excuse not to use it.

The four core pieces

The module has more moving parts than print, but only four concepts you actually need to internalize:

  • Loggers: named objects you call .info(), .warning(), etc. on. You get one per module, typically.
  • Handlers: decide where a log record goes (console, file, network socket, email).
  • Formatters: decide what a log record looks like as text (or JSON).
  • Levels: decide whether a given record is important enough to show up at all.

A logger can have multiple handlers, each handler can have its own formatter and its own level. That’s the whole system. Once this clicks, the rest of the module is just configuration.

import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
handler.setFormatter(formatter)

logger.addHandler(handler)

logger.debug("this won't print, handler is set to INFO")
logger.info("this will print")
2026-08-25 11:40:02,113 INFO __main__: this will print

Levels: use them as a filter, not decoration

Python’s levels, in increasing severity, are DEBUG, INFO, WARNING, ERROR, CRITICAL. Each has a numeric value (10, 20, 30, 40, 50), and a logger or handler only emits records at or above its configured level.

The mistake I see constantly is treating every message as INFO because it’s the default. Levels are only useful if you’re disciplined about what goes where:

  • DEBUG: noisy, developer-only detail. Variable values, function entry/exit, “made it here” checkpoints. Off in production.
  • INFO: normal operation milestones. “Server started,” “job completed,” “user signed up.” What you’d want in a healthy-system log.
  • WARNING: something unexpected but not broken. A retry, a deprecated code path, a fallback kicking in.
  • ERROR: something failed. A request errored out, a task didn’t complete. The system is still up.
  • CRITICAL: the system itself is in danger. Can’t connect to the database at startup, out of disk space.

The payoff for getting this right: in production you set the root level to INFO or WARNING and the app stays quiet unless something’s actually wrong. When you need to dig into a specific bug, you flip one logger to DEBUG (see the per-module section below) instead of adding print statements and ripping them back out.

logging.basicConfig: the 90% solution

For scripts and small services, you rarely need to build loggers and handlers by hand. basicConfig configures the root logger in one call:

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("cache miss, falling back to database")
2026-08-25 11:42:10 INFO __main__: application started
2026-08-25 11:42:10 WARNING __main__: cache miss, falling back to database

Two things trip people up here. First, basicConfig only has an effect the first time it’s called (or if you pass force=True), if some import earlier in the process already configured logging, your call silently does nothing. Second, call it once, near your entry point, not inside every module. Modules should only ever call getLogger, never configure handlers or levels themselves, that’s the application’s job, not the library’s.

One logger per module, using __name__

This is the single most important convention in the whole module:

# in payments/charge.py
logger = logging.getLogger(__name__)

__name__ gives you payments.charge, and loggers are hierarchical by dot-separated name, payments.charge is a child of payments, which is a child of the root logger. This buys you two things:

  1. Log lines tell you exactly where they came from, no manual tagging needed.
  2. You can control verbosity per-subsystem. Want to see debug output only from your database layer while everything else stays quiet?
logging.getLogger("myapp.db").setLevel(logging.DEBUG)

Every other logger keeps its inherited level. This is the “flip one logger to DEBUG” trick from the section above, and it only works cleanly if every module used __name__ instead of a single shared logger or ad hoc names.

Don’t attach handlers in library code

If you’re writing a library (as opposed to an application), your modules should call getLogger(__name__) and log freely, but should never call basicConfig, add handlers, or set levels. That configuration belongs to whoever is running the final application. A library that configures logging on import can duplicate output, override the application’s formatting, or spam stdout in a program that expected silence unless something broke. The standard advice from the logging docs is for libraries to attach a NullHandler to their top-level logger so it’s silent until the application opts in:

# in mylib/__init__.py
import logging
logging.getLogger("mylib").addHandler(logging.NullHandler())

Real-world pattern: rotating file logs for a long-running service

print to a file grows forever until it fills the disk. RotatingFileHandler caps file size and keeps a bounded number of backups:

import logging
from logging.handlers import RotatingFileHandler

logger = logging.getLogger("myapp")
logger.setLevel(logging.INFO)

handler = RotatingFileHandler(
    "app.log",
    maxBytes=10_000_000,  # 10 MB
    backupCount=5,
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
logger.addHandler(handler)

logger.info("worker started")

Once app.log hits 10MB, it’s renamed to app.log.1 (bumping any existing .1 to .2, and so on up to backupCount), and a fresh app.log starts. You get bounded disk usage without a cron job or a separate log-rotation tool. For anything that runs on a daily cadence instead of a size threshold, TimedRotatingFileHandler does the same thing on a schedule (midnight, every hour, etc).

Real-world pattern: structured JSON logs for log aggregators

If your logs end up in something like CloudWatch, Datadog, or an ELK stack, plain text formatting means the aggregator has to parse your message with regex, fragile the moment someone tweaks the format string. Emitting JSON instead makes every field queryable natively. You don’t need a third-party dependency for this, a custom Formatter is enough:

import json
import logging

class JSONFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
        # anything passed via extra={...} shows up on the record
        for key, value in record.__dict__.items():
            if key not in logging.LogRecord("", 0, "", 0, "", (), None).__dict__:
                payload[key] = value
        return json.dumps(payload)

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

logger = logging.getLogger("myapp")
logger.setLevel(logging.INFO)
logger.addHandler(handler)

logger.info("order placed", extra={"order_id": 4821, "user_id": 991})
{"timestamp": "2026-08-25T11:50:03", "level": "INFO", "logger": "myapp", "message": "order placed", "order_id": 4821, "user_id": 991}

The extra dict is the key feature here, it attaches arbitrary structured fields to a log record without stuffing them into the message string. In production I’d reach for python-json-logger or structlog instead of hand-rolling this, but knowing what they do under the hood makes them far less magical.

Real-world pattern: logging exceptions with full context

logger.error(f"failed: {e}") throws away the traceback, the exact thing you need to actually debug the failure. Use logger.exception() inside an except block, or pass exc_info=True, to capture the full stack trace:

import logging

logger = logging.getLogger(__name__)

def process_order(order):
    try:
        charge_card(order)
    except PaymentError:
        logger.exception("payment failed for order %s", order.id)
        raise
2026-08-25 11:52:41 ERROR myapp.orders: payment failed for order 4821
Traceback (most recent call last):
  File "orders.py", line 12, in process_order
    charge_card(order)
  File "payments.py", line 30, in charge_card
    raise PaymentError("card declined")
myapp.payments.PaymentError: card declined

logger.exception() is just logger.error() with exc_info=True baked in, and it must be called from inside the except block (or with the exception still active) or there’s no traceback to attach. Also note the %s placeholder instead of an f-string: logging only formats the message if the record actually gets emitted, so lazy %-style interpolation avoids the cost of string-building for a DEBUG call that a WARNING-level logger is going to throw away anyway. It’s a small thing, but it adds up if you have debug logging sprinkled through a hot path.

Real-world pattern: configuring logging from a dict (great for 12-factor apps)

Hand-wiring handlers works for a single script, but a real application (web server, worker, CLI with subcommands) usually wants its whole logging setup defined declaratively, so it can live next to the rest of the app’s config and change per environment without a code change:

import logging.config

LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "default": {
            "format": "%(asctime)s %(levelname)s %(name)s: %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "default",
            "level": "INFO",
        },
        "file": {
            "class": "logging.handlers.RotatingFileHandler",
            "formatter": "default",
            "filename": "app.log",
            "maxBytes": 10_000_000,
            "backupCount": 5,
            "level": "DEBUG",
        },
    },
    "loggers": {
        "myapp": {
            "handlers": ["console", "file"],
            "level": "DEBUG",
            "propagate": False,
        },
    },
}

logging.config.dictConfig(LOGGING_CONFIG)

This is exactly the format Django’s LOGGING setting uses under the hood, and it’s a natural fit for anything where config already lives in a settings module or gets loaded from YAML/JSON/TOML. disable_existing_loggers: False matters if other modules already grabbed a logger via getLogger before this runs, setting it to the default True would silently mute them.

Avoiding the two most common mistakes

Configuring logging more than once. Calling basicConfig (or building duplicate handlers) in more than one place is the classic cause of every log line appearing twice, because each handler gets attached to the same logger independently, and both fire on every call. Configure once, at the entry point, and let every module log without instrating additional handlers.

Using logging.info(...) instead of a named logger. The top-level logging.info, logging.warning, etc. functions are convenience wrappers around the root logger. They work fine in a five-line script, but the moment you have more than one module, you lose the ability to tell where a message came from or to tune verbosity per subsystem. Get in the habit of logger = logging.getLogger(__name__) at the top of every file, even in small projects, it costs nothing and it’s the difference between “some log line fired somewhere” and “line 42 of payments/charge.py fired.”

Summary

If you only change one habit after reading this: stop reaching for print, and stop calling logging.info at the module level without a named logger. Everything else, rotation, JSON output, per-module verbosity, is easy to add later once that foundation is in place. The mental model that ties it all together is loggers decide if and where a message is worth recording, handlers and formatters decide how it gets written down.

  • Get a logger per module with logging.getLogger(__name__) instead of logging through the bare logging.info(...) functions, so you always know where a message came from.
  • Treat levels as a real filter, not decoration, so production can run quiet at INFO/WARNING and you can flip a single module to DEBUG when you need to dig in.
  • Configure logging once, at the application’s entry point, with basicConfig or dictConfig, never inside library code.
  • Reach for RotatingFileHandler or TimedRotatingFileHandler on any long-running service so logs don’t grow forever.
  • Use logger.exception() inside except blocks so you keep the traceback instead of just the error message.
  • Emit structured (JSON) logs once anything downstream needs to query or aggregate them, rather than parsing plain text after the fact.