Python: Logging Levels, Handlers, Formatters

Python logging, explained the simple way: understand log levels, configure handlers and formatters, send logs to both a file and the console, rotate files so they never fill the disk, and pick up the patterns that move you from amateur print() debugging to the real thing.

“Adding manpower to a late software project makes it later.”

Fred Brooks, The Mythical Man-Month

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 15 minutes

You are still using print() for debugging, aren’t you? I was too, until a service crashed at 3 AM and I had no way to see what had happened. A print() writes to the screen and then it is gone. Logging writes to files, rotates them, stamps every line with a timestamp, and tags each message with a severity level you can filter on later. The setup takes about ten lines, and once you have done it once you never go back.

Here is the everyday version. A print() is like shouting an update across a noisy room: whoever happens to be listening hears it, and a second later nobody can prove you said anything. Logging is the security camera in the corner. It quietly records everything with a timestamp, you can rewind it tomorrow, and you can fast-forward past the boring parts straight to the moment things went wrong. By the end of this post you will set up that camera: named loggers, handlers that decide where messages go, formatters that decide how they look, and rotating files so the recording never eats your whole disk.

YesNo, droppedLog Levels (low high)DEBUG (10)detailed diagnosticINFO (20)normal operationWARNING (30)something unexpectedERROR (40)operation failedCRITICAL (50)system is downFormatterFormat string:%(asctime)s %(name)s%(levelname)s %(message)s2026-03-27 14:30:00myapp.db WARNINGConnection pool exhaustedHandler(s)StreamHandler consoleRotatingFileHandler rotating filesFileHandler fileSMTPHandler email alertsLoggerLogger namee.g. ‘myapp.db’Level >=threshold?Log Eventlogging.warning(‘msg’)LogRecord createdMessage discardedPython Logging: How a Message Flows from Logger to Handlers to Formatter

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram shows how Python logging is wired together. A logger captures a message, a handler decides where it goes (the console, a file, an email alert), and a formatter decides what each line looks like. Every message carries one of five severity levels (DEBUG, INFO, WARNING, ERROR, CRITICAL), and each handler can set its own cutoff so the noisy stuff never reaches it. That layered design is the whole reason logging beats print() for real code: you can route errors to a file and chatty debug lines to the console without touching a single log statement.

The Problem with print()

📄 the_problem.py: why print() falls apart in production

# Why print() is not enough:
# 1. No severity levels, so you can't filter noise from real errors
# 2. No timestamps, so you can't tell when it happened
# 3. Goes to one stream only, lost the moment the process dies
# 4. Can't be turned off without editing code
# 5. No source info: which module? which line?

# The fix: Python's built-in logging module
import logging

logging.basicConfig(level=logging.DEBUG)
logging.debug("This is debug info")      # Lowest priority
logging.info("Server started")           # Normal operation
logging.warning("Disk 80% full")         # Something to watch
logging.error("Database connection lost") # Something broke
logging.critical("System out of memory") # Everything is on fire

▶ Output

DEBUG:root:This is debug info
INFO:root:Server started
WARNING:root:Disk 80% full
ERROR:root:Database connection lost
CRITICAL:root:System out of memory

What happened here: One call to basicConfig() and five one-liners, and every message comes out tagged with its level and the logger name (root, because we used the module-level functions). One small surprise worth knowing: those lines went to stderr (standard error), not stdout (standard output), since that is where the default handler writes. The format looks plain because we have not added a formatter yet. That is the next thing we fix.

Log Levels: Severity Matters

LevelValueWhen to UseExample
DEBUG10Detailed diagnostic info for developersVariable values, function entry/exit
INFO20Normal operation milestonesServer started, user logged in
WARNING30Something unexpected but recoverableDisk filling up, deprecated API (Application Programming Interface) used
ERROR40Operation failedAPI call failed, file not found
CRITICAL50Application is unusableOut of memory, database down

📄 level_filtering.py: set the minimum level to filter out noise

import logging

