Every Python developer starts with print(). It's the first debugging tool we learn, and for small scripts, it works fine. But once your project grows beyond 500 lines — or worse, once it runs unattended in production — print statements become a liability.
I learned this the hard way. I had a cron job running daily that processed about 20,000 records. When it failed at 3 AM, my "debugging" was staring at a terminal that had already closed. No timestamps. No log levels. No way to know which record caused the crash.
So I rebuilt it with proper logging. Here's exactly what I learned.
Why print() Falls Short
Before we get into the how, let's be precise about why print() doesn't scale:
- No log levels — You can't separate INFO from WARNING from ERROR at a glance.
- No timestamps — Good luck figuring out which operation took 30 seconds versus 30 minutes.
- No file output — When your app crashes, stdout disappears.
- No formatting control — You're writing ad-hoc f-strings that look different in every module.
Logging solves all of these. And it's in the standard library — zero dependencies.
The Bare Minimum Setup
Here's the logging equivalent of "hello world":
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("Disk space below 10%%")
logger.error("Failed to connect to database")
Output:
2026-06-12 09:15:22 [INFO] __main__: Application started
2026-06-12 09:15:22 [WARNING] __main__: Disk space below 10%
2026-06-12 09:15:22 [ERROR] __main__: Failed to connect to database
Every message has a timestamp, a severity level, and a source. You can grep for [ERROR] in a 10,000-line log file and find the failures in seconds.
Choosing the Right Log Level
This is where most people get it wrong. Here's the rule of thumb I use:
| Level | When to use |
|---|---|
DEBUG |
Anything you only care about while actively developing |
INFO |
Normal operations: "Started processing file X", "User Y logged in" |
WARNING |
Something unexpected but recoverable: "Disk at 85%, continuing" |
ERROR |
Something failed but the app keeps running: "Failed to send email, retrying" |
CRITICAL |
The app cannot continue: "Database unreachable, shutting down" |
The mistake I made early on was using logger.info() for everything. When something went wrong, I was drowning in noise. Now I reserve INFO for state transitions and ERROR for actual failures.
Logging to a File (With Rotation)
Once your app runs unattended, you need logs to survive a crash. Here's a production-ready setup:
import logging
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"app.log",
maxBytes=5_242_880, # 5MB
backupCount=5,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[handler],
)
This creates app.log, and when it hits 5MB, renames it to app.log.1 and starts a fresh file. You'll never wake up to a server that ran out of disk space because of a log file.
For long-running servers, also add the console handler so you can tail logs during development:
import sys
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.DEBUG)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[handler, console],
)
Structured JSON Logging (For Production)
When you have multiple services or use log aggregation tools (ELK, Datadog, Grafana), plain text logs become hard to parse. JSON logs solve that:
import json
import logging
class JSONFormatter(logging.Formatter):
def format(self, record):
log_entry = {
"timestamp": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info and record.exc_info[0]:
log_entry["exception"] = self.formatException(record.exc_info)
if hasattr(record, "extra_data"):
log_entry["extra_data"] = record.extra_data
return json.dumps(log_entry)
handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
Now your logs look like:
{"timestamp": "2026-06-12 09:15:22", "level": "ERROR", "logger": "payments", "message": "Stripe charge failed", "extra_data": {"customer_id": "cus_123", "amount": 2999}}
You can grep, jq, or feed this directly into log analytics.
Structuring Loggers in a Multi-Module Project
In a real project, you don't use a single logger. Each module gets its own:
# myapp/database.py
import logging
logger = logging.getLogger(__name__) # "myapp.database"
def connect():
logger.info("Connecting to database...")
# ...
# myapp/api.py
logger = logging.getLogger(__name__) # "myapp.api"
def handle_request():
logger.info("Handling request...")
# ...
The beauty of Python's logging hierarchy is that setting level=logging.INFO on the "myapp" logger automatically applies to all children. But you can override individual modules:
logging.getLogger("myapp.database").setLevel(logging.DEBUG)
logging.getLogger("myapp.api").setLevel(logging.WARNING)
This means you can crank up database logging while keeping the API layer quiet.
Capturing Exception Tracebacks
This is the single biggest upgrade from print(). Instead of:
try:
process_order(order_id)
except Exception as e:
print(f"Failed: {e}") # You lose the traceback!
Use:
try:
process_order(order_id)
except Exception:
logger.exception("Failed to process order %s", order_id)
logger.exception() automatically includes the full traceback. You'll know exactly which line failed and why.
The One Pattern I Wish I'd Known Sooner
The biggest anti-pattern I see (and used myself) is f-string interpolation in log messages:
logger.info(f"Processing {user_id} — bad habit")
Even if the log level is WARNING and the message is never emitted, Python still evaluates the f-string. For expensive operations like str(large_dataset), this wastes CPU.
The correct pattern uses lazy formatting — Python only evaluates it if the log level is active:
logger.info("Processing %s — correct", user_id)
logger.info("Processing %s with %d records", user_id, len(data))
For logging.debug() calls inside hot loops, this can save measurable CPU time.
Putting It All Together
Here's the config template I copy into every new project:
import logging
import sys
from logging.handlers import RotatingFileHandler
def setup_logging(
name: str = "app",
level: int = logging.INFO,
log_file: str | None = "app.log",
json_format: bool = False,
):
logger = logging.getLogger(name)
logger.setLevel(level)
formatter = (
JSONFormatter()
if json_format
else logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
handlers = []
console = logging.StreamHandler(sys.stdout)
console.setFormatter(formatter)
handlers.append(console)
if log_file:
file_handler = RotatingFileHandler(
log_file, maxBytes=5_242_880, backupCount=5
)
file_handler.setFormatter(formatter)
handlers.append(file_handler)
for h in handlers:
logger.addHandler(h)
return logger
# Usage
log = setup_logging("myapp")
log.info("Application ready")
The switch from print() to logging was one of those small changes that had an outsized impact on my development velocity. Bugs that used to take 45 minutes to track down now take 5. Cron jobs that ran silently for months now tell me exactly what happened.
Your future self — the one debugging at 2 AM while your site is down — will thank you.
Top comments (0)