Textualize/textual · error · BadIdentifier

{name!r} is an invalid {description}; identifiers must conta

Error message

{name!r} is an invalid {description}; identifiers must contain only letters, numbers, underscores, or hyphens, and must not begin with a number.

What it means

check_identifiers raises BadIdentifier when a widget id, class, or similar identifier fails the regex: only letters, numbers, underscores, hyphens allowed, and it must not start with a number. Used by widget id setters, add_class/remove_class/toggle_class, etc.

Source

Thrown at src/textual/dom.py:99

QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget] | None]"
"""The key used to cache query_one results."""


class BadIdentifier(Exception):
    """Exception raised if you supply a `id` attribute or class name in the wrong format."""


def check_identifiers(description: str, *names: str) -> None:
    """Validate identifier and raise an error if it fails.

    Args:
        description: Description of where identifier is used for error message.
        *names: Identifiers to check.
    """
    match = _re_identifier.fullmatch
    for name in names:
        if match(name) is None:
            raise BadIdentifier(
                f"{name!r} is an invalid {description}; "
                "identifiers must contain only letters, numbers, underscores, or hyphens, and must not begin with a number."
            )


class DOMError(Exception):
    """Base exception class for errors relating to the DOM."""


class NoScreen(DOMError):
    """Raised when the node has no associated screen."""


class _ClassesDescriptor:
    """A descriptor to manage the `classes` property."""

    def __get__(
        self, obj: DOMNode, objtype: type[DOMNode] | None = None

View on GitHub (pinned to 06dbeef4bb)

Solutions

  1. Strip leading '#'/'.': use 'main' not '#main'
  2. Ensure ids/classes match [a-zA-Z_][a-zA-Z0-9_-]*
  3. When generating ids from numbers, prefix a letter, e.g. f'row-{n}'

Example fix

# before
widget.id = "#main"
widget.add_class("1st-row")
# after
widget.id = "main"
widget.add_class("row-1")
Defensive patterns

Strategy: type-guard

Validate before calling

import re
_RE_ID = re.compile(r'[a-zA-Z_][a-zA-Z0-9_-]*')

def safe_id(value: str) -> str:
    value = value.lstrip('#.')
    return value if _RE_ID.fullmatch(value) else f'id-{value}'

Type guard

_RE = re.compile(r'[a-zA-Z_][a-zA-Z0-9_-]*')
def is_valid_identifier(name: str) -> bool:
    return _RE.fullmatch(name) is not None

Try / catch

from textual.dom import BadIdentifier
try:
    widget.add_class(cls)
except BadIdentifier:
    cls = sanitize(cls); widget.add_class(cls)

Prevention

When it happens

Trigger: widget.id = '1header', add_class('foo.bar'), classes like 'my class' (space), ids with '#' prefix accidentally included, or starting with a digit.

Common situations: Prefixing an id with '#' out of CSS habit ('#main'), ids derived from user data or counters starting with digits, class strings containing spaces or dots.

Related errors


AI-assisted analysis of Textualize/textual@06dbeef4bb (2026-08-27). Data as JSON: /api/errors/0b0b8c3a5e085052. Report an issue: GitHub.