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.

Python levels mapped onto syslog severities Five Python levels on the left connect to five of the eight syslog severities on the right: CRITICAL to 2 critical, ERROR to 3 error, WARNING to 4 warning, INFO to 6 info and DEBUG to 7 debug. Severities 0 emergency, 1 alert and 5 notice are marked unused because stock Python logging never produces them. A dashed arrow shows that a custom or unnamed level, absent from priority_map, falls back to severity 4 warning. Python level Syslog severity CRITICAL ERROR WARNING custom / unnamed INFO DEBUG 0 emergency 1 alert 2 critical 3 error 4 warning 5 notice 6 info 7 debug unused unused unused not in priority_map
Python's five levels reach only five of syslog's eight severities; emergency, alert and notice have no default Python source, and anything not in 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
Where the SysLogHandler address points A Python application using SysLogHandler writes to the /dev/log Unix socket, where an on-host rsyslog or syslog-ng daemon receives it and forwards it to a central log store over TLS. A dashed alternative path runs straight from the application to the central store over UDP or TCP port 514, bypassing the on-host daemon and its buffer. Two ways to reach a syslog daemon Python app SysLogHandler rsyslog / syslog-ng on the same host central store RFC 5424, TLS /dev/log forwards direct UDP or TCP to port 514 — no on-host buffer address="/dev/log" or ("10.0.0.9", 514)
Prefer the local socket and let the on-host daemon own forwarding; a direct network address puts every network blip on the application's own logging path.

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.

Inside SysLogHandler.emit() A log record's levelname enters mapPriority, which is a priority_map dictionary lookup with the default "warning"; a missing key silently yields severity 4. The resulting severity name goes to encodePriority together with the handler's fixed facility, computing facility shifted left three places OR severity — local0 gives 16 times 8 plus 3 equals 131. That number becomes the angle-bracketed prefix placed ahead of the ident and the formatted message, which is then written to the socket synchronously. Inside SysLogHandler.emit() record.levelname "ERROR" mapPriority(levelname) priority_map.get(name, "warning") encodePriority(facility, sev) 16 << 3 | 3 = 131 <131> + ident + message written to the socket, synchronously the lookup key is the level NAME an unnamed level arrives as "Level 25" miss → "warning", severity 4 no exception, no diagnostic facility is fixed per handler severity varies per record RFC 3164 framing, not RFC 5424 no timestamp or hostname added
Every record walks the same four steps; only step two can quietly change meaning, because a level name absent from 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:

How facility and severity pack into one priority value Eight bits are drawn as cells reading 1 0 0 0 0 1 0 0, with place values 128, 64, 32, 16, 8, 4, 2, 1 underneath. The leading five bits are the facility field holding local0, value 16, shifted three places left so it lands on the 128 column. The trailing three bits are the severity field holding warning, value 4. Together they make 128 plus 4 equals 132, the number that appears in angle brackets at the start of the message. PRI = facility × 8 + severity facility — 5 bits severity — 3 bits 1 0 0 0 0 1 0 0 128 64 32 16 8 4 2 1 10000 = local0 (16), shifted left 3 100 = warning (4) 128 + 4 = 132 <132> billing: WARNING …
The priority is one number: the facility occupies the top five bits, the severity the bottom three, so 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_LOCAL0LOG_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()
Anatomy of one captured datagram A single datagram is drawn as four adjoining segments. The first holds the angle-bracketed priority 131, which is facility local0 times 8 plus severity error. The second is the handler's ident string, prepended verbatim. The third is whatever the Formatter produced. The fourth is the NUL byte appended because append_nul defaults to True, which TCP receivers often reject. one datagram, exactly as it left the socket <131> billing: ERROR probe error NUL priority 16 × 8 + 3 handler.ident prepended verbatim Formatter output %(levelname)s %(message)s append_nul set False on TCP RFC 3164 only — no version digit, timestamp, hostname or structured data
Read the bytes, not the daemon's rendering: the priority prefix is the only part that carries severity, and everything after it is your formatter's text.

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

Debugging a severity that does not match the level Starting from a wire severity that differs from the level logged, three checks run in order. First, is the level name a key in handler.priority_map — if not, mapPriority returned warning and the fix is to add the entry. Second, does the record's name look like "Level 25" — if so the level was never registered and the fix is addLevelName. Third, is the prefix 128 plus the severity — if not, the handler is on the wrong facility and the fix is to pass LOG_LOCAL0. If all three pass, the mapping is correct and the daemon's own rewrite rules are the next place to look. The severity on the wire is not the level I logged Is the level name a key in handler.priority_map? Does the record's name read "Level 25"? Is the prefix 128 + severity for local0? Mapping is correct check the daemon's own rewrite rules yes no yes no yes no mapPriority() returned "warning" (4) handler.priority_map["SECURITY"] = "alert" the level was never named logging.addLevelName(60, "SECURITY") the handler is on another facility facility=SysLogHandler.LOG_LOCAL0
Three checks, in order: the map entry, the registered level name, then the facility — each one produces a different wrong number on the wire.
  • Error signature: alerting rules that watch for syslog emergency never fire, even during outages that produced plenty of CRITICAL records. Root cause: Python's highest stock level maps to severity 2 (critical), not 0 (emergency); stock logging cannot produce severity 0 or 1 at all. Remediation: register a dedicated high level with addLevelName and map it to emergency or alert in priority_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 warning stream, and severity-based filters either miss them or over-collect them. Root cause: mapPriority returns warning for any name absent from priority_map, silently and without an exception. Remediation: every addLevelName call needs a matching priority_map entry; add a unit test that asserts handler.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_STREAM for 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_nul defaults to True and appends a NUL byte, which many TCP-mode daemons treat as message content rather than a terminator. Remediation: set handler.append_nul = False and let newline framing delimit records, checking the receiving daemon's expected framing before changing it.

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.