python-json-logger
python-json-logger is a set of logging.Formatter classes that make the Python standard library emit one JSON object per log line instead of a formatted string. You keep every logger.info() call you already have; you swap the formatter on your handler and the output becomes machine-readable for CloudWatch, Loki, Datadog, or whatever else parses your container stdout. It picks up any non-standard attribute you attach to a record (through extra=, a logging filter, or by logging a dict instead of a string), and it can rename fields to match your platform (asctime to timestamp, levelname to severity), pin static fields like service and environment onto every line, and swap the encoder for orjson or msgspec when the stdlib json module is too slow. It is a maintained fork: everything from 3.0.0 onward comes from nhairs after the original project stopped releasing.
The lowest-friction way to get JSON logs out of an application that already uses the standard logging module, and the reason it has tens of millions of weekly downloads. If you are designing logging from scratch and want context binding and processors, start with structlog instead.
Use it if
- You already use stdlib logging, directly or through Django, Flask, uvicorn, gunicorn, or Celery, and you only need the output format to change so a log aggregator can index fields
- Your logging is configured with dictConfig or fileConfig in YAML or JSON, and you want a formatter you can name in that config with no code change
- Your platform expects specific key names or a fixed set of tags: rename_fields maps levelname to severity, static_fields stamps service, env and version on every record
- Log volume is high enough that JSON encoding shows up in profiles, and you want the orjson or msgspec formatter with the same configuration surface
- You want structured logging as a real API, with loggers you bind context onto and event-style key-value calls: structlog is built for that, while here every call still looks like logger.info("text", extra={...})
- You want readable colourised logs in development and JSON in production without wiring two handlers and two formatters by hand: loguru and structlog ship that pairing
- You need the extras to be safe: keys passed in extra= that collide with standard LogRecord attribute names (message, args, name, module) raise a KeyError from the standard library, not a warning, and this library cannot protect you from it
- You need active feature development: 266 stars, a single maintainer, and a PyPI status of Mature mean the project is intentionally quiet. It is a small formatter, so that is defensible, but plan on forking rather than waiting if you need something new
- Your observability stack already structures logs for you, for example an OpenTelemetry logging handler or a sidecar agent that parses logfmt. Adding JSON formatting on top just gives the agent a second format to guess at
Setup reality
pip install python-json-logger, no runtime dependencies, Python 3.10 or newer. Two things trip people up. First, the import path moved: new code uses pythonjsonlogger.json.JsonFormatter, while pythonjsonlogger.jsonlogger.JsonFormatter is the legacy path kept for compatibility, and anything pinned below 3.0 is the older unmaintained codebase with different behaviour. Second, output is opt-in: a bare JsonFormatter() emits message plus whatever extras you attach, and nothing else, because every standard LogRecord attribute sits in reserved_attrs by default. You either list the fields you want in fmt or pass reserved_attrs=[] to get all of them. The orjson and msgspec formatters raise MissingPackageError unless you install those packages yourself, and since 4.0 you can no longer pass json_default or json_encoder as dotted strings, so dictConfig users need the ext:// prefix instead.
Patterns
Emit JSON from the root loggerattach-formatter
import logging
from pythonjsonlogger.json import JsonFormatter
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger()
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("service started")
# {"message": "service started"}Import from pythonjsonlogger.json, not pythonjsonlogger.jsonlogger, which is the legacy path. Notice how little is in the output by default: every standard record attribute is reserved until you ask for it.
Pick which record fields appearchoose-fields
from pythonjsonlogger.json import JsonFormatter
# printf style
JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s")
# str.format style
JsonFormatter("{asctime}{levelname}{message}", style="{")
# comma style, specific to this library
JsonFormatter("asctime,levelname,message", style=",")
# plain sequence of field names
JsonFormatter(["asctime", "levelname", "message", "exc_info"])Any LogRecord attribute name works. The comma style and the list form were added in 4.0 and are specific to this package, so do not reuse that fmt string with another Formatter class.
Match your log platform key namesrename-fields
from pythonjsonlogger.json import JsonFormatter
formatter = JsonFormatter(
"%(asctime)s %(levelname)s %(message)s",
rename_fields={"asctime": "timestamp", "levelname": "severity"},
timestamp=True,
)rename_fields only renames keys that are actually emitted; set rename_fields_keep_missing=True if you want the renamed key present as null when the source field is absent. timestamp=True adds a separate UTC timestamp key, or pass a string to name it.
Stamp service metadata on every linestatic-and-default-fields
from pythonjsonlogger.json import JsonFormatter
formatter = JsonFormatter(
static_fields={"service": "checkout-api", "version": "1.4.2"},
defaults={"environment": "dev"},
)
logger.info("boot") # environment=dev
logger.info("boot", extra={"environment": "prod"}) # environment=prodstatic_fields cannot be overridden per call; defaults can. Use static for identity (service name, version) and defaults for values a specific record may want to replace.
Add fields to a single log recordper-call-fields
# via extra=
logger.info("payment captured", extra={"order_id": 991, "amount_cents": 4200})
# or log a dict directly
logger.info({"message": "payment captured", "order_id": 991, "amount_cents": 4200})Keys in extra= that clash with LogRecord attributes (message, args, name, module, levelname) raise KeyError from the standard library. Logging a dict avoids that, but other formatters in the same process will render it as a Python repr.
Include every standard fieldall-record-attributes
from pythonjsonlogger.json import JsonFormatter
formatter = JsonFormatter(reserved_attrs=[])
# or keep the defaults but let two custom names through
from pythonjsonlogger.core import RESERVED_ATTRS
formatter = JsonFormatter(
reserved_attrs=[a for a in RESERVED_ATTRS if a not in ("thread", "process")],
)reserved_attrs is a deny list, so emptying it emits everything including filename, lineno, thread and process. That is verbose and costs bytes per line; usually a short fmt list is the better trade.
Configure through dictConfigdict-config
import logging.config
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": "pythonjsonlogger.json.JsonFormatter",
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
"rename_fields": {"asctime": "timestamp"},
"static_fields": {"service": "checkout-api"},
}
},
"handlers": {
"stdout": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}
},
"root": {"level": "INFO", "handlers": ["stdout"]},
})The "()" key is what lets dictConfig pass keyword arguments to the formatter. Since 4.0 callables such as json_default must be referenced with the ext:// prefix rather than a bare dotted string.
Swap in orjson or msgspecfaster-encoder
# pip install orjson
from pythonjsonlogger.orjson import OrjsonFormatter
handler.setFormatter(OrjsonFormatter("%(asctime)s %(levelname)s %(message)s"))
# pip install msgspec
from pythonjsonlogger.msgspec import MsgspecFormatterNeither encoder is installed for you; importing without the package raises MissingPackageError. They also serialize some types differently from the stdlib formatter (orjson base64-encodes bytes, both render exceptions as "ValueError: message" strings).
Handle objects json cannot encodecustom-serializer
from decimal import Decimal
from pythonjsonlogger.json import JsonFormatter
import pythonjsonlogger.defaults as defaults
def my_default(obj):
if isinstance(obj, Decimal):
return str(obj)
return defaults.json_default(obj)
formatter = JsonFormatter(json_default=my_default)Fall back to the package default rather than raising: it already handles dataclasses, enums, exceptions, tracebacks and types, and a formatter that throws will lose the log line entirely.
Log tracebacks as a list of linesexceptions-as-array
from pythonjsonlogger.json import JsonFormatter
formatter = JsonFormatter(
"%(asctime)s %(levelname)s %(message)s %(exc_info)s",
exc_info_as_array=True,
stack_info_as_array=True,
)
try:
1 / 0
except ZeroDivisionError:
logger.exception("division failed")Without these flags a traceback is one long string full of newlines, which most log viewers render badly. As an array each frame stays a separate element.
Attach a request ID to every recordrequest-id-filter
import logging
from contextvars import ContextVar
request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
class RequestIdFilter(logging.Filter):
def filter(self, record):
record.request_id = request_id.get()
return True
handler.addFilter(RequestIdFilter())
logger.info("handling request") # {"message": ..., "request_id": "..."}A filter is better than repeating extra= at every call site, and it works for logs emitted by libraries you do not control. Any attribute set on the record shows up in the JSON automatically.
Rewrite the whole record before it is encodedreshape-output
from pythonjsonlogger.json import JsonFormatter
class NestedFormatter(JsonFormatter):
def process_log_record(self, log_data):
return {
"ts": log_data.pop("asctime", None),
"level": log_data.pop("levelname", None),
"msg": log_data.pop("message", None),
"attrs": log_data,
}
handler.setFormatter(NestedFormatter("%(asctime)s %(levelname)s %(message)s"))process_log_record is the supported hook for reshaping, including nesting fields under a parent key. Note that 4.0 renamed the log_record arguments on the base class hooks, so subclasses written against 3.x may need signature updates.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| structlog | PyPI | You want context binding, event dicts and processor pipelines rather than a formatter bolted onto stdlib logging. |
| loguru | PyPI | You want one opinionated logger with pretty console output, rotation and JSON serialization built in, and you control the whole application. |
| ecs-logging | PyPI | You ship to Elasticsearch and want output that already matches the Elastic Common Schema without hand-mapping field names. |