Mapping Python Log Levels to Syslog Severity
Python has five log levels while syslog has eight severities, and forwarding log records to a syslog daemon means deciding what each level becomes on the wire. This page is for backend engineers and SREs who ship application logs into rsyslog, syslog-ng or a journald-backed collector and need CRITICAL to reach the paging path while DEBUG stays out of it. It sits within the log levels and severity mapping reference, part of the Python Logging Fundamentals and Structured Data guide, and covers one narrow mechanism — SysLogHandler.priority_map — plus the facility arithmetic that turns a mapped severity into the numeric prefix a daemon actually parses.
priority_map silently lands on warning.Prerequisites
SysLogHandler ships in the standard library, so there is nothing to install and no third-party formatter is required. The only pin that matters is the interpreter itself, because the handler's reconnect behaviour on stream sockets changed in Python 3.11:
# pyproject.toml — SysLogHandler is standard library; only the interpreter is pinned.
[project]
requires-python = ">=3.9,<3.14"
dependencies = [] # no third-party logging package needed for syslog output
You also need a reachable syslog endpoint. On Linux the local daemon usually listens on the /dev/log Unix socket; remote daemons accept UDP or TCP on port 514. Keep the address and facility in the environment so the same container image can point at a local socket in production and a throwaway listener in development:
# Confirm a local syslog socket exists before wiring the handler.
test -S /dev/log && echo "local syslog socket present"
export SYSLOG_ADDRESS="/dev/log" # or "10.0.0.9:514" for a remote relay
export SYSLOG_FACILITY="local0" # local0..local7 are reserved for site use
Expected Output:
local syslog socket present
If you assemble handlers declaratively — the approach described in configuring logging with dictConfig — everything below applies unchanged; priority_map is an attribute of the constructed handler object, so you reach it after dictConfig has run rather than inside the dictionary.
Implementation
The translation from Python level to syslog severity is performed by SysLogHandler.priority_map, a dictionary keyed by level name. Understanding and, where needed, extending that map is the whole task. Getting it right is not cosmetic: operations tooling filters, routes and pages on syslog severity, not on Python level names, so a record that arrives with the wrong severity is either invisible to an alert that should have fired or noisy in a dashboard that should have stayed quiet. The level your code chooses and the severity the daemon sees must agree, and that agreement is defined entirely by priority_map.
priority_map resolves to warning with no error of any kind.Step 1 — Read the default map before assuming anything. SysLogHandler carries two class-level tables: priority_names, which maps all eight syslog severity names (plus the emerg, panic, crit, err and warn aliases) to their numeric codes, and priority_map, which maps Python level names to those severity names. Print them once rather than trusting memory:
import logging.handlers as handlers
# The level-name -> severity-name table the handler consults for every record.
print(handlers.SysLogHandler.priority_map)
print(handlers.SysLogHandler.priority_names["notice"])
Expected Output:
{'DEBUG': 'debug', 'INFO': 'info', 'WARNING': 'warning', 'ERROR': 'error', 'CRITICAL': 'critical'}
5
Five entries, eight severities. mapPriority is a single dictionary lookup with a default — self.priority_map.get(levelName, "warning") — so any level name that is not one of those five resolves to warning, severity 4. The asymmetry exists because the two scales were designed for different audiences: Python's levels target application authors, while syslog's severities span machine-level emergencies down to verbose debugging. Three severities — emergency (0), alert (1) and notice (5) — therefore stay unused unless you register custom levels and map them deliberately.
Step 2 — Create the handler with an explicit facility. The default facility is LOG_USER (1), which mixes your application in with generic user-level messages from everything else on the host. Choose a facility that identifies your application instead; local0 through local7 are reserved for exactly this. Pass address as a tuple for network targets or a path string for the local socket:
import logging
import logging.handlers
# local0 (facility 16) keeps app logs separable from kernel, mail, auth and cron.
handler = logging.handlers.SysLogHandler(
address="/dev/log",
facility=logging.handlers.SysLogHandler.LOG_LOCAL0,
)
handler.ident = "billing: " # prepended before the formatted message
handler.setFormatter(logging.Formatter("%(name)s: %(levelname)s %(message)s"))
log = logging.getLogger("billing")
log.setLevel(logging.DEBUG)
log.addHandler(handler)
log.warning("retrying charge for invoice 5512")
The facility is fixed per handler instance, so every record from this logger shares it and only the severity varies per message. That split is what lets a daemon route all of an application's output to a dedicated file by facility while alerting rules select on severity independently. If you want the same records to also leave the process as JSON for an aggregator, add a second handler with a structured formatter — see structured logging with the Python standard library — rather than trying to make one syslog line serve both consumers.
Step 3 — Confirm the priority arithmetic. The handler computes the wire priority as encodePriority(facility, severity), which is facility << 3 | severity, i.e. facility times eight plus severity. With local0 (16) and WARNING (4) the value is 16 * 8 + 4 = 132. Check the two calls directly instead of inferring the result from a log file:
print(handler.mapPriority("WARNING")) # -> 'warning'
print(handler.mapPriority("TRACE")) # -> 'warning' (unmapped fallback!)
print(handler.encodePriority(
handler.facility,
handler.mapPriority("CRITICAL"),
)) # -> 130 (16*8 + 2)
Expected Output:
warning
warning
130
The second line is the one worth staring at: an unknown level name produces exactly the same severity as a genuine warning, with no exception, no log message and nothing in the output to distinguish the two.
Step 4 — Extend the map for custom levels. If you register a level such as TRACE below DEBUG, or a SECURITY level above CRITICAL, add a priority_map entry for it. Choose the target severity by intent rather than by numeric proximity: TRACE is more verbose than DEBUG, but since syslog has nothing below debug, mapping it to debug is correct. A level meant to wake an on-call engineer should map to alert (1) or emergency (0) — severities stock Python logging will never otherwise produce.
import logging
TRACE = 5
logging.addLevelName(TRACE, "TRACE")
SECURITY = 60
logging.addLevelName(SECURITY, "SECURITY")
# Entries are keyed by level NAME, and the value must exist in priority_names.
handler.priority_map["TRACE"] = "debug" # severity 7
handler.priority_map["SECURITY"] = "alert" # severity 1 — pages on-call
Two details bite here. First, priority_map is a class attribute, so mutating handler.priority_map[...] without first copying it changes the map for every SysLogHandler in the process; that is usually what you want, but assign handler.priority_map = dict(handler.priority_map) first if one handler needs a different mapping from another. Second, the key is the level name as it appears on the record, so a numeric level that was never passed to addLevelName arrives as "Level 25" and falls straight through to warning no matter what you put in the map.
Step 5 — Keep the handler off the request path. SysLogHandler writes synchronously inside emit, so a blocked Unix socket or a TCP peer applying back-pressure stalls whichever thread logged. For anything latency-sensitive, put the syslog handler behind a queue as described in non-blocking logging with QueueHandler; the level-to-severity mapping happens in the listener thread and behaves identically. The wider trade-offs between sink types are covered in the handler architecture reference.
Configuration options
Two tables cover almost every decision. The first is the mapping itself — Python's numeric level, the severity name the default map produces, and the syslog code that ends up in the priority byte:
local0 plus warning reads <132> and every local0 record starts at 128.| Python level | Numeric | Syslog severity | Syslog code |
|---|---|---|---|
| CRITICAL | 50 | critical | 2 |
| ERROR | 40 | error | 3 |
| WARNING | 30 | warning | 4 |
| INFO | 20 | info | 6 |
| DEBUG | 10 | debug | 7 |
| unmapped / custom | any | warning (fallback) | 4 |
Severities 0 (emergency), 1 (alert) and 5 (notice) have no Python source by default. If your operations team alerts on emergency, reserve it for a deliberate custom level rather than remapping CRITICAL onto it — CRITICAL already carries a meaning at severity 2, and moving it makes every ordinary application error indistinguishable from a host-is-dying event.
The second table covers the constructor arguments that actually change behaviour in production:
| Parameter | Value | Default | Production note |
|---|---|---|---|
address |
"/dev/log" or ("10.0.0.9", 514) |
("localhost", 514) |
Prefer the local socket and let the daemon forward; it survives network blips. |
facility |
SysLogHandler.LOG_LOCAL0 … LOG_LOCAL7 |
LOG_USER (1) |
Pick one facility per application so daemon-side routing rules stay simple. |
socktype |
socket.SOCK_DGRAM or socket.SOCK_STREAM |
datagram for tuples | Use SOCK_STREAM for records you cannot afford to lose; expect back-pressure. |
ident |
"billing: " |
"" |
Prepended before the formatted message, ahead of any formatter output. |
append_nul |
True / False |
True |
Set False for daemons that expect newline-delimited framing on TCP. |
priority_map |
dict[str, str] |
five entries | Extend once per custom level; values must be keys of priority_names. |
UDP is the cheapest option and drops messages silently under load, which is acceptable for high-volume, non-critical output. TCP gives reliable delivery but the socket write can block, which is exactly why Step 5 matters. When the syslog path crosses an untrusted network, terminate TLS at a local relay rather than trying to wrap the handler's socket yourself.
Verification
Do not verify by reading /var/log/syslog and trusting that the line looks right — the daemon has already re-rendered the message by then. Read the raw datagram instead. Point the handler at a throwaway UDP listener and print exactly what arrives:
import socketserver
class Probe(socketserver.BaseRequestHandler):
def handle(self):
# Strip the NUL byte SysLogHandler appends by default.
print(self.request[0].decode().rstrip("\x00"))
with socketserver.UDPServer(("127.0.0.1", 5140), Probe) as srv:
srv.serve_forever()
With a second handler aimed at ("127.0.0.1", 5140) and the local0 facility, emit one record at each level and compare the prefixes:
for level in ("debug", "info", "warning", "error", "critical"):
getattr(log, level)(f"probe {level}")
Expected Output (as received on the socket):
<135> billing: DEBUG probe debug
<134> billing: INFO probe info
<132> billing: WARNING probe warning
<131> billing: ERROR probe error
<130> billing: CRITICAL probe critical
Every prefix is 128 + severity, because local0 contributes 16 * 8 = 128. If you see <132> for a record you logged at CRITICAL, the level name never reached priority_map; if you see <14> instead of <134>, the handler is still on the default LOG_USER facility. A useful assertion in CI is to decode the prefix and check priority % 8 against the expected severity code, which catches a regression the moment someone adds a custom level without updating the map.
SysLogHandler emits a traditional BSD-style RFC 3164 message: the priority prefix, the optional ident, then whatever your formatter produced. If your receiver enforces strict RFC 5424 framing it will reject or mangle this, because the handler generates no version digit, ISO 8601 timestamp, hostname, app-name or structured-data section. The standard production answer is to let Python speak the simple format to a local rsyslog or syslog-ng instance and let that daemon re-frame to RFC 5424 before forwarding upstream over TLS. This keeps application code minimal and centralises transport reliability, encryption and log rotation in the daemon. The priority rules are unchanged either way: the <131>-style value is still facility times eight plus severity, and only the surrounding header differs.
Common mistakes
-
Error signature: alerting rules that watch for syslog emergency never fire, even during outages that produced plenty of
CRITICALrecords. Root cause: Python's highest stock level maps to severity 2 (critical), not 0 (emergency); stockloggingcannot produce severity 0 or 1 at all. Remediation: register a dedicated high level withaddLevelNameand map it toemergencyoralertinpriority_map, and reserve it for conditions that genuinely warrant a page — the same level-discipline argument made in how to configure Python logging for production. -
Error signature: records logged at a custom level appear in the daemon's
warningstream, and severity-based filters either miss them or over-collect them. Root cause:mapPriorityreturnswarningfor any name absent frompriority_map, silently and without an exception. Remediation: everyaddLevelNamecall needs a matchingpriority_mapentry; add a unit test that assertshandler.mapPriority(name)for each custom level rather than discovering the fallback during an incident. -
Error signature: audit or transaction logs show gaps that correlate with traffic spikes, while the application reports no logging errors whatsoever. Root cause: the default datagram transport drops messages under pressure and surfaces nothing to the caller — a UDP send that goes nowhere still returns successfully. Remediation: construct the handler with
socktype=socket.SOCK_STREAMfor records that must not be lost, and keep the slower socket off request threads by feeding it from a queue listener. -
Error signature: the daemon logs a parse error, or the message body carries a visible trailing
^@, on TCP connections only. Root cause:append_nuldefaults toTrueand appends a NUL byte, which many TCP-mode daemons treat as message content rather than a terminator. Remediation: sethandler.append_nul = Falseand let newline framing delimit records, checking the receiving daemon's expected framing before changing it.
Related
- Log levels and severity mapping — the parent reference on how levels drive filtering and routing decisions across a logging pipeline.
- How to configure Python logging for production — the full handler graph this syslog handler slots into, including runtime level control.
- Configuring logging with dictConfig — declaring the same handler without hand-wiring it in code.
- Non-blocking logging with QueueHandler — keeping a synchronous socket write off the request path.
- Structured logging with the Python standard library — emitting JSON for an aggregator alongside the plain syslog line.
Frequently Asked Questions
Why do my custom Python levels arrive with syslog severity warning?
SysLogHandler.mapPriority does a dictionary lookup on the record's level name and returns 'warning' for anything it does not find. A custom level registered with addLevelName, or a numeric level with no registered name at all, is absent from priority_map, so the handler falls back to warning (severity 4) rather than the severity you expected. Add the level name to handler.priority_map to fix it.
Does Python's WARNING map to syslog WARNING?
Yes. Python WARNING maps to syslog severity 4 (warning). The mismatch people hit is at the top: Python CRITICAL maps to syslog severity 2 (critical), not 0 (emergency), because Python has no emergency or alert equivalent.
Should I use UDP or TCP for SysLogHandler?
UDP is the default for network addresses and is lossy under pressure, which is acceptable for high-volume non-critical logs. Pass socktype=socket.SOCK_STREAM when you cannot tolerate dropped records, and terminate a TLS-wrapped TCP connection at a local relay when the syslog path crosses an untrusted network.
What is the difference between facility and severity?
Severity describes how urgent a single message is, from 0 emergency to 7 debug, and varies per record. Facility describes which subsystem produced it, such as local0 to local7 for application use, and is fixed per handler instance. The two combine into the syslog priority value as facility times eight plus severity.
Does SysLogHandler emit RFC 5424 messages?
No. It writes a traditional BSD-style RFC 3164 message: a numeric priority prefix followed by whatever your formatter produced. It does not generate the RFC 5424 version digit, ISO 8601 timestamp, hostname, app-name or structured-data fields. The usual pattern is to let a local rsyslog or syslog-ng instance re-frame messages as RFC 5424 before forwarding them upstream.