All findings · Lucene finding · LFV-1171 · fuzzy-query-binary-term-uses-hex-display-string

FuzzyQuery on a term whose bytes are not valid UTF-8 fuzzes the hex string "[ff 61 62 63]" and misses the term itself

A FuzzyQuery on a binary term, such as the id bytes ff 61 62 63, builds its automaton from the hex display string "[ff 61 62 63]", so with maxEdits 1 or 2 it misses the identical term that maxEdits = 0 finds. Silent, in every release since 4.0.0.

Summary

Priority
Low, rank 267FuzzyQuery with maxEdits of 1 or more silently misses the identical term, matching only terms near the hex rendering of the bytes, whenever the pattern bytes are not valid UTF-8, on default settings and in every release since 4.0.0. It needs a fuzzy query over binary keyword values such as raw ids or hashes (or a niche query-parser path over a collation-key field), which is unusual, so it is Low, though high in Low because the query is core and released everywhere.
Impact
minor
Since
4.0.0 (2012; LUCENE-2514, 2010); the hex form since 4.3.0introduced 2010-07-05 · over 16 years
Affected
4.0.0 to 10.6.0 (7 releases run, all fail) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of FuzzyTermsEnum's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

FuzzyQuery with maxEdits 1 or 2 takes its pattern from Term.text(), which for bytes that are not UTF-8 is a printable hex string, not the term.

Diagram of the bug: the pattern bytes ff 61 62 63 of one indexed document. With maxEdits = 0 the query compares the bytes and finds the document (1 hit). With maxEdits = 1 the bytes first become Term.text(), the 13-character display string "[ff 61 62 63]", and the fuzzy automaton built from that string accepts texts within one edit of it but never the raw bytes, so the query finds 0 documents, with no error.
Why FuzzyQuery(term, 1) misses the identical binary term: its automaton is built from Term.text(), the 13-character display string [ff 61 62 63], not from the 4 bytes ff 61 62 63. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index one document whose id is the 4 raw bytes ff 61 62 63, with new StringField("id", bytes, Store.NO): a supported binary keyword value, such as a raw id or hash.
  2. You search new FuzzyQuery(new Term("id", bytes), 1) and expect 1 hit: the identical term is 0 edits away, and FuzzyQuery(term, 0) and TermQuery both count 1.
  3. FuzzyTermsEnum builds its automaton from Term.text(). The byte 0xff never occurs in UTF-8, so text() falls back to the hex display string "[ff 61 62 63]", 13 characters.
  4. You get 0 hits, with no error, assertions on or off. The automaton matches terms within 1 edit of that 13-character text instead: a document holding the text [ff 61 62 63] is found.

Who is affected

Applications that run fuzzy queries over binary keyword values (raw ids, hashes) with new Term(field, BytesRef) or the public FuzzyTermsEnum, on default settings. One niche parser path: classic QueryParser over a CollationKeyAnalyzer field, where 東京都~1 returns nothing. Patterns built from a String are never affected. Rare: no report found since 2012.

Mechanism, terms and numbers415 words

FuzzyQuery (with maxEdits 1 or 2) and the public FuzzyTermsEnum build their Levenshtein automaton from Term.text() (FuzzyTermsEnum.java:101 and :132 at 5adff78bb168). For a term whose bytes are not valid UTF-8, which new Term(field, BytesRef) accepts and StringField(String, BytesRef, Store) indexes, Term.text() does not return the term: it falls back to BytesRef.toString(), a human-readable hex rendering such as "[ff 61 62 63]" (13 characters for 4 bytes). The automaton accepts strings within maxEdits of that rendering and can never accept the raw bytes. With one document holding the bytes ff 61 62 63, new FuzzyQuery(new Term("id", bytes), 1) counts 0 hits while FuzzyQuery(term, 0) and TermQuery count 1; documents holding the text [ff 61 62 63] are matched instead. No exception, with assertions on and off. Every release since 4.0.0 has it.

