Quick Answer

print writes text to stdout with no level, no timestamp and no way to switch off. The logging module gives every message a level, a source and a destination you configure once. Call logging.basicConfig(level=logging.INFO, format=...) at startup, then use logging.getLogger(__name__) in each module. The classic gotcha is that the root logger defaults to WARNING, so info and debug messages disappear until you set a level.

Why print is not logging

print is a fine debugging tool while you are sitting in front of the program. It stops being one the moment the code runs anywhere else.

A print statement has no level, so you cannot ask for errors only when the output gets noisy. It has no origin, so with fifty printed lines you cannot tell which module produced which. It has no timestamp, so you cannot tell whether the slow step took two seconds or two minutes. It always goes to standard output, so on a server it lands wherever the process manager happens to point, mixed in with everything else. And it cannot be switched off without editing the source, which is why production code ends up with commented out prints everywhere.

There is a subtler problem. When stdout is a pipe or a file rather than a terminal, Python buffers it in larger blocks, so printed lines can appear out of order relative to errors, or not appear at all if the process is killed. That is exactly the scenario where you most need the output.

The logging module solves all of that with roughly the same amount of typing:

import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s %(message)s',
)
log = logging.getLogger(__name__)

log.info('order created')
# 2026-08-06 10:14:02,113 INFO __main__ order created

You now have a timestamp, a severity, the module name and one place to change any of it. Nothing else in your code has to change when you later decide to write to a file instead, or to show debug messages only for one module.

Levels, and the default that swallows your logs

There are five standard levels, in increasing severity: DEBUG, INFO, WARNING, ERROR and CRITICAL. A logger emits a record only if its level is at or above the configured threshold, which is how you turn the volume up and down without touching the call sites.

Now the part that makes beginners abandon the module on day one:

import logging

logging.info('server started')       # prints nothing at all
logging.warning('disk almost full')  # WARNING:root:disk almost full

Nothing is broken. The root logger defaults to WARNING, so INFO and DEBUG are discarded before they reach any output. There is no message telling you this, which is why people conclude that logging does not work and go back to print. One line fixes it:

import logging

logging.basicConfig(level=logging.INFO)
logging.info('server started')       # INFO:root:server started

Choosing the right level is a real skill and it is what separates a useful log from a wall of text. A rough guide that holds up in practice. DEBUG is for values you would have printed while developing, such as the payload you are about to send, and it stays off in production. INFO is for events a normal, healthy run produces: server started, order created, file processed. WARNING is for something recovered from that a human might want to know about, such as a retry succeeding on the second attempt. ERROR is for an operation that failed. CRITICAL is for the application being unable to continue.

The test for INFO versus DEBUG is simple: if you would be annoyed to see this line a thousand times an hour in a live system, it is DEBUG. Getting this wrong in the noisy direction is worse than useless, because a log nobody reads is the same as no log at all.

basicConfig, formatters and the one-shot gotcha

basicConfig is the shortcut that sets up the root logger: it creates a handler, attaches a formatter and sets a level. The useful arguments are level, format, datefmt and filename.

import logging

logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S',
)

The format string uses percent style placeholders. The ones worth memorising are %(asctime)s for the time, %(levelname)s for the severity, %(name)s for the logger name, %(message)s for the text, and %(lineno)d with %(filename)s when you want to jump straight to the source line.

Here is the gotcha that costs people an afternoon. basicConfig does nothing if the root logger already has a handler, and it does so silently:

import logging

logging.warning('early message')            # implicitly configures the root logger
logging.basicConfig(level=logging.DEBUG)    # silently does nothing
logging.debug('you will never see this')

Calling logging.warning at module level, or importing a library that configures logging on import, is enough to trigger this. Your carefully written config is discarded without a word. There are two fixes: call basicConfig before anything else logs, ideally as the first thing your entry point does, or pass force=True, which removes existing handlers on the root logger and applies your configuration anyway. force was added in Python 3.8.

logging.basicConfig(level=logging.DEBUG, force=True)

One more rule that matters in real projects: configure logging only in the program's entry point, never in a library module or anything that gets imported. A library should create loggers with getLogger(__name__) and add no handlers, leaving the choice of destination and level to the application using it. A library that calls basicConfig on import is hijacking a decision that is not its to make.

Handlers: files, rotation and more than one destination

A handler decides where records go. You can attach several to the same logger, each with its own level and format, which is how you write everything to a file while showing only problems on the console.

import logging
from logging.handlers import RotatingFileHandler

log = logging.getLogger('payments')
log.setLevel(logging.DEBUG)

fmt = logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s')

