pypa/pip · error · IDNAError

A-label must not end with a hyphen

Error message

A-label must not end with a hyphen

What it means

Raised by ulabel() (core.py:441) when the bytes after the 'xn--' prefix end with a hyphen ('-'). Punycode labels are defined so the literal hyphens separate the basic ASCII codepoints from the extensions; a trailing hyphen is not valid Punycode syntax, so the A-label is rejected as malformed before decode is even attempted.

Source

Thrown at src/pip/_vendor/idna/core.py:441

    """
    if len(label) > _max_input_length:
        raise IDNAError("Label too long")
    if not isinstance(label, (bytes, bytearray)):
        try:
            label_bytes = label.encode("ascii")
        except UnicodeEncodeError:
            check_label(label)
            return label
    else:
        label_bytes = bytes(label)

    label_bytes = label_bytes.lower()
    if label_bytes.startswith(_alabel_prefix):
        label_bytes = label_bytes[len(_alabel_prefix) :]
        if not label_bytes:
            raise IDNAError("Malformed A-label, no Punycode eligible content found")
        if label_bytes.endswith(b"-"):
            raise IDNAError("A-label must not end with a hyphen")
    else:
        check_label(label_bytes)
        return label_bytes.decode("ascii")

    try:
        label = label_bytes.decode("punycode")
    except UnicodeError as err:
        raise IDNAError("Invalid A-label") from err
    check_label(label)
    return label


def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False) -> str:
    """Apply the UTS #46 character mapping to a domain string.

    Implements the mapping table from `UTS #46 §4
    <https://www.unicode.org/reports/tr46/>`_: each character is kept,
    replaced, or rejected based on its status (``V``, ``M``, ``D``, ``3``,

View on GitHub (pinned to d7d0d0a394)

Solutions

  1. Strip a stray trailing hyphen from the Punycode portion if it is clearly a typo, or regenerate the A-label with idna.alabel() from the source Unicode.
  2. Validate the A-label shape (matches ^xn--[A-Za-z0-9-]*[A-Za-z0-9]$) before calling ulabel/decode.
  3. Use decode(domain, display=True) to pass malformed ACE labels through unchanged for display-only consumers.

Example fix

// before
idna.ulabel('xn--abc-')  # trailing hyphen in Punycode portion

// after
idna.ulabel('xn--abc')   # no trailing hyphen
# or rebuild correctly:
idna.alabel('münchen')  # -> b'xn--mnchen-3ya'
Defensive patterns

Strategy: validation

Validate before calling

def is_well_formed_alabel(label) -> bool:
    s = label.decode('ascii') if isinstance(label, (bytes, bytearray)) else label
    lower = s.lower()
    if lower.startswith('xn--'):
        payload = lower[4:]
        if not payload or payload.endswith('-'):
            return False
    return True

Type guard

import re
ACE_RE = re.compile(r'^xn--[A-Za-z0-9-]*[A-Za-z0-9]$', re.IGNORECASE)

def is_valid_ace_label(s) -> bool:
    return isinstance(s, str) and bool(ACE_RE.match(s))

Try / catch

import idna

try:
    decoded = idna.ulabel(label)
except idna.IDNAError as err:
    if 'must not end with a hyphen' in str(err):
        decoded = label.rstrip('-')  # or regenerate via alabel
    else:
        raise

Prevention

When it happens

Trigger: Calling idna.ulabel() or idna.decode() with an A-label like 'xn--abc-' where the Punycode portion ends in '-'. Also produced by string manipulation that accidentally appends a hyphen, or by an off-by-one slice of a valid A-label.

Common situations: Templated domains that append a trailing '-' for separator reasons, truncation/slicing bugs, copy-paste of a corrupted IDN, or DNS zone files with a typo'd ACE label.

Related errors


AI-assisted analysis of pypa/pip@d7d0d0a394 (2026-08-04). Data as JSON: /data/errors/4530850e28771aaa.json. Report an issue: GitHub.