The mechanism (main 5adff78bb168, under lucene/core/src/java/org/apache/lucene/)

  1. Two paths. FuzzyQuery.getTermsEnum (search/FuzzyQuery.java:204-209) uses new SingleTermsEnum(terms.iterator(), term.bytes()) for maxEdits == 0 (:205-206), the bytes, which is correct; otherwise it creates a FuzzyTermsEnum (:208).
  2. The pattern is the text. Both FuzzyTermsEnum constructors, the public one (search/FuzzyTermsEnum.java:101) and the one FuzzyQuery uses (:132), supply new FuzzyAutomatonBuilder(term.text(), maxEdits, prefixLength, transpositions), the fix site. FuzzyQuery.getAutomata() (FuzzyQuery.java:172), used by visit() (:197-201), does the same.
  3. The text of a binary term is its display string. Term.text() is toString(bytes) (index/Term.java:100-102): a strict UTF-8 decode that, on failure, returns BytesRef.toString() (:108-122), documented as "human-readable form of the term text. If the term is not unicode, the raw bytes will be printed instead." (:104-107). BytesRef.toString() is [ + hex bytes separated by spaces + ] (util/BytesRef.java:143-157).
  4. The raw term is unreachable. FuzzyAutomatonBuilder takes the code points of [ff 61 62 63] (search/FuzzyAutomatonBuilder.java:50) and builds a Unicode automaton compiled to UTF-8 bytes; it can never accept a 0xff byte. The independent reproducer's diagnostic on main: "automaton accepts raw bytes: false", "automaton accepts display string: true".

So the results of a FuzzyQuery search are not monotone in maxEdits: 0 finds the term, 1 and 2 do not. Only that search path special-cases 0: the public FuzzyTermsEnum with maxEdits 0, and getAutomata() / visit() of a FuzzyQuery with maxEdits 0, also work on the display string (executed; see "Suggested fixes", fix A, "Open decision"). Before 4.3.0, text() was a lenient decode, so the pattern was garbled instead (no hit; with assertions on, an AssertionError in UnicodeUtil).

Exposure and likelihood153 words
  • Who. Code that builds a fuzzy pattern from raw bytes that are not UTF-8: new FuzzyQuery(new Term(field, bytes), maxEdits) or new FuzzyTermsEnum(terms, term, ...) over a field of binary keyword values (raw ids, hashes, other byte keys). Term(String, String) always encodes well-formed UTF-8, so text patterns are never affected.
  • One parser path. The classic QueryParser builds the fuzzy Term from Analyzer.normalize() bytes (QueryParserBase.java:729-730). With the stock CollationKeyAnalyzer, the key bytes of the text 東京都 start with 0x80; 東京都~1 returns no hit while the plain term query matches (the devil's-advocate verifier's probe). Niche.
  • Effect. Silently missing results (the exact match is dropped) and, when the index holds such text, extra hits near the hex string. No exception, no log.
  • Default vs opt-in. Under the default settings the repro uses, a non-UTF-8 pattern is enough to trigger it.
  • Scale. Deterministic: every recorded run without the fix failed; one document is enough.

Minimal repro

One JUnit test method on the public API: one StringField document holding the bytes ff 61 62 63, one FuzzyQuery(term, 1), one count. Deterministic, single thread; fails with assertions on and off. If the constructor rejects the pattern (the recommended fix), the test returns, so it is also the regression test.

TestFuzzyQueryBinaryPattern.java (lucene/core, package org.apache.lucene.search, 1 test)52 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestFuzzyQueryBinaryPattern extends LuceneTestCase {

  public void testFuzzyQueryMatchesIdenticalBinaryTerm() throws Exception {
    BytesRef bytes = new BytesRef(new byte[] {(byte) 0xff, 'a', 'b', 'c'});
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new StringField("id", bytes, Field.Store.NO));
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        // expected: 1 hit (identical term) or IAE for a non-UTF-8 pattern; actual: 0 hits, automaton built from "[ff 61 62 63]"
        FuzzyQuery q;
        try {
          q = new FuzzyQuery(new Term("id", bytes), 1);
        } catch (IllegalArgumentException e) {
          return;
        }
        assertEquals(1, new IndexSearcher(r).count(q));
      }
    }
  }
}
Command
cp $REPRO/TestFuzzyQueryBinaryPattern.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestFuzzyQueryBinaryPattern -Ptests.seed=DEADBEEF
# same failure with -Ptests.asserts=false
Expected
count(new FuzzyQuery(new Term("id", bytes), 1)) is 1, the identical term (or the constructor rejects the non-UTF-8 pattern).
Observed
java.lang.AssertionError: expected:<1> but was:<0>, with assertions on and off.

