tiangolo/fastapi · error · ValueError

SSE 'id' must not contain null characters

Error message

SSE 'id' must not contain null characters

What it means

Raised by `_check_id_valid` (fastapi/sse.py:48) as a ValueError when the `id` field of a `ServerSentEvent` contains a null character ('\0'). The SSE spec forbids NUL in event IDs because they break `Last-Event-ID` handling on reconnect; the browser sends the id back as a header where NUL is illegal. This is checked in addition to the single-line rule.

Solutions

  1. Strip NUL bytes: `id = raw_id.replace('\0', '')` before constructing the event.
  2. Encode binary ids as hex/base64 so they contain no NUL: `id = raw_bytes.hex()`.
  3. Validate upstream that ids are printable ASCII/UTF-8 without NUL.

Example fix

// before
yield ServerSentEvent(id=raw_bytes.decode('latin-1'), data=payload)
// after
yield ServerSentEvent(id=raw_bytes.hex(), data=payload)
Defensive patterns

Strategy: validation

Validate before calling

from fastapi.sse import ServerSentEvent

def safe_sse_id(raw) -> str | None:
    if raw is None:
        return None
    s = str(raw).replace('\0', '')
    s = s.replace('\r', '').replace('\n', ' ')
    return s

yield ServerSentEvent(id=safe_sse_id(raw_id), data=payload)

Type guard

def id_has_no_null(value: object) -> bool:
    return isinstance(value, str) and '\0' not in value

Prevention

When it happens

Trigger: Constructing `ServerSentEvent(id='abc\x00def', data=...)`. Building the id from binary/serialized data that contains NUL bytes. Using a database/protobuf value that includes embedded nulls as the SSE id.

Common situations: Feeding binary identifiers (e.g. packed structs, UUIDs from binary sources) into the `id` field. Logging/telemetry payloads mistakenly placed in `id`. Strings from C-interop or NUL-padded buffers.

Related errors


AI-assisted analysis of tiangolo/fastapi@3e8d1526d8 (2026-08-11). Data as JSON: /api/errors/ace7ef806e04f036. Report an issue: GitHub.

Appendix: source

Thrown at fastapi/sse.py:48

    serves mainly as a marker and sets the correct `Content-Type`.
    """

    media_type = "text/event-stream"


def _check_single_line(v: str | None, field_name: str) -> str | None:
    if v is not None and ("\r" in v or "\n" in v):
        raise ValueError(f"SSE '{field_name}' must be a single line")
    return v


def _check_event_single_line(v: str | None) -> str | None:
    return _check_single_line(v, "event")


def _check_id_valid(v: str | None) -> str | None:
    if v is not None and "\0" in v:
        raise ValueError("SSE 'id' must not contain null characters")
    return _check_single_line(v, "id")


class ServerSentEvent(BaseModel):
    """Represents a single Server-Sent Event.

    When `yield`ed from a *path operation function* that uses
    `response_class=EventSourceResponse`, each `ServerSentEvent` is encoded
    into the [SSE wire format](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream)
    (`text/event-stream`).

    If you yield a plain object (dict, Pydantic model, etc.) instead, it is
    automatically JSON-encoded and sent as the `data:` field.

    All `data` values **including plain strings** are JSON-serialized.

    For example, `data="hello"` produces `data: "hello"` on the wire (with
    quotes).

View on GitHub (pinned to 3e8d1526d8)