What you'll learn
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.
