Dash0 Raises $110M Series B at $1B Valuation

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:

bash
1
pip install python-json-logger

If you want to use one of the faster JSON encoders, install the relevant package:

bash
123
pip install python-json-logger orjson
# or
pip 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:

python
12
# v3+ import
from 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:

python
12345678910111213141516
import logging
from pythonjsonlogger.json import JsonFormatter
logger = logging.getLogger()
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
# explicitly configure the fields you want to see in the JSON output
handler.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:

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:

python
12345678910111213141516171819202122232425262728293031
import logging.config
LOGGING_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:

python
12345678910111213141516
# Standard brace-style format
formatter = 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 directly
formatter = 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:

python
12345678910111213141516171819202122
LOGGING_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:

python
123456
"formatters": {
"json": {
"()": "pythonjsonlogger.json.JsonFormatter",
"reserved_attrs": [],
}
}

Setting it to an empty list dumps everything onto the JSON output:

json
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:

python
123456789
formatter = JsonFormatter(
"{asctime} {levelname} {message}",
style="{",
rename_fields={
"asctime": "timestamp",
"levelname": "severity",
"message": "msg",
},
)

The same thing in dictConfig:

python
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:

python
123456789
from pythonjsonlogger.core import RESERVED_ATTRS
formatter = JsonFormatter(
reserved_attrs=[
*RESERVED_ATTRS,
"request_id",
"internal_flag",
]
)

Or through dictConfig:

python
1234567891011121314151617
from pythonjsonlogger.core import RESERVED_ATTRS
LOGGING_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:

python
123456
formatter = 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:

python
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:

python
1234
"static_fields": {
"service.name": "ext://myapp.config.SERVICE_NAME",
"service.version": "ext://myapp.config.VERSION",
}

The defaults argument works similarly to static_fields:

python
123456789101112
formatter = JsonFormatter(
defaults={"environment": "dev"}
)
# This log will have environment=dev
logger.info("startup complete")
# This one overrides it to environment=staging
logger.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:

python
12345
# Using orjson (typically 5-10x faster than stdlib json)
# https://github.com/ijl/orjson#performance
from pythonjsonlogger.orjson import OrjsonFormatter
formatter = OrjsonFormatter()
python
1234
# Using msgspec
from pythonjsonlogger.msgspec import MsgspecFormatter
formatter = MsgspecFormatter()

Here's the dictConfig equivalent:

python
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:

python
123456789101112131415
# main.py
from decimal import Decimal
class Money:
def __init__(self, amount: Decimal, currency: str):
self.amount = amount
self.currency = currency
def 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:

python
1234567
"formatters": {
"json": {
"()": "pythonjsonlogger.json.JsonFormatter",
"format": "%(asctime)s %(levelname)s %(message)s",
"json_default": "ext://__main__.my_default",
}
}

This outputs:

json
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.

python
12345678910111213141516171819202122232425262728
import logging
import sys
from opentelemetry import trace
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
from pythonjsonlogger.json import JsonFormatter
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
# Add the handler first so basicConfig inside instrument() is a no-op
handler = 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 LogRecord
LoggingInstrumentor().instrument(set_logging_format=True)

With that in place, any log call made inside an active span automatically carries the trace and span IDs:

python
12345678
tracer = 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:

json
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:

python
12345678910111213141516171819
import logging
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
provider = 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:

python
123
logger.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.

Authors
Ayooluwa Isaiah
Ayooluwa Isaiah