All files of this repro: repro/LFV-1171/

More files, environment, full output204 words

TestFuzzyQueryBinaryPattern (one method, extends LuceneTestCase, package org.apache.lucene.search of the lucene/core tests), with the Apache license header. The test adds no randomness of its own (LuceneTestCase still randomizes the test environment); every recorded run without the fix failed, each under its recorded seed, with assertions on and off. Its catch accepts any IllegalArgumentException; the constructor's other checks (FuzzyQuery.java:94-103: maxEdits range, prefixLength, maxExpansions) cannot fire for these arguments; a regression test for fix A could also assert the rejection itself (expectThrows and the message). A separate re-run used a variant of this test that first asserts the maxEdits = 0 hit; without the fix it fails at its maxEdits = 1 assertion.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 9.12.3, 8.11.4, 4.3.0, 4.2.1, 4.0.0: fail; 3.6.2: input not expressible
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (pure Java; only this platform was run)
Command (environment)
cp $REPRO/TestFuzzyQueryBinaryPattern.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestFuzzyQueryBinaryPattern -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestFuzzyQueryBinaryPattern > testFuzzyQueryMatchesIdenticalBinaryTerm FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at org.apache.lucene.search.TestFuzzyQueryBinaryPattern.testFuzzyQueryMatchesIdenticalBinaryTerm(TestFuzzyQueryBinaryPattern.java:48)

# gradle, same, assertions on: the same failure

# with the rejection fix: PASS with assertions on and off

Why it looks unintended

  • The constructor promises terms "with an edit distance of at most maxEdits to term"; the identical bytes are a 0-edit match, which maxEdits = 0 finds.
  • No checked javadoc, test or history states a UTF-8 precondition, yet Term(String, BytesRef) and StringField(String, BytesRef, Store) accept any bytes.
  • The hex fallback of Term.text() is a documented "human-readable form" for printing (4.3.0).
