Defining Custom Log Levels in Python
Python's logging module supports adding levels beyond the standard five, and the facility is used far more often than it should be. A TRACE level below DEBUG is a genuine severity distinction and a reasonable addition; an AUDIT or SECURITY level is a distinction of kind masquerading as severity, and it causes problems for every consumer downstream. This page covers when a custom level earns its place, how to register one properly, and the mapping that keeps it intelligible to syslog, OpenTelemetry and the log store. It is a task article under log levels and severity mapping, part of the Python logging fundamentals and structured data section.
Prerequisites
The standard library is all that is needed.
pip install "python-json-logger>=2.0.7,<4.0.0"
Implementation
Step 1 — Decide whether the distinction is severity or kind. The test is simple: should raising the configured level suppress these records? For extremely verbose diagnostic output, yes — that is severity, and a level is correct. For audit events, security events or business events, no — an operator raising the level to quieten a noisy service must not suppress them. That is kind, and it belongs in a field such as record.class or in a dedicated logger with its own configuration, as in audit logging in Python applications.
Step 2 — Choose a number between existing levels. Level filtering is a numeric comparison, so the number determines behaviour. TRACE at 5 is below DEBUG, so it is off whenever debug is off and on only when explicitly requested. NOTICE at 25 sits between INFO and WARNING for events that are normal but noteworthy. Numbers that collide with standard levels, or that sit outside the zero-to-fifty range, produce surprising filtering.
import logging
TRACE = 5
NOTICE = 25
Step 3 — Register the names. Without registration, a record at level 5 renders as Level 5, which is unreadable in output and unrecognisable to any consumer. addLevelName fixes the rendering and makes the name resolvable in configuration, so "level": "TRACE" works in a dictConfig.
logging.addLevelName(TRACE, "TRACE")
logging.addLevelName(NOTICE, "NOTICE")
Step 4 — Expose the level without patching the Logger class. The common recipe adds a trace method to logging.Logger. That modifies the class for every library in the process, conflicts when two libraries do the same thing differently, and makes behaviour depend on import order. A small module-level helper, or calling log.log(TRACE, …) directly, gives the same ergonomics with no global side effect.
# mylogging.py
import logging
TRACE = 5
logging.addLevelName(TRACE, "TRACE")
def trace(logger: logging.Logger, msg: str, *args, **kwargs) -> None:
if logger.isEnabledFor(TRACE):
logger._log(TRACE, msg, args, **kwargs)
# usage
log = logging.getLogger("wire")
trace(log, "frame received %d bytes", len(frame))
Step 5 — Map the level for every downstream consumer. Syslog has its own severity numbers, OpenTelemetry has a severity scale from 1 to 24, and a log store's dashboards expect a known set of names. A custom level unmapped at any of these boundaries arrives as something unrecognised. The formatter is the place to translate, and the mapping should be explicit for every custom level. The standard-level mappings are covered in mapping Python log levels to syslog.
# Python level -> (OpenTelemetry severity number, OpenTelemetry severity text, syslog severity)
SEVERITY = {
TRACE: (1, "TRACE", 7), # debug-level in syslog; there is no lower
logging.DEBUG: (5, "DEBUG", 7),
logging.INFO: (9, "INFO", 6),
NOTICE: (10, "INFO2", 5), # syslog "notice" exists and fits exactly
logging.WARNING: (13, "WARN", 4),
logging.ERROR: (17, "ERROR", 3),
logging.CRITICAL: (21, "FATAL", 2),
}
class SeverityMappingFormatter(jsonlogger.JsonFormatter):
def add_fields(self, target, record, message_dict):
super().add_fields(target, record, message_dict)
number, text, syslog = SEVERITY.get(record.levelno, (9, "INFO", 6))
target["severity_number"] = number
target["severity_text"] = text
target["level"] = record.levelname
Expected Output: a custom level that every consumer can interpret.
{"message": "frame received 1482 bytes", "level": "TRACE", "severity_number": 1, "severity_text": "TRACE"}
Why most proposed custom levels should be fields
Requests for custom levels usually come from a real need expressed in the wrong mechanism, and it helps to recognise the common ones.
"We need an AUDIT level." The need is for audit records to be separately routable, never suppressed and retained differently. A level achieves none of those: it is filtered by the same threshold as everything else and routed through the same handlers. A dedicated logger with propagation disabled achieves all three.
"We need a SECURITY level." The need is for security-relevant records to be findable and alertable. Their severity varies — a failed login is a warning, a detected intrusion is critical — so collapsing them into one level loses the severity information that should drive alert priority. A field such as record.class: security alongside the real severity preserves both.
"We need a BUSINESS or METRIC level." The need is for business events to be countable. These are informational in severity and distinct in kind; an event field with a bounded vocabulary makes them queryable, and a counter metric usually serves the counting need better than logs at all.
"We need a SUCCESS level." The need is usually for positive outcomes to stand out when reading logs. That is a presentation concern and an outcome field — outcome: success — serves it without inventing a severity for good news.
The pattern is consistent: when a proposed level describes what a record is about rather than how urgently it needs attention, it belongs in a field. The genuine severity extensions — TRACE, occasionally NOTICE — are few, and they extend the ladder rather than bending it.
Custom levels across libraries and the fleet
A custom level defined in one service is a local convention, and two problems appear as soon as it spreads beyond that service.
The first is collision. Two libraries in the same process that each register a level at number 5 with different names — TRACE in one, VERBOSE in another — silently overwrite each other, because the level-name registry is global. Whichever registers last wins, and records from the other library are rendered with the wrong name. Libraries should therefore not register custom levels at all; a library that wants extremely verbose output should log at DEBUG under a dedicated child logger, which an application can enable or silence precisely. Custom levels are an application decision, made once, in the application's own logging setup.
The second is inconsistency across a fleet. If some services use TRACE at 5, others at 1, and others do not use it at all, a dashboard or alert rule that filters by severity behaves differently per service. Standardising the set of custom levels — ideally none, or TRACE alone — in a shared logging package, together with its severity mapping, keeps the meaning of a level consistent everywhere it appears. The same shared package is the natural home for the schema conventions in designing a log schema for a service fleet.
There is also a cost to consider on the reading side. Every additional level is one more value engineers must understand when filtering, one more entry in every dashboard's severity breakdown, and one more mapping to keep correct. The standard five cover the overwhelming majority of needs, and each addition should clear a higher bar than "it would be convenient".
Configuration options
| Level | Number | Syslog | OpenTelemetry | Justified when |
|---|---|---|---|---|
TRACE |
5 | 7 debug | 1 TRACE | output too verbose even for debug |
DEBUG |
10 | 7 debug | 5 DEBUG | standard |
INFO |
20 | 6 info | 9 INFO | standard |
NOTICE |
25 | 5 notice | 10 INFO2 | normal but noteworthy, if the team will use it |
WARNING |
30 | 4 warning | 13 WARN | standard |
ERROR |
40 | 3 err | 17 ERROR | standard |
CRITICAL |
50 | 2 crit | 21 FATAL | standard |
Verification
Confirm the level filters correctly and renders by name.
import io, logging
stream = io.StringIO()
h = logging.StreamHandler(stream); h.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
log = logging.getLogger("wire"); log.handlers = [h]; log.propagate = False
log.setLevel(logging.DEBUG); trace(log, "hidden at DEBUG")
log.setLevel(TRACE); trace(log, "visible at TRACE")
print(stream.getvalue().strip())
Expected Output: only the second record, rendered by name.
TRACE visible at TRACE
Common mistakes
A level for a kind of record. Error signature: audit or security events suppressed when an operator raises the level. Root cause: kind modelled as severity. Remediation: a field or a dedicated logger.
Patching logging.Logger. Error signature: a method that exists or not depending on import order, or behaves differently between libraries. Root cause: a global class modification. Remediation: a helper function or log.log(level, …).
No addLevelName. Error signature: Level 5 in output. Root cause: the number never registered with a name. Remediation: register every custom level at import.
Unmapped downstream. Error signature: custom-level records in an unknown severity bucket in the store. Root cause: no translation in the formatter. Remediation: an explicit severity mapping per level.
Numbers that collide. Error signature: a custom level filtered identically to a standard one. Root cause: reusing 10, 20, 30, 40 or 50. Remediation: choose a number strictly between standard levels.
Frequently Asked Questions
When is a custom log level justified?
When the distinction is genuinely about severity and none of the standard five fits — most commonly a TRACE level below DEBUG for extremely verbose output that should be off even in debug builds. Distinctions about the kind of record, such as audit or security, belong in fields or dedicated loggers.
What numbers do the standard levels use?
DEBUG is 10, INFO 20, WARNING 30, ERROR 40 and CRITICAL 50. A custom level should sit between two of these so ordinary level comparisons treat it sensibly, for example 5 for TRACE or 25 for NOTICE.
Why not add a method like logger.trace to the Logger class?
Patching logging.Logger changes the class for every library in the process. It works, and it creates a global side effect, conflicts if two libraries define the same method differently, and makes the codebase depend on import order. A small helper function or calling log with the level number avoids all of that.
How do custom levels appear in a log store?
However the formatter renders them. Without an explicit mapping, a store expecting standard severity names receives Level 5 or an unfamiliar string, which dashboards and alert rules do not recognise. The mapping to standard severities has to be written deliberately.