# Only show WARNING and above (hides DEBUG and INFO)
logging.basicConfig(level=logging.WARNING, force=True)

logging.debug("Hidden, below threshold")
logging.info("Hidden, below threshold")
logging.warning("Visible, at threshold")
logging.error("Visible, above threshold")

▶ Output

WARNING:root:Visible, at threshold
ERROR:root:Visible, above threshold

What happened here: Setting the level to WARNING tells logging to throw away anything less serious. The debug and info calls still ran, Python just dropped them on the floor before they printed. Think of it as a volume knob: turn it up to DEBUG while you are hunting a bug, turn it down to WARNING in production so the logs stay readable. (The force=True is only there to reconfigure logging inside one script run; in a real app you set the level once at startup.)

Proper Logging Setup

📄 proper_setup.py: named loggers, handlers, and formatters

import logging

# Create a named logger (NOT the root logger)
logger = logging.getLogger("myapp.api")
logger.setLevel(logging.DEBUG)    # Logger accepts all levels

# Console handler: show INFO and up in the terminal
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console_fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")
console.setFormatter(console_fmt)

# File handler: write DEBUG and up to a file (more detail)
file_handler = logging.FileHandler("app.log")
file_handler.setLevel(logging.DEBUG)
file_fmt = logging.Formatter("%(asctime)s %(name)s %(levelname)s %(filename)s:%(lineno)d %(message)s")
file_handler.setFormatter(file_fmt)

# Attach handlers to logger
logger.addHandler(console)
logger.addHandler(file_handler)

# Usage
logger.debug("Processing request from Rahul")    # File only
logger.info("Request processed in 45ms")          # Console + file
logger.warning("Response time above threshold")    # Console + file
logger.error("Payment gateway timeout for Pravin") # Console + file

▶ Console Output

14:30:22 [INFO] Request processed in 45ms
14:30:22 [WARNING] Response time above threshold
14:30:22 [ERROR] Payment gateway timeout for Pravin

▶ app.log (file has more detail)

2026-06-21 14:30:22,123 myapp.api DEBUG proper_setup.py:24 Processing request from Rahul
2026-06-21 14:30:22,124 myapp.api INFO proper_setup.py:25 Request processed in 45ms
2026-06-21 14:30:22,124 myapp.api WARNING proper_setup.py:26 Response time above threshold
2026-06-21 14:30:22,125 myapp.api ERROR proper_setup.py:27 Payment gateway timeout for Pravin

What happened here: This is the pattern you will reuse forever. It works like a doctor’s visit: the receptionist gets a one-line summary of why you came, while your medical file records every detail for later. The console is the receptionist, the log file is the medical file. The logger itself is set to DEBUG, so it lets everything through, and then each handler picks its own cutoff. The console handler is set to INFO, so the debug line never reaches your terminal and stays out of your way.

The file handler is set to DEBUG, so the file keeps the full story, including that debug line, plus the filename and line number for each entry. Same log calls, two destinations, two levels of detail. (Rahul and Pravin in the messages are just two imaginary users hitting this API, the kind of names you would see scattered through real request logs.) The numbers like proper_setup.py:24 are the real source lines the calls sit on; yours will match wherever you put the logger.debug(...) line in your own file. The timestamps are simply the clock at the moment I ran this, so yours will show a different time.

Rotating File Handlers

📄 rotating.py: stop log files from growing forever

import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler

logger = logging.getLogger("myapp")
logger.setLevel(logging.INFO)    # A fresh logger inherits WARNING, so set this

# Rotate by SIZE: start a new file when the current one hits 5MB, keep 3 backups
size_handler = RotatingFileHandler(
    "app.log", maxBytes=5_000_000, backupCount=3
)
# Creates: app.log, app.log.1, app.log.2, app.log.3

# Rotate by TIME: a new file every midnight, keep 7 days of history
time_handler = TimedRotatingFileHandler(
    "app.log", when="midnight", interval=1, backupCount=7
)
# Creates: app.log, app.log.2026-06-20, app.log.2026-06-19, ...