The full argument194 words
  • Contract. "Create a new FuzzyQuery that will match terms with an edit distance of at most maxEdits to term" (FuzzyQuery.java:70-72). If a binary Term is within the pattern domain this API accepts, the identical bytes are a 0-edit match, and maxEdits = 0 does match them.
  • No UTF-8 precondition in the checked docs. No javadoc or test says the pattern must be UTF-8, and no CHANGES entry says what happens otherwise. The closest entry, LUCENE-2265 (lucene/CHANGES.txt:15525-15528, 4.0.0-ALPHA), says "FuzzyQuery and WildcardQuery now operate on Unicode codepoints, not unicode code units"; it predates byte-based terms (its commit 5b74a4ec611 is from 2010-05-03, two months before LUCENE-2514) and states no rule for non-UTF-8 patterns. Term(String, BytesRef) (Term.java:52) and StringField(String, BytesRef, Store) (StringField.java:79) accept any bytes, and TestBinaryTerms indexes and searches binary terms.
  • A display aid. The hex fallback came with LUCENE-4934 (#5998, 4.3.0) so that toString() works on binary terms; the same commit added TestBinaryTerms.testToString ("foo:[ff fe]"). Neither the commit nor its issue discusses fuzzy matching.
  • Inconsistent paths. The maxEdits = 0 path uses term.bytes(), the others term.text().
  • Tests. TestFuzzyQuery uses text patterns only.
The devil's-advocate check155 words

The strongest case for "not a bug": fuzzy edit distance is defined over Unicode code points (LUCENE-2265, #3341, where Robert Muir wrote "fuzzy, regex, wildcard, all of our users are defined on unicode characters ... bytes is an implementation detail"; its CHANGES entry, lucene/CHANGES.txt:15525, says FuzzyQuery and WildcardQuery "now operate on Unicode codepoints"), so a binary pattern is outside the query's domain; WildcardQuery and RegexpQuery also read Term.text().

Why it fails: that justifies rejecting or documenting such patterns, not silently fuzzing a display string and dropping the 0-edit match, and nothing tells the caller. The verifiers rated the argument moderate and rejected it; it is also why the recommended fix rejects, and a maintainer may still prefer the documentation-only fix. The WildcardQuery and RegexpQuery siblings share the assumption (a WildcardQuery without wildcards behaves the same way in the devil's-advocate verifier's probe, executed; RegexpQuery was only read, not run), but are not claimed here.

Suggested fix

Recommended Reject non-UTF-8 fuzzy patterns when maxEdits > 0 (fix A)

Reject a non-UTF-8 pattern when maxEdits > 0: IllegalArgumentException from the FuzzyQuery and public FuzzyTermsEnum constructors (+33/-1, two files). The repro passes with assertions on and off; 203 tests of 11 classes run with 0 failures. Still open: the maxEdits = 0 paths of the public FuzzyTermsEnum, getAutomata() and visit() keep the display string.

  /** The edit distance is defined over the pattern's code points (FuzzyAutomatonBuilder). */
  static Term checkUtf8Pattern(Term term, int maxEdits) {
    if (maxEdits > 0 && isUtf8(term.bytes()) == false) {
      throw new IllegalArgumentException(
          "fuzzy matching with maxEdits > 0 needs a term whose bytes are valid UTF-8; got " + term);
    }
    return term;
  }
All options and trade-offs3 options · 583 words

Option A was built and checked through gradle on 5adff78bb168; B and C were not built. WildcardQuery (executed) and RegexpQuery (read only) share the Term.text() assumption; no option here changes them.

Reject non-UTF-8 fuzzy patterns when maxEdits > 0 (fix A) (validated by the checks) (recommended)

A package-private FuzzyQuery.checkUtf8Pattern(term, maxEdits) runs the same strict UTF-8 check Term.toString(BytesRef) uses and throws when it fails. The FuzzyQuery constructor calls it after its other argument checks; the public FuzzyTermsEnum constructor passes its term through it.

  /** The edit distance is defined over the pattern's code points (FuzzyAutomatonBuilder). */
  static Term checkUtf8Pattern(Term term, int maxEdits) {
    if (maxEdits > 0 && isUtf8(term.bytes()) == false) {
      throw new IllegalArgumentException(
          "fuzzy matching with maxEdits > 0 needs a term whose bytes are valid UTF-8; got " + term);
    }
    return term;
  }

Trade-offs

A behaviour change: code that builds such queries today gets an exception instead of silent wrong results. That includes the classic QueryParser over a CollationKeyAnalyzer field, where 東京都~1 would throw instead of returning nothing (not run), and the three main-source callers of the public FuzzyTermsEnum constructor: DirectSpellChecker (DirectSpellChecker.java:425, suggest), the sandbox FuzzyLikeThisQuery (FuzzyLikeThisQuery.java:217) and the classification module's NearestFuzzyQuery (NearestFuzzyQuery.java:153); the suites of the first two ran with the fix, the classification tests did not. The first sketch guarded only FuzzyQuery; the cross-model refute review found the public constructor unguarded, so both are guarded.

Open decision: the other maxEdits = 0 paths (left as they are by fix A). The guard fires only for maxEdits > 0, but maxEdits = 0 is special-cased only in FuzzyQuery.getTermsEnum. The public FuzzyTermsEnum constructor (FuzzyTermsEnum.java:94-102) and FuzzyQuery.getAutomata() (:172, used by visit(), :197-201) build the automaton from term.text() for maxEdits = 0 too. Executed on main and with fix A shadowed (same output): FuzzyQuery(term, 0) counts 1, but new FuzzyTermsEnum(terms, term, 0, 0, false) enumerates only the text term [ff 61 62 63] (bytes 5b 66 66 20 ...), and the automata from getAutomata() and visit() accept that text and reject the raw bytes. A complete fix needs a choice: reject non-UTF-8 terms in the public FuzzyTermsEnum constructor for every maxEdits (it always builds an automaton), and for getAutomata() / visit() either reject them in FuzzyQuery for every maxEdits or build the 0-edit automaton from the bytes. Not built or run.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off).
  • With the fix: 203 tests run, 0 failures (202 pass, 1 is @Ignored by its suite): in core the repro, TestFuzzyQuery (13), TestFuzzyTermOnShortTerms (1), TestMultiTermQueryRewrites (4), TestWildcardQuery (14) and TestBinaryTerms (2); TestQueryParser (73), TestSimpleQueryParser (46) and TestQPHelper (38) in queryparser; TestDirectSpellChecker (6) in suggest; TestFuzzyLikeThisQuery (5) in sandbox. :lucene:core:checkGoogleJavaFormat passes on the fix.

Document the limit only (not built)

A javadoc note on FuzzyQuery and FuzzyTermsEnum that the pattern must be UTF-8 text.

Trade-offs

Smallest change and no behaviour change, but callers who miss the note still get 0 hits silently. Given the Unicode-only design (#3341), a maintainer may well choose this.

Fuzzy-match non-UTF-8 patterns over bytes (not built)

Build a byte-alphabet Levenshtein automaton when the pattern is not UTF-8.

Trade-offs

Real fuzzy matches on binary terms, but distances would mean bytes for some patterns and code points for others; a larger design change that the Unicode-only design (#3341) argues against.

Downstream note

Downstream, a service that offers fuzzy search on binary keyword fields silently returns no match for the exact value; the documents it wrongly matches are ones an ordinary query on the same field reaches anyway.

How it was found

A proof-obligation audit of FuzzyTermsEnum's contract: the enumerated terms must be those within maxEdits of the pattern term's bytes, for every legal Term. The pattern is built from Term.text(), which is not the term for non-UTF-8 bytes; a one-document public-API JUnit test then witnessed the missed match.

The method in full109 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder read FuzzyTermsEnum and FuzzyQuery against their obligation (terms within maxEdits of the pattern term) and the inputs Term accepts (any bytes). The automaton is built from term.text(), and Term.text() is the term only for valid UTF-8; for other bytes it is the hex display string.
  • The finder then wrote a public-API JUnit test that fails on main with assertions on and off and on 10.6.0, 9.12.3 and 8.11.4.
  • An independent reproducer, two executed cross-model reviews, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@ and issues@ lists, downstream trackers or the web (searched 2026-11-03). Nearest items, context only: #5998 (origin of the hex fallback), #3341 (fuzzy defined on code points), #2201 (FuzzyQuery must match the precise term).

Searches3 searches

No matching public report was found in the documented searches. Other related items: #7850 (LUCENE-6792, another query that assumed UTF-8 terms, display only, fixed) and elastic/elasticsearch#18154 (a downstream note that fuzzy queries would not work on binary ids, at the product level). Open PRs #14716 and #12433 touch FuzzyTermsEnum.java but not the term.text() lines. Limits: keyword search; GitHub search does not index review comments; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03, re-checkedtwo scripted prior-report searches (96 queries); 44 manual GitHub searches (also lucene-solr, Solr, Elasticsearch, OpenSearch, Lucene.NET); 14 JIRA queries, 13 issues read in full; everything created or updated since 2026-11-01no report; #5998, #3341, #2201 and #7850 are context
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs and of the 40 PRs updated since 2026-11-01#14716 and #12433 touch FuzzyTermsEnum.java, not the term.text() lines
Mailing lists, downstream, web2026-11-03dev@, java-user@ and issues@ (18 extra list queries); downstream trackers; web searchesnothing about this bug; elastic/elasticsearch#18154 is a product-level design note

History

Introduced in 4.0.0 by LUCENE-2514 (0c333c60dd0, 2010), which made Term byte-based while the fuzzy code kept term.text(); since 4.3.0 (LUCENE-4934) the pattern is the hex string. Every release run, 4.0.0 to 10.6.0, and main fail; 3.6.2 cannot express the input. Not fixed upstream.

Versions tested5 rows

Introduced by 0c333c60dd0 (LUCENE-2514 "Term is no longer character based", #3588, trunk r960484, 2010-07-05; Subversion era, no PR). It added Term(String, BytesRef) and made Term.text() a decode of the bytes, while FuzzyTermsEnum (from the flex merge, b679816a700) and FuzzyQuery kept building the pattern from term.text(). First release 4.0.0.

The hex form comes from LUCENE-4934 (#5998; e8279926a2d on trunk, 74fc96bd3d6 on branch_4x, 2013-04-15, released in 4.3.0), which made Term.text() decode strictly with the BytesRef.toString() fallback. Before it, 4.0.0 to 4.2.1 fuzzed a garbled decode: no hit, or with assertions on an AssertionError in UnicodeUtil. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still calls term.text() at both lines.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
10.6.0, 10.5.2, 9.12.3, 8.11.4 affectedrelease jars: the identical term is missed (expected:<1> but was:<0> or, two-document test, expected:<[0]> but was:<[1]>)
4.3.0 main() formthe hex-string neighbour is matched, the identical term is not (assertions on and off)
4.0.0, 4.2.1 main() formno hit with assertions off; AssertionError in UnicodeUtil with assertions on
3.6.2 input not expressibleTerm holds only a String; the test does not compile

Independent checks

A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, a credibility audit and a final repro check reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts285 words

The cross-model reviews ran on main ea57360912ed, whose fuzzy, Term and BytesRef sources are byte-identical to 5adff78bb168; the refute review's test was re-run on 5adff78bb168 with the same result. The other checks on main ran on 5adff78bb168; the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedTwo-document test fails on main (assertions on and off), 10.6.0, 9.12.3 and 8.11.4: only the document holding the text [ff 61 62 63] matches.
independent reproducer realOwn single-document test (bytes 80 6b 65 79) fails on main, 10.6.0, 10.5.2 and 9.12.3; the automaton accepts the display string and rejects the raw bytes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own tests (bytes ff 78 79): FuzzyQuery and the public FuzzyTermsEnum return the display-string term; found the first fix sketch left the public constructor unguarded.
cross-model review, intended lens (OpenAI GPT-5.6, executed) real (high)The Unicode-only argument is moderate and rejected; the extended fix passes its test and TestFuzzyQuery.
devil's-advocate verifier real (low)TermQuery, PrefixQuery, TermRangeQuery and FuzzyQuery(0) match the binary term, FuzzyQuery(1) does not; found the CollationKeyAnalyzer parser path.
adjudication confirmed, LowRe-ran two tests on main: fail. Medium confidence only because a maintainer may call it unsupported input.
separate re-run, audit and final repro check confirmedThe one-test repro fails on main with assertions on and off and on the 10.6.0 jars; the final check set the impact class to minor.
this doc reproduced; fix A validatedGradle: fails without the fix; with it the repro passes and 203 tests of 11 classes run with 0 failures.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,227 words

Finding record

LFV-1171, snapshot of 2026-11-06

Registry title
FuzzyQuery with maxEdits >= 1 on a term whose bytes are not valid UTF-8 builds its automaton from the hex display string of Term.text(), so it misses the identical term and matches terms near "[ff 61 62 63]" instead
Area
core / search: FuzzyQuery and FuzzyTermsEnum (Levenshtein pattern built from Term.text())
Priority
Low, rank 267: FuzzyQuery with maxEdits of 1 or more silently misses the identical term, matching only terms near the hex rendering of the bytes, whenever the pattern bytes are not valid UTF-8, on default settings and in every release since 4.0.0. It needs a fuzzy query over binary keyword values such as raw ids or hashes (or a niche query-parser path over a collation-key field), which is unusual, so it is Low, though high in Low because the query is core and released everywhere.
Impact class
minor
Impact note
Applications that build a FuzzyQuery (or the public FuzzyTermsEnum) from a Term whose bytes are not valid UTF-8, for example a binary id indexed with StringField(String, BytesRef, Store), are hit with default settings and with -ea or -da: once maxEdits >= 1 the query never matches the identical indexed term (maxEdits = 0 does), and it matches terms within maxEdits of the hex rendering ("[ff 61 62 63]" for the bytes ff 61 62 63) instead, with no error. Patterns built from a String are never affected; one niche query-parser path exists (classic QueryParser with '~' over a field analyzed with the stock CollationKeyAnalyzer, on non-Latin text). Every release since 4.0.0 (the hex form since 4.3.0) and main.
Summary
You index a binary term, for example the bytes ff 61 62 63 in a StringField, and search it with new FuzzyQuery(new Term(field, sameBytes), 1); you expect the document to match, because the identical term is at edit distance 0 and FuzzyQuery(term, 0) does match it. Instead the query matches nothing, or only a document holding the text term "[ff 61 62 63]": FuzzyTermsEnum builds its Levenshtein automaton from Term.text(), and for bytes that are not valid UTF-8 Term.text() returns the human-readable hex rendering of the bytes, not the term.
Introduced
0c333c60dd03aedde4fc75aca29713ca9b87b6ff (LUCENE-2514 'Term is no longer character based', apache/lucene#3588, trunk r960484; no PR, Subversion era) [read, then exec on releases: it added Term(String, BytesRef) and made Term.text() a decode of the bytes, while FuzzyTermsEnum (flex merge b679816a700) and FuzzyQuery kept building the Levenshtein pattern from term.text()]. The hex-display-string form of the symptom comes from LUCENE-4934 (apache/lucene#5998): trunk e8279926a2d7e08920c45cd657f71122fc45975c and branch_4x 74fc96bd3d68757d20508aceb9ffb7530374652c, which made Term.text() decode strictly and fall back to BytesRef.toString() [read; exec boundary 4.2.1 / 4.3.0] (2010-07-05 (LUCENE-2514, author and commit date 2010-07-05T08:33:25Z); hex form 2013-04-15 (LUCENE-4934))
First affected version
4.0.0 [exec: fails with -da (the decoded pattern is garbage code points, no hit) and with -ea (AssertionError 'b = 0xff' in UnicodeUtil from Term.text()); the hex-display-string form starts in 4.3.0 [exec]; 3.6.2 lacks Term(String, BytesRef), so a non-UTF-8 pattern cannot be expressed there (compile error)]
Affected versions
4.0.0 to 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); not fixed anywhere. Executed, fail [exec]: main with -ea and -da (the finder, the independent reproduction, the intended-behaviour check, the adjudication and a separate re-run), 10.6.0 (the finder, the independent reproduction, the history step and a separate re-run), 10.5.2 and 9.12.3 (the independent reproduction; 9.12.3 also finder and history step), 8.11.4 (finder, history step), 4.3.0 (-ea and -da: hex-string neighbour matched), 4.2.1 and 4.0.0 (-da: no hit; -ea: AssertionError in UnicodeUtil). Not affected: 3.x and earlier (API absent). Other releases by reading every release tag (0c333c60dd0 is in all release tags from 4.0.0-alpha on). Not new in 10.6. Upstream main 1c2072f0284 (2026-11-02) still builds the pattern from term.text() (FuzzyTermsEnum.java:101 and :132). All runs on Linux aarch64 with JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (96 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@ and issues@ lists, the JIRA archive and the web), 44 manual GitHub issue and PR searches, manual JIRA-archive and list queries, web searches, and a scan of the changed files of all 409 open apache/lucene PRs plus the 40 PRs updated since 2026-11-01: #14716 and #12433 (open) touch FuzzyTermsEnum.java but not the term.text() lines; #16847 touches only other fuzzy classes. Hits are context only: #5998 (LUCENE-4934, origin of the hex fallback in 4.3.0), #3341 (LUCENE-2265, fuzzy defined on Unicode code points), #2201 (LUCENE-1124, FuzzyQuery must match the precise term), #7850 (LUCENE-6792, a query assuming UTF-8 terms, fixed for display), #7051 (LUCENE-5989, StringField takes a BytesRef), #3588 (LUCENE-2514), #10110 (FuzzyAutomatonBuilder), elastic/elasticsearch#18154 (a downstream design note that fuzzy queries would not work on binary ids, not this defect). Same-day recheck: nothing new; newest upstream commits on the four files have author dates 2024-07-10 (FuzzyTermsEnum), 2022-09-24 (FuzzyQuery), 2020-12-23 (FuzzyAutomatonBuilder) and 2026-05-15 (Term, final modifiers); upstream main 1c2072f0284 unchanged on the fix site. Not fixed anywhere.
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle in a scratch worktree with assertions on and off, without and with the rejection fix (the repro, then 203 tests of 11 classes in core, queryparser, suggest and sandbox with 0 failures; checkGoogleJavaFormat on the fix), and standalone on the main jars. Release jars run: 10.6.0, 10.5.2, 9.12.3, 8.11.4, and 4.3.0, 4.2.1, 4.0.0 (a main() form of the same check); 3.6.2 cannot express the input. All on Linux aarch64 with OpenJDK 25.0.3.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:101 (at 5adff78bb168) and :132 ('new FuzzyAutomatonBuilder(term.text(), maxEdits, prefixLength, transpositions)'), lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:70-72 (constructor javadoc 'will match terms with an edit distance of at most maxEdits to term'), :172 (getAutomata uses term.text()), :205-206 (maxEdits == 0: SingleTermsEnum(terms.iterator(), term.bytes())), lucene/core/src/java/org/apache/lucene/index/Term.java:52 (Term(String, BytesRef)), :94-121 (text() = toString(bytes): strict UTF-8 decode, else BytesRef.toString(); 'If the term is not unicode, the raw bytes will be printed instead'), lucene/core/src/java/org/apache/lucene/document/StringField.java:79 (StringField(String, BytesRef, Store)), lucene/core/src/java/org/apache/lucene/search/WildcardQuery.java:70 and RegexpQuery.java:98 (siblings that also read term.text(); not claimed), lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:729-730 (getFuzzyQuery builds the Term from Analyzer.normalize bytes), lucene/CHANGES.txt:18913 (LUCENE-1124), lucene/core/src/test/org/apache/lucene/index/TestBinaryTerms.java (binary terms are supported index content)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:172, (at 5adff78bb168) :197-201, :204-209
  • lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:94-102, (at 5adff78bb168) :119-133
  • lucene/core/src/java/org/apache/lucene/index/Term.java:96-122; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/util/BytesRef.java:143-157; lucene/core/src/java/org/apache/lucene/search/FuzzyAutomatonBuilder.java:37-57
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:727-732 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/Term.java:52; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/document/StringField.java:69-87
Minimal repro
  • lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:94-103 (at 5adff78bb168) (the constructor's other IllegalArgumentException checks)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:70-93 (at 5adff78bb168) (constructor javadoc, no @throws for the pattern)
  • lucene/core/src/java/org/apache/lucene/index/Term.java:52, (at 5adff78bb168) :96-107; lucene/core/src/java/org/apache/lucene/document/StringField.java:69-79
  • lucene/core/src/test/org/apache/lucene/index/TestBinaryTerms.java:33-71; (at 5adff78bb168) lucene/core/src/test/org/apache/lucene/search/TestFuzzyQuery.java
  • lucene/core/src/java/org/apache/lucene/search/WildcardQuery.java:70; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/search/RegexpQuery.java:98
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/spell/DirectSpellChecker.java:425; (at 5adff78bb168) lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:727-732
  • lucene/sandbox/src/java/org/apache/lucene/sandbox/queries/FuzzyLikeThisQuery.java:217; (at 5adff78bb168) lucene/classification/src/java/org/apache/lucene/classification/utils/NearestFuzzyQuery.java:153 (the other main-source callers of the public FuzzyTermsEnum constructor)
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:101, (at 5adff78bb168) :132; lucene/core/src/java/org/apache/lucene/index/Term.java:100-122

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The diagram illustrates the repro's query (main 5adff78bb168, FuzzyTermsEnum.java:101 and :132, Term.java:100-122).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.