Last updated: April 28, 2026
Setting Up Python JSON Logger: A Practical Guide
If you've ever tried to grep through a wall of unstructured Python logs during an incident, you already know why plain text logging doesn't scale. The moment your application runs across multiple services, or you pipe logs into any kind of aggregation tool, you need structure. And in practice, "structure" almost always means JSON.
python-json-logger is one of the
most popular ways to get there in the Python ecosystem. It's a drop-in
logging.Formatter replacement that serializes your log records as JSON
objects, and it works with Python's
standard logging module
without forcing you to change how you write log statements.
The library has been around since 2012, originally created by Zakaria Zajac and now maintained by Nicholas Hairs. As at the time of writing, it's on version 4.1, requires Python 3.10+, and supports alternative JSON encoders like orjson and msgspec.
This guide covers installation, configuration, field customization, trace
correlation, and how python-json-logger fits into an
OpenTelemetry-based
logging pipeline.
Installing python-json-logger
To get started, install from PyPI with pip:
1pip install python-json-logger
If you want to use one of the faster JSON encoders, install the relevant package:
123pip install python-json-logger orjson# orpip install python-json-logger msgspec
The library depends only on Python's standard library by default, so the base install is lightweight.
Migration notes for v3 and v4 (optional)
If you're upgrading from the pre-fork version (v2.x, under the
madzak/python-json-logger repository), there are some breaking changes to
watch for.
In v3.0+, the import path changed. The old import was
from pythonjsonlogger import jsonlogger, and the formatter class was
jsonlogger.JsonFormatter. The new import is:
12# v3+ importfrom pythonjsonlogger.json import JsonFormatter
The old pythonjsonlogger.jsonlogger module still exists as a compatibility
shim, but you should update your imports.
Version 4.0, released in October 2025, introduced several changes worth knowing
about. The log_record argument in methods like process_log_record,
add_fields, and serialize_log_record was renamed to avoid confusion with
logging.LogRecord. If you've subclassed any of the formatter classes and
overridden these methods, you'll need to update your method signatures.
v4.0 also dropped support for passing strings in place of objects when
instantiating formatters (for json_default, json_encoder,
json_serializer). If you were relying on that for fileConfig compatibility,
use the ext:// prefix format instead.
And as of v4.1, the minimum Python version is 3.10.
Setting up JsonFormatter
At its simplest, you swap the standard formatter for JsonFormatter and
everything else stays the same:
12345678910111213141516import loggingfrom pythonjsonlogger.json import JsonFormatterlogger = logging.getLogger()logger.setLevel(logging.INFO)handler = logging.StreamHandler()# explicitly configure the fields you want to see in the JSON outputhandler.setFormatter(JsonFormatter("%(asctime)s %(levelname)s %(message)s"))logger.addHandler(handler)logger.info("order processed",extra={"order_id": "abc-123", "total": 49.99},)
This produces a single line of JSON:
1234567{"asctime": "2026-04-28 17:58:13,312","levelname": "INFO","message": "order processed","order_id": "abc-123","total": 49.99}
That's the whole integration! Your existing logger.info(), logger.warning(),
and logger.error() calls continue to work exactly as before, and the only
thing that changes is the output format.
The library strips out standard LogRecord attributes (like asctime,
levelname, lineno, and so on) by default, unless you explicitly include them
in the format string. This keeps your JSON output small, but it also means
you'll need to configure the fields you actually care about.
If you prefer configuring logging declaratively, the same setup works through
dictConfig. Here's an equivalent configuration as a Python dictionary, which
you could just as easily load from a YAML file:
12345678910111213141516171819202122232425262728293031import logging.configLOGGING_CONFIG = {"version": 1,"disable_existing_loggers": False,"formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","format": "%(asctime)s %(levelname)s %(message)s",}},"handlers": {"console": {"class": "logging.StreamHandler","formatter": "json","stream": "ext://sys.stdout",}},"root": {"level": "INFO","handlers": ["console"],},}logging.config.dictConfig(LOGGING_CONFIG)logger = logging.getLogger()logger.info("order processed",extra={"order_id": "abc-123", "total": 49.99},)
The "()" key tells dictConfig to instantiate the class directly rather than
calling it as a factory. This is the standard Python mechanism for using custom
formatter classes in declarative logging configs. The sections that follow add
field renaming, static fields, and other options on top of this base config.
Controlling which fields appear
You control the output fields through the fmt argument when creating the
formatter. The library supports several format styles, including the standard
%, {, and $ styles from Python's logging module, plus a custom
comma-separated style:
12345678910111213141516# Standard brace-style formatformatter = JsonFormatter("{asctime} {levelname} {message} {module}",style="{",)# Comma-separated style (specific to python-json-logger)formatter = JsonFormatter("asctime,levelname,message,module",style=",",)# Or pass a list of field names directlyformatter = JsonFormatter(["asctime", "levelname", "message", "module"])
All three produce the same output. The comma style and list style were added in version 4.0 and are often the cleanest option when you're writing configs from scratch.
In a dictConfig setup, the format and style keys map directly to these
arguments. Here's the comma-separated style as a dictionary config:
12345678910111213141516171819202122LOGGING_CONFIG = {"version": 1,"disable_existing_loggers": False,"formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","format": "asctime,levelname,message,module","style": ",",}},"handlers": {"console": {"class": "logging.StreamHandler","formatter": "json","stream": "ext://sys.stdout",}},"root": {"level": "INFO","handlers": ["console"],},}
You can also pass reserved_attrs through dictConfig to control which
standard fields get excluded:
123456"formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","reserved_attrs": [],}}
Setting it to an empty list dumps everything onto the JSON output:
1234567891011121314151617181920212223242526{"message": "order processed","name": "root","msg": "order processed","args": [],"levelname": "INFO","levelno": 20,"pathname": "/Users/dev/dash0/demo/python-json-logger/main.py","filename": "main.py","module": "main","exc_info": null,"exc_text": null,"stack_info": null,"lineno": 50,"funcName": "<module>","created": 1777396195.283608,"msecs": 283.0,"relativeCreated": 11.542,"thread": 8698225216,"threadName": "MainThread","processName": "MainProcess","process": 36954,"taskName": null,"order_id": "abc-123","total": 49.99}
In most cases you don't want that, so pick the fields you need and leave the rest out.
Renaming and excluding fields
When shipping logs to an aggregation platform, you'll often need field names
that match a specific schema. The rename_fields argument handles this:
123456789formatter = JsonFormatter("{asctime} {levelname} {message}",style="{",rename_fields={"asctime": "timestamp","levelname": "severity","message": "msg",},)
The same thing in dictConfig:
123456789101112"formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","format": "{asctime} {levelname} {message}","style": "{","rename_fields": {"asctime": "timestamp","levelname": "severity","message": "msg",},}}
Now your JSON output uses timestamp, severity, and msg instead of the
Python defaults. This is useful when you're ingesting logs into a system that
expects
OpenTelemetry semantic conventions
or some other predefined schema.
To exclude specific fields, add them to the reserved_attrs array you saw
earlier:
123456789from pythonjsonlogger.core import RESERVED_ATTRSformatter = JsonFormatter(reserved_attrs=[*RESERVED_ATTRS,"request_id","internal_flag",])
Or through dictConfig:
1234567891011121314151617from pythonjsonlogger.core import RESERVED_ATTRSLOGGING_CONFIG = {# ..."formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","format": "%(asctime)s %(levelname)s %(message)s","reserved_attrs": [*RESERVED_ATTRS,"request_id","internal_flag",],}},# ...}
If you're configuring the logging module from a YAML or JSON file, you can't
spread the
RESERVED_ATTRS
constant, so you'd need to explicitly list the attribute you want excluded one
by one.
Adding static and default fields
Metadata like the environment name or service identifier should probably appear
on every log line without you having to pass it each time. The static_fields
argument does this without any per-call effort:
123456formatter = JsonFormatter(static_fields={"service.name": "payment-api","deployment.environment.name": "production",})
Every log record produced by this formatter will include
"service.name": "payment-api" and
"deployment.environment.name": "production", regardless of what you pass at
the call site.
In a dictionary config:
12345678910"formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","format": "%(asctime)s %(levelname)s %(message)s","static_fields": {"service.name": "payment-api","deployment.environment.name": "production",},}}
Version 4.0 added support for DictConfigurator prefixes in static_fields, so
you can reference Python objects with the ext:// syntax. That's handy when you
want to pull values from a config module rather than hardcoding them:
1234"static_fields": {"service.name": "ext://myapp.config.SERVICE_NAME","service.version": "ext://myapp.config.VERSION",}
The defaults argument works similarly to static_fields:
123456789101112formatter = JsonFormatter(defaults={"environment": "dev"})# This log will have environment=devlogger.info("startup complete")# This one overrides it to environment=staginglogger.info("startup complete",extra={"environment": "staging"},)
Both defaults and static_fields add fields to every log record, but they
work at different layers. defaults is passed through to the standard library's
logging.Formatter and sets fallback values on the LogRecord itself, while
static_fields is a python-json-logger feature that only injects fields
during JSON serialization.
Swapping in a faster JSON encoder
By default, the library uses Python's built-in json module which is fine for
most workloads. But if you're logging at high volume, python-json-logger ships
with formatter classes for two faster alternatives:
12345# Using orjson (typically 5-10x faster than stdlib json)# https://github.com/ijl/orjson#performancefrom pythonjsonlogger.orjson import OrjsonFormatterformatter = OrjsonFormatter()
1234# Using msgspecfrom pythonjsonlogger.msgspec import MsgspecFormatterformatter = MsgspecFormatter()
Here's the dictConfig equivalent:
12345"formatters": {"json": {"()": "pythonjsonlogger.orjson.OrjsonFormatter",}}
Both drop in exactly like JsonFormatter. The API is the same; only the
encoding backend changes.
Serializing custom types
If you need to control how non-standard types are serialized (say, turning
UUID objects into strings or datetime objects into ISO format), pass a
json_default function:
123456789101112131415# main.pyfrom decimal import Decimalclass Money:def __init__(self, amount: Decimal, currency: str):self.amount = amountself.currency = currencydef my_default(obj):if isinstance(obj, Money):return {"amount": str(obj.amount), "currency": obj.currency}raise TypeError(f"Cannot serialize {type(obj)}")formatter = JsonFormatter(json_default=my_default)
In dictConfig, you can't pass a function directly, but you can reference one
from a module using the ext:// prefix:
1234567"formatters": {"json": {"()": "pythonjsonlogger.json.JsonFormatter","format": "%(asctime)s %(levelname)s %(message)s","json_default": "ext://__main__.my_default",}}
This outputs:
12345678{"asctime": "2026-04-28 19:08:33,048","levelname": "INFO","message": "order processed","module": "main","order_id": "abc-123","total": { "amount": "49.99", "currency": "USD" }}
The library already handles many common types out of the box (UUIDs become strings, bytes become base64), so you'll likely only need a custom default for your own domain objects.
Integrating with OpenTelemetry
If you're already using
OpenTelemetry for Python,
there are two ways to connect python-json-logger to your telemetry pipeline,
depending on how deeply you want to integrate.
Injecting trace context into log records
The
opentelemetry-instrumentation-logging
package can inject trace and span IDs into standard Python log records. Once
it's enabled, every LogRecord gets otelTraceID, otelSpanID, and
otelServiceName attributes. Since python-json-logger automatically picks up
custom LogRecord attributes, these show up in your JSON output with no
additional work.
There's one ordering constraint to be aware of: you must add your handler to the
logger before calling instrument(). Internally, instrument() calls
logging.basicConfig(), which is a no-op if the root logger already has
handlers. If you call instrument() first, basicConfig() installs its own
plain-text handler and you end up with two handlers: one JSON, one plain text.
12345678910111213141516171819202122232425262728import loggingimport sysfrom opentelemetry import tracefrom opentelemetry.instrumentation.logging import LoggingInstrumentorfrom opentelemetry.sdk.trace import TracerProviderfrom opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessorfrom pythonjsonlogger.json import JsonFormatterprovider = TracerProvider()provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))trace.set_tracer_provider(provider)# Add the handler first so basicConfig inside instrument() is a no-ophandler = logging.StreamHandler(sys.stdout)handler.setFormatter(JsonFormatter("{asctime} {levelname} {message} {otelTraceID} {otelSpanID} {otelServiceName}",style="{",))logger = logging.getLogger()logger.addHandler(handler)logger.setLevel(logging.INFO)# set_logging_format=True enables trace field injection into every LogRecordLoggingInstrumentor().instrument(set_logging_format=True)
With that in place, any log call made inside an active span automatically carries the trace and span IDs:
12345678tracer = trace.get_tracer(__name__)def process_order(order_id: str) -> None:with tracer.start_as_current_span("order.process") as span:span.set_attribute("order.id", order_id)logger.info("order processed", extra={"order_id": order_id})process_order("abc-123")
The resulting log line has otelTraceID and otelSpanID values that match the
active span, making it straightforward to jump from a log entry to the
corresponding distributed trace in your observability backend:
12345678910{"asctime": "2026-04-28 19:14:56,126","levelname": "INFO","message": "order processed","otelTraceID": "21b545c5d13e8f39ef1f3bacbf5366d3","otelSpanID": "f20ac0dece44e829","otelTraceSampled": true,"otelServiceName": "unknown_service","order_id": "abc-123"}
This is the quickest path to getting
log-trace correlation
working, but note that otelTraceID and otelSpanID are plain string
attributes in the JSON body and not the first-class trace_id and span_id
fields of the
OTel Log Data Model.
If you collect these logs through a filelog receiver or any other receiver, you need to configure it to extract those fields and promote them to the proper OTLP trace context fields, otherwise your backend has no way to link a log record to its trace.
The Bridge API approach below sets those fields automatically on every OTLP
LogRecord, so no extra collector configuration is required.
Using the OTel Logs Bridge API
For deeper integration, you can use OpenTelemetry's Logs Bridge API to send log records directly to an OpenTelemetry Collector via the OpenTelemetry Protocol (OTLP).
In this approach, python-json-logger is not involved. OTel's LoggingHandler
takes over entirely, turning every logger.info() call into a properly
structured OTLP LogRecord with trace_id and span_id set as first-class
fields.
To set it up, use a LoggerProvider backed by the OTLP exporter and attach its
handler to your root logger:
12345678910111213141516171819import loggingfrom opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporterfrom opentelemetry.sdk._logs import LoggerProvider, LoggingHandlerfrom opentelemetry.sdk._logs.export import BatchLogRecordProcessorprovider = LoggerProvider()provider.add_log_record_processor(BatchLogRecordProcessor(OTLPLogExporter()))otel_handler = LoggingHandler(level=logging.INFO,logger_provider=provider,)logger = logging.getLogger()logger.setLevel(logging.INFO)logger.addHandler(otel_handler)
OTLPLogExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from the environment, so
no endpoint is hard-coded here. Before the process exits, call
provider.shutdown() to flush any buffered records:
123logger.info("order processed", extra={"order_id": "abc-123", "total": 49.99})provider.shutdown()
Comparing python-json-logger with structlog
The obvious alternative to python-json-logger is
structlog. Where
python-json-logger is a formatter you attach to Python's existing logging
module, structlog is a full logging library with its own API, processor
pipeline, and context binding system.
If you have an existing codebase with hundreds of logging.info() calls and you
just want JSON output, python-json-logger gets you there with a one-line
formatter change. If you're starting a new project and want richer context
binding, typed processors, and a more ergonomic API, structlog is worth the
look.
They're not mutually exclusive, either. structlog can use Python's logging
module as its output backend, and you can attach JsonFormatter to that backend
if you want. In practice, most teams pick one and stick with it.
We also have a guide to choosing a Python logging library if you're weighing your options.
Final thoughts
python-json-logger does one thing well: it turns Python's standard logging
output into machine-readable JSON without asking you to rewrite your
application. That simplicity is its main advantage. You install it, swap the
formatter, and your logs are ready for whatever aggregation pipeline you're
feeding them into.
For most production setups, the combination of python-json-logger for local
JSON formatting and OpenTelemetry for telemetry export covers all the bases.
Your logs are structured and queryable, and they correlate with traces. And when
something breaks at 2 AM, you're searching indexed JSON fields instead of
parsing free text with regular expressions.
If you're looking for a platform that handles log ingestion, correlation, and analysis natively with OpenTelemetry, Dash0 is built from the ground up on OTLP and supports Python applications through the Dash0 Kubernetes Operator or direct SDK integration.
