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.
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.- You index one document whose
idis the 4 raw bytesff 61 62 63, withnew StringField("id", bytes, Store.NO): a supported binary keyword value, such as a raw id or hash. - You search
new FuzzyQuery(new Term("id", bytes), 1)and expect 1 hit: the identical term is 0 edits away, andFuzzyQuery(term, 0)andTermQueryboth count 1. FuzzyTermsEnumbuilds its automaton fromTerm.text(). The byte0xffnever occurs in UTF-8, sotext()falls back to the hex display string"[ff 61 62 63]", 13 characters.- 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.
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.
- Two paths.
FuzzyQuery.getTermsEnum(search/FuzzyQuery.java:204-209) usesnew SingleTermsEnum(terms.iterator(), term.bytes())formaxEdits == 0(:205-206), the bytes, which is correct; otherwise it creates aFuzzyTermsEnum(:208). - The pattern is the text. Both
FuzzyTermsEnumconstructors, the public one (search/FuzzyTermsEnum.java:101) and the oneFuzzyQueryuses (:132), supplynew FuzzyAutomatonBuilder(term.text(), maxEdits, prefixLength, transpositions), the fix site.FuzzyQuery.getAutomata()(FuzzyQuery.java:172), used byvisit()(:197-201), does the same. - The text of a binary term is its display string.
Term.text()istoString(bytes)(index/Term.java:100-102): a strict UTF-8 decode that, on failure, returnsBytesRef.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). - The raw term is unreachable.
FuzzyAutomatonBuildertakes 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 a0xffbyte. 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)ornew 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
QueryParserbuilds the fuzzyTermfromAnalyzer.normalize()bytes (QueryParserBase.java:729-730). With the stockCollationKeyAnalyzer, the key bytes of the text東京都start with0x80;東京都~1returns 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.
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.
/*
* 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));
}
}
}
}
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.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (pure Java; only this platform was run) |
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
# 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- The constructor promises terms "with an edit distance of at most
maxEditstoterm"; the identical bytes are a 0-edit match, whichmaxEdits = 0finds. - No checked javadoc, test or history states a UTF-8 precondition, yet
Term(String, BytesRef)andStringField(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
maxEditstoterm" (FuzzyQuery.java:70-72). If a binaryTermis within the pattern domain this API accepts, the identical bytes are a 0-edit match, andmaxEdits = 0does 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 commit5b74a4ec611is from 2010-05-03, two months before LUCENE-2514) and states no rule for non-UTF-8 patterns.Term(String, BytesRef)(Term.java:52) andStringField(String, BytesRef, Store)(StringField.java:79) accept any bytes, andTestBinaryTermsindexes 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 addedTestBinaryTerms.testToString("foo:[ff fe]"). Neither the commit nor its issue discusses fuzzy matching. - Inconsistent paths. The
maxEdits = 0path usesterm.bytes(), the othersterm.text(). - Tests.
TestFuzzyQueryuses 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.
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.
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, seedDEADBEEF: 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) andTestBinaryTerms(2);TestQueryParser(73),TestSimpleQueryParser(46) andTestQPHelper(38) in queryparser;TestDirectSpellChecker(6) in suggest;TestFuzzyLikeThisQuery(5) in sandbox.:lucene:core:checkGoogleJavaFormatpasses on the fix.
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.
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, 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.
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
FuzzyTermsEnumandFuzzyQueryagainst their obligation (terms withinmaxEditsof the pattern term) and the inputsTermaccepts (any bytes). The automaton is built fromterm.text(), andTerm.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").
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03, re-checked | two 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-01 | no report; #5998, #3341, #2201 and #7850 are context |
| Open PRs | 2026-11-03 | the 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, web | 2026-11-03 | dev@, java-user@ and issues@ (18 extra list queries); downstream trackers; web searches | nothing about this bug; elastic/elasticsearch#18154 is a product-level design note |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle: fails with assertions on and off; passes with fix A |
| 10.6.0, 10.5.2, 9.12.3, 8.11.4 | affected | release jars: the identical term is missed (expected:<1> but was:<0> or, two-document test, expected:<[0]> but was:<[1]>) |
| 4.3.0 | main() form | the hex-string neighbour is matched, the identical term is not (assertions on and off) |
| 4.0.0, 4.2.1 | main() form | no hit with assertions off; AssertionError in UnicodeUtil with assertions on |
| 3.6.2 | input not expressible | Term holds only a String; the test does not compile |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Two-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 | real | Own 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, Low | Re-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 | confirmed | The 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 validated | Gradle: fails without the fix; with it the repro passes and 203 tests of 11 classes run with 0 failures. |
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.
- PNG 560 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,227 words
- 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;checkGoogleJavaFormaton 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 (amain()form of the same check); 3.6.2 cannot express the input. All on Linux aarch64 with OpenJDK 25.0.3.
- 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-209lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:94-102, (at 5adff78bb168) :119-133lucene/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-79lucene/core/src/test/org/apache/lucene/index/TestBinaryTerms.java:33-71; (at 5adff78bb168) lucene/core/src/test/org/apache/lucene/search/TestFuzzyQuery.javalucene/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-732lucene/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
- TestFuzzyQueryBinaryPattern.java (lucene/core, package org.apache.lucene.search, 1 test):
repro/LFV-1171/TestFuzzyQueryBinaryPattern.java(52 lines)
The diagram illustrates the repro's query (main 5adff78bb168, FuzzyTermsEnum.java:101 and :132, Term.java:100-122).
assets/LFV-1171/bug.png(560 KB)
![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.](../assets/LFV-1171/bug.png)