logger.addHandler(size_handler)
logger.info("Anvay's deployment completed successfully")

What happened here: A plain FileHandler writes to one file until your disk fills up. A rotating handler fixes that automatically. Picture a security camera that records onto a stack of tapes: when the current tape is full it pops in a fresh one, and once the stack reaches its limit it records over the oldest tape. RotatingFileHandler swaps tapes by size (maxBytes), TimedRotatingFileHandler swaps them on a schedule (every midnight), and backupCount is how many old tapes you keep before the oldest gets reused.

One detail worth noticing in the code: only size_handler is actually attached with addHandler; the time handler is created purely for comparison. In your own code attach one or the other, and if you ever do use both, give each its own filename so they do not fight over the same file.

Set those two numbers and your logs can never quietly eat the server. The last line records a successful deploy by an engineer named Anvay, and note the setLevel(logging.INFO) near the top: a brand-new logger inherits WARNING from the root, so without that line the info message would be silently dropped before it ever reached the file.

Logging Exceptions

📄 exception_logging.py: capture the full traceback

import logging

logger = logging.getLogger("myapp.db")
logging.basicConfig(level=logging.DEBUG)

def fetch_user(user_id):
    """Simulate a database query that fails."""
    users = {"1": "Aditi", "2": "Prathamesh"}
    return users[user_id]    # KeyError if not found

try:
    user = fetch_user("99")
except KeyError:
    # exc_info=True adds the full traceback to the log
    logger.error("User not found", exc_info=True)

    # Shortcut: logger.exception() is error + exc_info=True
    # logger.exception("User not found")

▶ Output

ERROR:myapp.db:User not found
Traceback (most recent call last):
  File "exception_logging.py", line 12, in <module>
    user = fetch_user("99")
  File "exception_logging.py", line 9, in fetch_user
    return users[user_id]    # KeyError if not found
           ~~~~~^^^^^^^^^
KeyError: '99'

What happened here: The toy database knows only two registered users, Aditi and Prathamesh, so asking for ID 99 blows up exactly the way a missing row would in production. The exc_info=True flag is what pulls in the full traceback instead of a bare one-line message. Without it you would only see User not found and have no idea where it blew up. An error log without a traceback is like an airline report that just says “the plane had a problem”; the traceback is the black box recording, everything that led up to the failure, line by line.

Notice the little ~~~~~^^^^^^^^^ markers under users[user_id]: that is Python 3.14.6 pointing at the exact expression that raised, which makes a busy traceback far easier to read. In real code you would skip the exc_info=True typing and just call logger.exception("User not found") inside the except block, which does the same thing in fewer keystrokes.

Logger Hierarchy & Propagation

📄 hierarchy.py: logger names build a tree

import logging

# Logger names use dots to build a hierarchy
# "myapp" is parent of "myapp.api", which is parent of "myapp.api.auth"

app = logging.getLogger("myapp")
api = logging.getLogger("myapp.api")
auth = logging.getLogger("myapp.api.auth")

# Put the handler on the parent: the children inherit it
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
app.addHandler(handler)
app.setLevel(logging.DEBUG)

# All three use the same handler (propagation)
auth.info("Niranjan logged in")     # myapp.api.auth: Niranjan logged in
api.warning("Rate limit reached")   # myapp.api: Rate limit reached
app.error("Config file missing")    # myapp: Config file missing

▶ Output

myapp.api.auth: Niranjan logged in
myapp.api: Rate limit reached
myapp: Config file missing

What happened here: The dots in a logger name are not decoration, they build a family tree. myapp.api.auth is a child of myapp.api, which is a child of myapp. We attached a single handler to the myapp parent, yet all three loggers used it, including the auth logger reporting that a user named Niranjan just signed in. That is propagation: a message bubbles up to its parents, the way a question in a company travels up the org chart until someone is responsible for answering it.

The practical payoff is huge: configure logging once on your top-level myapp logger and every module under it inherits the setup for free. This is also why the standard advice is logging.getLogger(__name__) in each file, since __name__ already gives you the dotted path that slots neatly into the tree.