file_handler = RotatingFileHandler(
    'payments.log', maxBytes=1_000_000, backupCount=5, encoding='utf-8'
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(fmt)

console = logging.StreamHandler()
console.setLevel(logging.WARNING)
console.setFormatter(fmt)

log.addHandler(file_handler)
log.addHandler(console)

log.info('order created')      # file only
log.error('capture failed')    # file and console

Use RotatingFileHandler rather than a plain FileHandler for anything long running. A plain file handler grows forever, and a log file that fills the disk takes the whole application down with it. With maxBytes and backupCount as above, you keep a bounded amount of history and old files are deleted automatically. TimedRotatingFileHandler does the same thing by clock time when you want one file per day.

File rotation behaves differently across platforms. On Windows, renaming a file that another process has open can fail, so multiple processes writing to the same rotating log is unreliable there; on Linux it usually works but can still interleave badly. If you run several worker processes, give each one its own file or send logs to the system journal.

Two mistakes produce duplicated lines. The first is adding a handler inside a function that runs more than once, so the same logger accumulates handlers and every message prints twice, then three times:

def get_logger():
    log = logging.getLogger('payments')
    log.addHandler(logging.StreamHandler())   # a new handler on every call
    return log

The second is having handlers on both a child logger and the root, since records propagate upwards by default. Either set log.propagate = False on the child, or attach handlers only at the root and let everything flow there, which is the simpler design for most applications.

Logging exceptions properly, and never logging secrets

Inside an except block, use logger.exception. It logs at ERROR level and attaches the full traceback automatically, which is the part you actually need:

try:
    average = total / count
except ZeroDivisionError:
    log.exception('could not compute average')
    average = 0

Outside an except block, or when you want a different level, pass exc_info=True instead. What you should not do is log.error(str(e)), because the string form of an exception often omits the type and always omits the line it came from, leaving you with "division by zero" and no idea where.

Pass arguments to the logger rather than formatting them yourself:

log.info('charged user %s amount %d paise', user_id, paise)   # preferred
log.info(f'charged user {user_id} amount {paise} paise')      # formats every time

With the first form, the string is only built if a handler actually emits the record, so debug calls in a hot loop cost almost nothing when debug is switched off. The f-string version does the work regardless, and it also loses the ability of log aggregation tools to group identical messages.

Now the rule that gets people fired rather than merely debugged. Never log secrets or personal data. Passwords, OTPs, API keys, session tokens, Authorization headers, card numbers, CVVs, Aadhaar and PAN numbers must never reach a log file. It is easy to do by accident, because the offending line usually looks harmless:

log.info('login payload=%s', request_body)   # contains the password
log.debug('headers=%s', dict(request.headers))  # contains the auth token

Logs are copied, shipped to third party dashboards, read by interns and included in support tickets. A secret written to a log is a secret you must now treat as compromised and rotate. Under India's data protection rules, personal data in logs is data you are responsible for, including its retention. Redact on the way in:

SENSITIVE = {'password', 'otp', 'token', 'authorization', 'card', 'cvv', 'aadhaar'}

def safe(payload: dict) -> dict:
    return {k: ('***' if k.lower() in SENSITIVE else v) for k, v in payload.items()}

log.info('login payload=%s', safe(request_body))

Log an identifier instead of the data itself. A user id or an order id lets you find the record in the database when you need it, and leaks nothing if the log file ends up somewhere it should not.

Frequently Asked Questions

Why does logging.info() print nothing? Because the root logger's default level is WARNING, so INFO and DEBUG records are discarded before any handler sees them. Call logging.basicConfig(level=logging.INFO) once at the start of your program. If that still shows nothing, something already configured the root logger, since basicConfig is a silent no-op when a handler is present; either move your call earlier or pass force=True.
Should I use logging.info() or a named logger? Use a named logger, created as log = logging.getLogger(__name__) at the top of each module. The module level functions such as logging.info() all write to the root logger, so every message reports the same source and you cannot raise the level for one noisy module. With __name__ the logger name matches the module path, which makes filtering and per-module levels straightforward later.
How do I log to a file instead of the console? The quick way is logging.basicConfig(filename='app.log', level=logging.INFO), which sends everything to that file. For anything long running use RotatingFileHandler or TimedRotatingFileHandler from logging.handlers instead, with maxBytes and backupCount set, so the file cannot grow until it fills the disk. Attaching both a file handler and a stream handler lets you keep full detail on disk and show only warnings on screen.
Is it safe to log a request body for debugging? Not as it comes. Request bodies routinely contain passwords, OTPs, tokens and personal details, and once written to a log those values are copied into backups, dashboards and support tickets. Redact known sensitive keys before logging, log an identifier such as the order id rather than the payload, and treat any secret that did reach a log as compromised and rotate it.
Why are my log lines printing twice? Almost always duplicate handlers. Either a function that adds a handler is being called more than once, so the logger accumulates them, or you have handlers on both a child logger and the root, and records propagate upwards by default. Add handlers exactly once during startup, and if a child logger needs its own destination, set propagate = False on it or attach handlers only at the root.