Skip to main content

Python Logging Basics: Standard Library Tutorial

The Python standard library's logging module is the production-grade way to emit diagnostic events from your code. Instead of using print() statements that end up mixed with real output, the logging module captures messages with metadata (timestamp, level, source), routes them to multiple destinations (console, files, networks), and formats them consistently. Almost every production Python application uses logging, and learning it now means you will never need to refactor print statements out of your code later.

Python's logging module provides a logger object that you import and use like so: logger.info("event happened"). Logs have severity levels from DEBUG (verbose) to CRITICAL (application down). You configure logging once at startup, and all of your code can then call logger.info(), logger.warning(), or logger.error() with confidence that the output is routed correctly. This article shows you how to set up logging in three lines of code and then customize it as your needs grow.

How Do You Set Up Basic Logging in Python?

The simplest setup is to call logging.basicConfig() at the start of your application. This configures the root logger with a format, level, and output destination.

import logging

# One-time configuration at application startup
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
filename='app.log'
)

# Now use logging anywhere in your code
logger = logging.getLogger(__name__)
logger.info("Application started")
logger.warning("This is a warning")
logger.error("An error occurred")

The basicConfig() function sets up the root logger with a single handler (file or console). The format string uses named placeholders like %(asctime)s (timestamp), %(name)s (logger name), %(levelname)s (INFO, WARNING, ERROR), and %(message)s (your message text). The level parameter filters messages: only messages at or above INFO level are emitted; DEBUG messages are discarded.

Why Should You Use getLogger(__name__) Instead of the Root Logger?

The __name__ variable in Python is the module path. When you call logging.getLogger(__name__), you get a logger that is named after your module, like myapp.database or myapp.handlers.payment. This allows you to enable/disable logging per module without restarting your app.

# In myapp/database.py
import logging
logger = logging.getLogger(__name__) # name is 'myapp.database'

def query(sql):
logger.debug(f"Executing: {sql}")
# ... execute query
return result

# In myapp/payment.py
import logging
logger = logging.getLogger(__name__) # name is 'myapp.payment'

def charge(amount):
logger.info(f"Charging ${amount}")
# ... charge card

At startup, you can set different levels for different loggers:

import logging
logging.basicConfig(level=logging.WARNING)

# Enable DEBUG only for database queries
logging.getLogger('myapp.database').setLevel(logging.DEBUG)

# All other modules stay at WARNING

This flexibility is why hierarchical logger names (using dots) are the standard pattern.

What Are Log Levels and When Should You Use Each One?

Log levels are severity rankings that let you filter messages by importance. Python defines five standard levels, from lowest to highest severity: DEBUG, INFO, WARNING, ERROR, CRITICAL.

LevelNumericTypical Use
DEBUG10Detailed diagnostic info for developers: SQL queries, variable values, loop iterations
INFO20General application flow: startup messages, user actions, task completions
WARNING30Something unexpected but handled: deprecated API use, high memory, retry attempts
ERROR40A task failed but the application continues: HTTP request failed, database timeout, file not found
CRITICAL50Application is unusable and exiting: system out of memory, cannot connect to database server

Choose levels based on intent, not frequency. Do not log info() just because something happens often; use debug() for diagnostic detail and info() for user-facing milestones.

import logging
logger = logging.getLogger(__name__)

def process_order(order_id):
logger.debug(f"Order dict keys: {order.keys()}") # developer diagnostic
logger.info(f"Processing order {order_id}") # customer-facing event

try:
result = send_to_warehouse(order)
except ConnectionError as e:
logger.warning(f"Warehouse unreachable, retrying: {e}") # recoverable
result = retry_with_backoff(order)

if not result:
logger.error(f"Order {order_id} failed after retries") # task failed
return False

logger.info(f"Order {order_id} succeeded")
return True

How Do You Write Logs to Both File and Console?

By default, basicConfig() sends logs to one destination. To send logs to both file and console, you need to manually add multiple handlers.

import logging

# Create the root logger
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

# Handler 1: Write to a file
file_handler = logging.FileHandler('app.log')
file_handler.setLevel(logging.DEBUG)

# Handler 2: Write to console
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO) # console only shows INFO and above

# Both handlers use the same format
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)

# Attach handlers to the logger
logger.addHandler(file_handler)
logger.addHandler(console_handler)

# Now every log goes to both destinations
logger = logging.getLogger(__name__)
logger.debug("This goes only to app.log")
logger.info("This goes to both app.log and console")

This pattern is essential for production: developers see INFO and above on the console, while the complete audit trail (including DEBUG) is persisted to a file.

What Happens if You Call basicConfig() More Than Once?

The basicConfig() function only configures the logger the first time it is called. Subsequent calls do nothing, even if you pass different arguments. This is intentional: you configure logging once at application startup, not repeatedly in modules.

import logging

# First call: configures the root logger
logging.basicConfig(level=logging.DEBUG, filename='first.log')

# Second call: has NO EFFECT
logging.basicConfig(level=logging.CRITICAL, filename='second.log')

logger = logging.getLogger(__name__)
logger.info("This message goes to first.log, not second.log")

If you need to reconfigure logging after startup (rare), manually access the root logger and reset its handlers:

import logging
logging.root.handlers.clear()
logging.basicConfig(level=logging.CRITICAL)

Key Takeaways

  • Import logging and call logging.basicConfig() once at startup to configure all loggers.
  • Use logger = logging.getLogger(__name__) in every module to create a named logger.
  • Log levels from DEBUG to CRITICAL let you filter messages by importance; choose wisely.
  • Use file handlers for audit trails and console handlers for real-time visibility.
  • Avoid print() in production code; use logger.info() instead for consistency.

Frequently Asked Questions

Should I ever use the root logger directly instead of getLogger(__name__)?

No. Always use getLogger(__name__) in application code so logs are tagged with their source module. The root logger is for framework and library setup only.

How do I log exceptions with full stack traces?

Use logger.exception() inside an except block, or pass exc_info=True to any log method. This captures the full traceback automatically.

What is the difference between basicConfig() and manual handler setup?

basicConfig() is a convenience that sets up one handler quickly. Manual handlers give you full control—different levels per destination, custom formatters, and multiple outputs. Use basicConfig() for simple scripts; use manual setup for applications.

Can I change the log level at runtime without restarting?

Yes. Call logger.setLevel() or handler.setLevel() at any time to adjust what gets logged. This is useful for temporary debugging.

How do I ensure logs are flushed to disk immediately?

File handlers buffer output by default. Call handler.flush() or logging.shutdown() to force a write. For critical audit trails, set StreamHandler instead and manage buffering yourself.

Further Reading