Skip to content

[Python][C++] utf8_normalize ignores its form option: NFC and NFKC output is decomposed #51225

Closed

Description

@singhpratech

Describe the bug

pyarrow.compute.utf8_normalize returns decomposed text for every form. With form="NFC" the
output is the NFD form (base letter plus combining mark), whether the input was precomposed or
decomposed. unicodedata.normalize from the standard library gives the expected result.

pyarrow 25.0.1, Python 3.13.9, macOS 26.6 (arm64), wheel from PyPI.

import pyarrow as pa, pyarrow.compute as pc, unicodedata

s = "héllo"                      # precomposed e-acute, U+00E9
a = pa.array([s], pa.string())
for form in ("NFC", "NFD", "NFKC", "NFKD"):
    out = pc.utf8_normalize(a, form=form).to_pylist()[0]
    exp = unicodedata.normalize(form, s)
    print(form, [hex(ord(c)) for c in out], "expected", [hex(ord(c)) for c in exp])

Output:

NFC  ['0x68', '0x65', '0x301', '0x6c', '0x6c', '0x6f'] expected ['0x68', '0xe9', '0x6c', '0x6c', '0x6f']
NFD  ['0x68', '0x65', '0x301', '0x6c', '0x6c', '0x6f'] expected ['0x68', '0x65', '0x301', '0x6c', '0x6c', '0x6f']
NFKC ['0x68', '0x65', '0x301', '0x6c', '0x6c', '0x6f'] expected ['0x68', '0xe9', '0x6c', '0x6c', '0x6f']
NFKD ['0x68', '0x65', '0x301', '0x6c', '0x6c', '0x6f'] expected ['0x68', '0x65', '0x301', '0x6c', '0x6c', '0x6f']

The same happens when the input is already decomposed ("héllo"): NFC and NFKC return it
unchanged instead of composing to U+00E9.

Expected behavior

form="NFC" and form="NFKC" compose, as unicodedata.normalize does; NFD and NFKD are correct
today.

Component(s)

Python, C++

Activity

  1. singhpratech commented on Sep 8, 2026

    @singhpratech
    ContributorAuthor

    Reading the kernel on main (cpp/src/arrow/compute/kernels/scalar_string_utf8.cc, Utf8NormalizeBase):
    MakeDecomposeOptions returns UTF8PROC_COMPOSE for NFC and NFKC, but the only utf8proc call the
    kernel makes is utf8proc_decompose, which decomposes regardless of that flag; composition happens
    in utf8proc_reencode, which is never called. So NFC and NFKC come out as NFD and NFKD. Calling
    utf8proc_reencode on the scratch buffer with the same options after the decompose (as
    utf8proc_map does internally) would give the composed forms.

  2. Santoshkumarpuppala commented on Sep 8, 2026

    @Santoshkumarpuppala

    Confirmed on main (397b3d00). Worth knowing before anyone patches this: the test that covers it cannot fail.

    scalar_string_test.cc:1248 declares json_composed as a literal á, but its bytes are 61 cc 81 — a + U+0301, the decomposed form, not the c3 a1 the comment on :1247 promises. :1249 writes those same three bytes as escapes, so both fixtures are the same string and the compose loop at :1250-1255 is an identity assertion.

    Three consequences:

    1. A correct NFC kernel makes :1251 fail. The patch has to change :1248 to "\xc3\xa1" (escape form, as at :1265) or add a distinct composed fixture.
    2. :1263-1295 is compatibility-axis only — U+00B2 and U+FDFA. Every assertion there passes under the bug, so :1250-1255 is the whole compose coverage.
    3. python/pyarrow/tests/test_compute.py:3822-3827 has the same hole: its only input is U+00B2, whose NFC is itself, so form="NFC" and form="NFD" assert against the same array.

    On the suggested fix — utf8proc_reencode returns a length in bytes and NUL-writes UTF-8 into the buffer, so it replaces the encode loop at :566-575 rather than composing with it, and its documented one-spare-byte precondition isn't met on the regrow path, where :543/:545 make capacity() == res exactly. utf8proc_normalize_utf32 is the drop-in: reencode is that function plus the encoding step, and it leaves the existing loop untouched.

    Happy to open the PR with the fixture fix and a composed-form pyarrow case if nobody has started.

  3. singhpratech commented on Sep 8, 2026

    @singhpratech
    ContributorAuthor

    Thank you for the fixture analysis, that is exactly the trap. I have a branch with the
    utf8proc_normalize_utf32() call, the fixture fix and a pyarrow case ready and will open the PR
    now; happy to have your review on it.

  4. added 2 commits that reference this issue on Sep 9, 2026
  5. added a commit that references this issue on Sep 10, 2026
  6. added this to the 26.0.0 milestone on Sep 10, 2026
  7. pitrou commented on Sep 10, 2026

    @pitrou
    Member

    Issue resolved by pull request 51237
    #51237

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions