Here is a minimal Python logging example you can run as a standalone script. It uses the standard library, sets an INFO threshold, and gives the logger a name:
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("example")
logger.debug("not shown at INFO level")
logger.info("job started")
logger.warning("job is taking longer than expected")
Run it with python example.py. The output is:
INFO example: job started
WARNING example: job is taking longer than expected
By default, basicConfig() creates a StreamHandler for stderr, not stdout. The DEBUG record is filtered because its level is below INFO. Call basicConfig() once at application startup, before logging; if the root logger already has handlers, a later call normally does nothing. The Python Logging HOWTO documents these defaults and recommends named loggers in larger applications.
Choose levels that describe the event
Python's standard levels, in increasing severity, are DEBUG, INFO, WARNING, ERROR, and CRITICAL. DEBUG is detailed diagnostic information; INFO records normal progress; WARNING notes an unexpected condition while the program still works; ERROR means an operation could not be completed; CRITICAL indicates a serious condition that may prevent continued operation. These are producer decisions, not automatic instructions to page a human. See the Logging HOWTO's level guidance.
Set a production default intentionally and use a bounded debug window when investigating. A log call below the effective level does not produce a record, so a downstream collector cannot recover it. Conversely, indiscriminate DEBUG logging can expose more data and increase volume. Use a stable event message rather than one that embeds every argument or request body.
Pass variable data as logging arguments instead of formatting the string yourself: logger.info("processed %d items", 12).
The logger interpolates the arguments when the message is formatted. Do not pass secrets just because interpolation is deferred. In a service, prefer logging.getLogger(__name__) so the logger name tracks its module and can be configured by hierarchy.
Add timestamps and choose an output stream
For a standalone service that writes application logs to stdout, make the stream explicit:
import logging
import sys
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger("worker")
logger.info("batch completed")
%(asctime)s comes from the formatter's event time; its default presentation uses local time unless the formatter is configured otherwise. Define the timezone in your log contract rather than guessing from a bare timestamp. If you need an explicit UTC, machine-readable timestamp, the JSON formatter below uses datetime with timezone.utc.
Stdout versus stderr is an output choice, not a severity rule built into Python logging. Many container runtimes capture both streams, but their routing and downstream severity mapping depend on the runtime and collector configuration. Do not mix print() and logging for operational events without deciding which stream and format the collector reads. The logging handlers reference describes StreamHandler and its configurable stream.
Format safe JSON records
The standard library does not turn basicConfig() output into structured JSON automatically. A formatter can serialize a small, documented set of fields to one JSON object per line. This complete example runs on Python 3.8+; run it in a fresh process rather than after the earlier basicConfig() examples:
import json
import logging
import sys
from datetime import datetime, timezone
class JsonFormatter(logging.Formatter):
def format(self, record):
event = {
"timestamp": datetime.fromtimestamp(
record.created, timezone.utc
).isoformat(),
"severity": record.levelname,
"message": record.getMessage(),
"service_name": "example-worker",
"logger": record.name,
}
for key in ("job_kind", "run_id"):
value = getattr(record, key, None)
if value is not None:
event[key] = value
if record.exc_info and record.exc_info[0] is not None:
event["exception_type"] = record.exc_info[0].__name__
return json.dumps(event, ensure_ascii=False, separators=(",", ":"))
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
logger = logging.getLogger("worker")
logger.info(
"batch completed",
extra={"job_kind": "import", "run_id": "run-42"},
)
The output has a current UTC timestamp and this shape:
{"timestamp":"2026-09-16T12:00:00+00:00","severity":"INFO","message":"batch completed","service_name":"example-worker","logger":"worker","job_kind":"import","run_id":"run-42"}
The timestamp above is illustrative; your run uses its actual time. run_id is a synthetic example, not a user identifier. The formatter deliberately allowlists two extra fields. It does not serialize all of record.__dict__, exception arguments, or full tracebacks. record.getMessage() can still contain sensitive data if callers put it in the message or arguments, so enforce the same data policy at the call site. Python's logging cookbook shows custom formatters and structured-output patterns; the LogRecord reference defines the fields used here.
Use one schema consistently. Keep service_name, severity, and timestamps stable across services, then add bounded, safe context such as environment, release, operation type, or a pseudonymous run ID. Use separate, documented field names for request and trace IDs; neither is proof of user identity. Do not log tokens, passwords, cookies, full request bodies, personal details, or raw environment variables. If full stack traces are permitted for some events, protect their access and retention because exception text and local context can reveal sensitive information.
Log exceptions at the owning boundary
logger.exception() emits at ERROR level and includes exception information. It belongs inside an exception handler. Use it where the application decides that a work unit failed, not at every layer that sees the same exception:
import logging
logger = logging.getLogger("worker")
def run_one(job):
try:
job.execute()
except Exception:
logger.exception("job failed", extra={"job_kind": "import"})
raise
The raise preserves failure for the caller. If the caller also logs the same traceback, remove one record or define a single logging owner. A recoverable condition may merit a specific handler and a sanitized INFO or WARNING instead. Do not catch a broad Exception, log “failed,” and continue as if the operation succeeded. The logging API specifies that exception() adds exception information and should be called from a handler.
With the JSON formatter above, the exception_type field is included but the traceback text is not. With the default text formatter, logger.exception() prints traceback information. That distinction is deliberate: decide what evidence is necessary and who can access it. If a traceback is required, build an explicit reviewed formatter policy; do not assume a generic JSON dump of the exception is safe.
Prevent duplicate records and handler surprises
Named loggers form a hierarchy. A record from app.worker normally propagates to handlers on app and the root logger. If you attach one handler to app.worker and another to root, the same event can appear twice. Configure handlers at one appropriate level; library code should usually create a named logger but leave handler configuration to the application. The Python logging cookbook calls out adding handlers in libraries and adding the same handler more than once.
Avoid repeatedly calling configuration code during import, reload, or tests. basicConfig() is a convenience for a simple application; it is not a runtime switch for every module. force=True can replace root handlers on Python 3.8+, but doing that in a library can break the host application's setup. For larger applications, put configuration in one entry point and test the resulting output under the actual runner.
Files are sometimes required by an operating environment, but a file handler needs an explicit owner, permissions, rotation, and collection design. The handlers reference distinguishes rotating handlers; do not assume a plain FileHandler bounds disk usage. In containers, stdout/stderr collection is often simpler because the runtime already captures those streams, subject to its logging driver and retention settings.
Verify the whole collection path
Test three separate things: the application emitted the expected event, the container/runtime captured it, and the collector delivered it to the intended destination. Send one safe marker with a known service_name and run_id; inspect its JSON type and fields before using filters or alert rules. Test an error event as well as a normal one. If you change a formatter, logger level, handler, or collector mapping, repeat the check. A local logger.info() call is not evidence that the remote backend stored a record.
Fluxtail's Docker with Fluent Bit guide describes a supported path from Docker JSON logs through a collector to an HTTP JSON receiver. It requires the exact receiver URL and a receiver-bound token, and the collector mapping determines which Python fields reach the final log. Fluxtail is a paid Starter/Pro, logs-focused service: its search and filters and Live Tail can help inspect delivered records, but the application still owns exception handling and log emission. The documented path here uses a collector, not a Python SDK. A generic HTTPHandler is not a drop-in Fluxtail sender; it does not supply the documented receiver URL, authentication, and JSON payload contract by itself.
Keep a local failure path when collection is down. Application logging should not silently become the success condition for the job, and a missing remote record should trigger a collection investigation before concluding that no event occurred. For the broader operating policy—sensitive fields, retention, and pipeline checks—see log management best practices.