Production Logging Recipe

📄 production.py: copy this into any new project

import logging
import sys
from logging.handlers import RotatingFileHandler

def setup_logging(name="myapp", level=logging.INFO):
    """Standard logging setup for production apps."""
    logger = logging.getLogger(name)
    logger.setLevel(logging.DEBUG)   # Capture everything

    formatter = logging.Formatter(
        "%(asctime)s [%(levelname)-8s] %(name)s (%(filename)s:%(lineno)d) %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S"
    )

    # Console: INFO+
    console = logging.StreamHandler(sys.stdout)
    console.setLevel(level)
    console.setFormatter(formatter)
    logger.addHandler(console)

    # File: DEBUG+ with rotation
    file_h = RotatingFileHandler("app.log", maxBytes=10_000_000, backupCount=5)
    file_h.setLevel(logging.DEBUG)
    file_h.setFormatter(formatter)
    logger.addHandler(file_h)

    return logger

# Usage across your app
logger = setup_logging("myapp")
logger.info("Application started")
logger.debug("Config loaded from /etc/myapp/config.yaml")

▶ Console Output

2026-06-21 15:49:43 [INFO    ] myapp (production.py:31) Application started

What happened here: The console shows just the one INFO line, because the console handler is set to level (which defaults to INFO). The debug line is missing from the terminal on purpose; it still lands in app.log, where the file handler keeps the full DEBUG detail. The [%(levelname)-8s] piece pads the level name to eight characters so the columns line up no matter whether it says INFO or WARNING.

The date and time stamp is just when you run it, so yours will differ. Think of this function as the main switchboard of a house: you wire it once, and after that every light switch in every room just works. Drop this setup_logging() function into a new project, call it once at startup, then use logging.getLogger("myapp") anywhere else and everything routes through the same setup.

Common Mistakes

❌ Mistake: Using the root logger in libraries

# Bad: pollutes the root logger, affects all logging
import logging
logging.info("Something happened")    # Uses root logger

# Good: use a named logger per module
logger = logging.getLogger(__name__)   # Uses module path as name
logger.info("Something happened")     # Isolated, configurable

Why: The root logger is shared by your whole program and every library you import. Configure it from inside a module and you change logging for everyone, like turning up the thermostat for the entire office building because your one desk felt cold. A named logger from getLogger(__name__) stays in its own branch of the tree, so callers can turn your module up to DEBUG or silence it without affecting anything else.

❌ Mistake: String formatting in log calls

# Bad: the f-string is built even when the message is filtered out
logger.debug(f"Processing user {user_id} with data {expensive_repr()}")

# Good: lazy formatting (logging only builds the string if it will be logged)
logger.debug("Processing user %s with data %s", user_id, expensive_repr())

Why: With the f-string version, Python builds the whole message (and calls expensive_repr()) before handing it to logger.debug, even when your level is set to WARNING and the line gets thrown away. You pay the cost for nothing. Passing the arguments separately lets logging check the level first and only format the string if the message is actually going to be recorded. On a hot path with thousands of debug calls, that difference is real.

Wrapping Up

You now have the full python logging toolkit: five severity levels that let you filter noise from emergencies, named loggers that build a tree, handlers that route the same message to a console and a file at different levels of detail, formatters that stamp every line, and rotating files that can never eat your disk. The single habit to take away is small: stop typing print() in anything that runs longer than five minutes, grab a logger with logging.getLogger(__name__), and let levels do the filtering. The production recipe above is yours to paste into your next project.

Next up we switch gears from operations to performance: Big O notation in Python, explained with real timings so you can see which approach actually scales. and if you want to jump to any other topic, browse the full Python + AI/ML tutorial series home.

Frequently Asked Questions

What are the Python logging levels?

Python has five standard log levels: DEBUG (10), INFO (20), WARNING (30), ERROR (40), and CRITICAL (50). Messages below the configured level are silently discarded. The default level is WARNING, which is why logging.info() produces no output without basicConfig(level=logging.INFO).

What is the difference between logging and print in Python?

print() outputs to stdout with no metadata, no filtering, and no persistence. logging adds timestamps, severity levels, source info, can route to multiple destinations (console, files, email), and can be filtered by level without changing code.

Should I use logging.basicConfig or create handlers manually?

Use basicConfig() for simple scripts. For applications, create handlers manually, because that gives you control over multiple outputs, a different format per handler, a different level per handler, and rotating files.

What is logger.exception() in Python?

logger.exception(msg) is a shortcut for logger.error(msg, exc_info=True). It logs the message at ERROR level AND includes the full traceback. Only call it inside an except block.

How do I prevent log files from growing too large?

Use RotatingFileHandler (rotate by size) or TimedRotatingFileHandler (rotate by time). Set maxBytes and backupCount to limit total disk usage. Example: 10MB max with 5 backups = 60MB total maximum.

Try It Yourself

Set up logging for a mini-application with two loggers: app.api (logs to console at INFO) and app.db (logs to db.log at DEBUG). Make both inherit formatting from a parent app logger. Log 5 messages at different levels from each and verify the output routing. That routing puzzle is exactly what a real python logging setup solves in a multi-module app.

Interview Questions on Python Logging

The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.

Q: Your service is running fine, but the logger.info() calls scattered through the code produce no output anywhere, not in the console and not in the file. What do you check first?

Check the effective level and the handlers. A freshly created logger has level NOTSET and inherits from the root logger, whose default is WARNING, so INFO messages are dropped before they go anywhere. Print logger.getEffectiveLevel() to confirm, then verify a handler is actually attached: with no handlers configured, Python’s last-resort handler only shows WARNING and above. Also check that no logger in the chain has propagate=False set.

Q: After a refactor, every log message suddenly appears two or three times in the console. What is going on?

Duplicate handlers. The usual causes are a setup function that gets called more than once and adds a new StreamHandler on every call, or a handler attached to both a child logger and its parent, so propagation delivers the same record to each. Fix it by configuring logging exactly once at startup, or guard the setup with if not logger.handlers, or set propagate=False on the child that has its own handler.

Q: What is the difference between the level set on a logger and the level set on a handler?

They are two separate gates. The logger’s effective level is checked first: if the message is below it, the record is discarded immediately and no handler ever sees it. If it passes, each attached handler then applies its own level independently. That is why the standard pattern sets the logger to DEBUG and lets the console handler filter at INFO while the file handler keeps everything.

Q: Why does the logging documentation recommend logger.debug(“User %s”, user) instead of an f-string?

Lazy formatting. When you pass the arguments separately, logging checks the level first and only builds the final string if the record will actually be emitted. An f-string is built eagerly, before the call even happens, so at WARNING level you still pay for every string construction and every expensive function call embedded in it. On a hot path with thousands of debug calls, that overhead is measurable.

Q: Your app forks multiple worker processes and they all write to the same RotatingFileHandler file. Lines come out garbled and rotation sometimes truncates data. What is the correct design?

The logging module is thread-safe, but it is not safe for multiple processes sharing one file: each process rotates and writes independently, so they clobber each other. The standard fix is a QueueHandler in every worker feeding a queue, with a single QueueListener in one process that owns the actual file handler. Alternatives are a SocketHandler sending to a central logging server, or one log file per process aggregated by an external tool.

Q: When would you set propagate=False on a logger?

When a subsystem needs isolated output. A typical case is an access-log logger that writes requests to its own file: without propagate=False, every record also bubbles up to the root logger’s console handler and shows up twice. Set it after attaching the dedicated handler, and use it sparingly, because loggers with propagation disabled no longer respond to application-wide logging configuration.

Further reading: Python logging documentation is the authoritative source on this.

Previous: Python: Datetime and Time (Dates, Timezones, Formatting)

Next: Big O Notation in Python, Explained with Real Timings

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *