The matcher reads a null entry as "no payload here", but toString() hands every entry, null included, to Term.toString(BytesRef), which dereferences it. explain() fails too because its description contains the query string.
- A payload is a small byte array that a token can carry at its position in the index; many tokens carry none.
SpanPayloadCheckQuerytakes a span query and a list of expected payloads, one per position of the span, and keeps only the spans whose payloads match the list in order.- In that list a
nullentry means "this position must have no payload"; a non-null entry never matches a position without one.
null entry as "no payload" and finds the document, while toString() and explain() pass the same null to Term.toString and throw. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document whose field
fholds two tokens:awith no payload, thenbwith the payloadp. - Query it with
SpanPayloadCheckQuery(spanNear[f:a, f:b], [null, "p"]), meaning "ahas no payload,bhasp". The search honours thenull:countreturns 1, and["p", "p"]returns 0. - You expect
query.toString()to print the query andsearcher.explain(query, 0)to explain the hit. - Instead both throw
NullPointerException: Cannot read field "bytes" because "termText" is null, with assertions on and off:toString()hands every entry, thenulltoo, toTerm.toString(BytesRef), and explain puts the query string into its description. - Search, count,
equalsandhashCodestay correct and the index is untouched; only printing and explaining such a query fail.
Applications that build SpanPayloadCheckQuery in code (no Lucene query parser builds it) and use a null entry to require a position without a payload. Every toString() of that query, in a log line or a debug print, and every explain() of a hit throws; search and count stay correct. Default settings, assertions on and off.
Mechanism, terms and numbers478 words
SpanPayloadCheckQuery keeps only the spans whose positions carry the expected payloads, given as a List<BytesRef>. Its matcher gives a null entry a defined meaning, "this position has no payload" (matches = payload == null, SpanPayloadCheckQuery.java:221-225 at 645d4fe14a20), while a non-null entry never matches a payload-less position (:226-230). So [null, "p"] over the span a b finds a document where a has no payload and b has p. But toString(String) passes every entry, the null too, to Term.toString(BytesRef) (:252-255), which reads termText.bytes (Term.java:116) and throws NullPointerException. IndexSearcher.explain() throws the same way for every matching document, because SpanWeight.explain puts the query string into its description (SpanWeight.java:182-183). Search, count, equals and hashCode work, and nothing reaches the index. The null meaning came with LUCENE-6716 (2015), so every release from 5.4.0 to 10.6.0 and main are affected; a one-line null-safe toString fixes it.
- The constructors store the list unchecked. Javadoc: "@param payloadToMatch The {@link java.util.List} of payloads to match" (
SpanPayloadCheckQuery.java:78,:86);this.payloadToMatch = payloadToMatch;(:97). Nothing mentionsnullentries. - The matcher reads
nullas "no payload here" (PayloadChecker.collectLeaf,:212-233):
BytesRef payload = postings.getPayload();
if (payloadToMatch.get(upto) == null) { // :221
matches = payload == null; // :222 null entry = no payload at this position
upto++;
return;
}
if (payload == null) { // :226 a non-null entry never matches a payload-less position
matches = false;toStringpasses every entry toTerm.toString(:246-260):
for (BytesRef bytes : payloadToMatch) { // :252
buffer.append(Term.toString(bytes)); // :253 NullPointerException when bytes == nullTerm.toString(BytesRef)dereferences it:decoder.decode(ByteBuffer.wrap(termText.bytes, termText.offset, termText.length))(Term.java:116-117, reported at:116).Query.toString()callstoString("")(Query.java:55-57).- Explain prints the query.
IndexSearcher.explain(Query, int)(IndexSearcher.java:941-944) reachesSpanWeight.explain, whose description is"weight(" + getQuery() + " in " + doc + ...(SpanWeight.java:180-189; the no-score branch at:192-193formatsgetQuery()too), so in an index with positions explain throws for every document that matches; a non-matching document getsExplanation.noMatch(SpanWeight.java:198), which does not print the query. - Not affected:
equalsandhashCode(SpanPayloadCheckQuery.java:264-280) useList.equalsandObjects.hashCode, which acceptnull; in an index with positions, search and count do not print the query. One error path does: on a field indexed without positions,SpanPayloadCheckWeight.scorerSupplierbuilds anIllegalStateExceptionmessage that contains the query (SpanPayloadCheckQuery.java:179-186), so on such a field the query throws this NPE instead of thatIllegalStateException. It fails there either way; only the exception changes (read, not run). - Test framework only:
AssertingIndexSearcher.rewritecallsQueryUtils.check(AssertingIndexSearcher.java:64-68), whosecheckUnequalbuildsq1 + " equal to " + q2eagerly (QueryUtils.java:110), so underLuceneTestCase.newSearchereven search fails (read, not run separately). The repro uses no searcher at all.
Exposure and likelihood203 words
- Who is hit. Code that builds
SpanPayloadCheckQuerywith anullentry inpayloadToMatchand then logs, prints or explains it: a log line, a slow-query log, a debug print, an explain request, or any code that puts the query into an exception message. Every such call throws, on every run. - Default vs opt-in. Default
IndexWriterConfig, codec and a plainIndexSearcher; assertions on and off. Opt-in on the API side: alucene-queriesclass that applications build directly in Java code; no Lucene query parser builds it (outside tests onlySpanPayloadCheckQuery.javaandPayloadMatcherFactory.javaname it). Solr'spayload_checkparser adds only encoded, non-empty payloads, nevernull.List.of(...)rejectsnull, so a caller usesArrays.asList,Collections.singletonListor anArrayList. - Effect. One unchecked
NullPointerExceptionfromtoString()orexplain(). Search and count results are correct (in an index with positions; see the mechanism, item 6); no index change, no hang, no resource growth. - Likelihood. Rare: no public report found in the documented searches (see Prior reports) in the nearly eleven years since 5.4.0 (tagged 2015-12-10).
- Workaround. None that keeps the meaning (an empty
BytesRefcannot stand in fornull, see below); callers can only avoid printing or explaining such a query.
One JUnit test in lucene/queries, public API only and no index: it builds SpanPayloadCheckQuery(f:a, [null]) and calls toString(). Deterministic, single thread. It fails on main with assertions on and off, and on the 10.6.0 release jars.
/*
* 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.queries.payloads;
import java.util.Collections;
import org.apache.lucene.index.Term;
import org.apache.lucene.queries.spans.SpanTermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestSpanPayloadCheckQueryNullEntryToString extends LuceneTestCase {
public void testToStringWithNullEntry() {
SpanPayloadCheckQuery q =
new SpanPayloadCheckQuery(
new SpanTermQuery(new Term("f", "a")), Collections.singletonList(null));
try {
q.toString();
} catch (NullPointerException e) {
// null = 'no payload here' to the matcher; expected: a query string, actual: NPE
throw new AssertionError("expected toString() to describe the query, got " + e, e);
}
}
}
cp $REPRO/TestSpanPayloadCheckQueryNullEntryToString.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/payloads/
cd $LUCENE && ./gradlew :lucene:queries:test --tests org.apache.lucene.queries.payloads.TestSpanPayloadCheckQueryNullEntryToString -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
- Expected
toString()returns a description of the query; with the fix it printsSpanPayloadCheckQuery(f:a, payloadRef: null;, payloadType:STRING;, operation:EQ;).- Observed
NullPointerException: Cannot read field "bytes" because "termText" is nullatTerm.java:116fromSpanPayloadCheckQuery.java:253, with assertions on and off.
More files, environment, full output235 words
The try/catch only turns the NPE into an AssertionError that states expected vs actual. The result does not depend on the seed (DEADBEEF and 5EED1234 tried). explain() throws through the same toString() call, so a second test would show no new cause; the finder's three-test JUnit (index, search, explain) shows that path.
Suggested regression test: this same test, as its own class or as one method in TestPayloadCheckQuery. It checks only that toString() no longer throws; it does not pin the marker text. Once a maintainer picks the marker, an assertEquals on the string can be added.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestSpanPayloadCheckQueryNullEntryToString > testToStringWithNullEntry FAILED
java.lang.AssertionError: expected toString() to describe the query, got java.lang.NullPointerException: Cannot read field "bytes" because "termText" is null
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B235680A74F07667]:0)
at org.apache.lucene.queries.payloads.TestSpanPayloadCheckQueryNullEntryToString.testToStringWithNullEntry(TestSpanPayloadCheckQueryNullEntryToString.java:34)
Caused by:
java.lang.NullPointerException: Cannot read field "bytes" because "termText" is null
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.index.Term.toString(Term.java:116)
at org.apache.lucene.queries.payloads.SpanPayloadCheckQuery.toString(SpanPayloadCheckQuery.java:253)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.Query.toString(Query.java:56)
at org.apache.lucene.queries.payloads.TestSpanPayloadCheckQueryNullEntryToString.testToStringWithNullEntry(TestSpanPayloadCheckQueryNullEntryToString.java:31)
1 test completed, 1 failed
# 10.6.0 release jars, -ea and -da: the same NPE at Term.toString(Term.java:116) <- SpanPayloadCheckQuery.toString(SpanPayloadCheckQuery.java:254) (10.6.0 has one more line in rewrite())
# the explain path (finder's three-test JUnit, main 645d4fe14a20, -da; testSearchMatches passes):
java.lang.NullPointerException: Cannot read field "bytes" because "termText" is null
org.apache.lucene.index.Term.toString(Term.java:116)
org.apache.lucene.queries.payloads.SpanPayloadCheckQuery.toString(SpanPayloadCheckQuery.java:253)
org.apache.lucene.search.Query.toString(Query.java:56)
java.base/java.lang.String.valueOf(String.java:4530)
org.apache.lucene.queries.spans.SpanWeight.explain(SpanWeight.java:183)
org.apache.lucene.search.IndexSearcher.explain(IndexSearcher.java:967)
org.apache.lucene.search.IndexSearcher.explain(IndexSearcher.java:943)- The matcher gives
nulla meaning on purpose:matches = payload == null(SpanPayloadCheckQuery.java:221-225, LUCENE-6716). - It is the only way to require a payload-less position: empty payloads are never stored, so an empty
BytesRefmatches 0 documents wherenullmatches 1. - The constructor accepts it, and
toString()andexplain()state no precondition against it.
The full argument235 words
- The
nullmeaning is deliberate code. LUCENE-6716 (22cc3a1560e, Alan Woodward (romseygeek), 2015-09-14) replaced the old collector, which skipped positions without payloads, withPayloadCheckerand its explicit null branch. It survived every later change, LUCENE-9659 (2021) included. - It is the only way to say "no payload here". The indexing chain never stores a zero-length payload (
FreqProxTermsWriterPerField.java:89:if (payload != null && payload.length > 0)), and a non-null entry against a payload-less position always fails (SpanPayloadCheckQuery.java:226-230). With the stockWhitespaceTokenizer+DelimitedPayloadTokenFilter(a token without a delimiter getssetPayload(null),DelimitedPayloadTokenFilter.java:64-65), an empty-BytesRefentry matches 0 documents where anullentry matches 1. - No precondition.
Query.toString(String)"Prints a query to a string" (Query.java:47-51);IndexSearcher.explainstates nothing that excludes a query it executes correctly. - Nothing shows that the
toStringline considered it. It came 18 minutes after the null branch, from the same author, in "LUCENE-6716: Better toString() for SpanPayloadCheckQuery" (13a64196d0d); the firstListversion (bytes.utf8ToString()) also threw onnull, and the available history and tests show no handling of anullentry intoString. - Precedent. Upstream fixed toString and explain NPEs of other classes as bugs, e.g. LUCENE-8130 (#9178,
TermStates.toString) and LUCENE-9524 (an NPE inSpanWeight#explainwith another cause). - Tests and docs. No upstream test builds a
nullentry; CHANGES.txt (the LUCENE-6716 entry) and MIGRATE.md say nothing about it.
The devil's-advocate check121 words
Strongest case (the intended-behaviour checker, the cross-model reviewers and the adjudication): nothing documents null entries, toString has never been null-safe in any version since the class arrived in 3.1, and nothing in Lucene or Solr produces a null entry, so null was never meant as input and the fix is to reject it.
Why it fails: the matcher implements null on purpose, and it is the only spelling of a case the stock DelimitedPayloadTokenFilter produces all the time (tokens without a payload); the constructor accepts it and the search honours it. What the argument does change is the framing: the meaning is defined by the code, not the javadoc, so rejecting and documenting null (option B) is a fair outcome too.
Recommended Print a marker for a null entry and document the null meaning
In toString, print null for a null entry instead of calling Term.toString(null), and document in both constructors that a null entry matches a position without a payload (+5/-3 lines, one file). With it the test passes with assertions on and off, and the whole lucene/queries suite passes (513 tests, 0 failures).
// SpanPayloadCheckQuery.toString(String)
for (BytesRef bytes : payloadToMatch) {
buffer.append(bytes == null ? "null" : Term.toString(bytes));
buffer.append(';');
}
// both constructors
* @param payloadToMatch The {@link java.util.List} of payloads to match; a {@code null} entry
* matches a position that has no payloadAll options and trade-offs3 options · 314 words
The recommended fix was applied to a scratch worktree of 645d4fe14a20 and run through gradle; the alternatives were not run.
Print a marker for a null entry and document the null meaning (validated by the checks) (recommended)
In SpanPayloadCheckQuery.toString(String) append "null" for a null entry; in the javadoc of both constructors say that a null entry matches a position that has no payload.
// SpanPayloadCheckQuery.toString(String)
for (BytesRef bytes : payloadToMatch) {
buffer.append(bytes == null ? "null" : Term.toString(bytes));
buffer.append(';');
}
// both constructors
* @param payloadToMatch The {@link java.util.List} of payloads to match; a {@code null} entry
* matches a position that has no payloadTrade-offs
- Keeps the only way to require a position without a payload and makes it a documented contract; non-null entries print exactly as before.
- The marker text is a choice (
null, or something like<none>).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF: the repro passes with-Ptests.asserts=trueandfalse. - The whole
:lucene:queries:testwith the fix, assertions on: 52 classes (51 upstream + the repro), 513 tests, 0 failures, 5 skipped. The same suite without the fix: 1 failure, the repro. :lucene:queries:checkGoogleJavaFormatpasses with the fix.- With the fixed class shadowed, the finder's three-test JUnit passes 3 of 3:
toString(),explain()(a matchingExplanation) and the search control.
Check each entry with Objects.requireNonNull or throw IllegalArgumentException, and say so in the javadoc.
Trade-offs
- Makes the contract explicit and fails early.
- Removes the only way to match a position without a payload, and turns queries that search correctly today into construction errors.
Return "null" from Term.toString(BytesRef) for a null argument.
Trade-offs
- One place for every caller.
- Changes a core utility for a queries-module bug and could hide genuine
nullterms elsewhere.
Downstream products are affected only if their own code builds a SpanPayloadCheckQuery with a null entry; Solr's payload_check parser never adds one.
Bounded-exhaustive translation validation: a harness ran 133,080 SpanPayloadCheckQuery searches over payload lists of up to 3 entries, null included, on the stock jars and compared them with an independent reference semantics. All searches agreed (null = no payload), but explain() threw in 932 of 3,380 sampled score/explain checks (each recorded example has a null entry).
The method in full133 words
Method: semi-formal (bounded-exhaustive translation validation), 2026-10-28, main a9779ef3c193. The hunt unit wrote a reference semantics of the payload span queries, independent of the matchers (a null list entry requires "no payload"), built test indexes over the terms a, b, c with and without payloads, and compared the real classes on the stock jars with the reference: all 133,080 SpanPayloadCheckQuery searches agreed, and explain() threw in 932 of the 3,380 sampled score/explain checks (every recorded example: NullPointerException on a list with a null entry). Reading the code then gave the root cause in toString(). The finder's JUnit (one document, [null, "p"], plain IndexSearcher) failed 2 of 3 with -ea and -da, the search control passing; a later minimisation step replaced it with the one-method test shown here.
No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-28, re-checked 2026-10-30). The closest item is LUCENE-6716 (#7774), the 2015 change that introduced the code.
Searches2 searches
Context, not reports: #7774 (LUCENE-6716, the introducing change; nobody discussed null entries); #8859 (LUCENE-7808, equals/hashCode); #10698 / lucene-solr #2185 (LUCENE-9659, the last toString change); LUCENE-9524 (a different NPE in SpanWeight.explain, a null sim scorer); #9178 (LUCENE-8130) and #3826 (LUCENE-2752, open), toString NPEs in other classes. Limits: keyword search only; GitHub search does not index PR diffs or every review comment; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, all of GitHub), JIRA LUCENE and SOLR, the Lucene mailing lists, the web | 2026-10-28 | 146 logged queries and reads: two search-script runs (90 network queries), 21 manual gh searches, JIRA JQL, list threads, git log -S over the full history and the release tags, the changed files of all 411 open apache/lucene PRs | no report of this bug; 20 hits, none the same bug |
| GitHub (apache/lucene), read-only | 2026-10-28 and 2026-10-30 | issues and PRs created since 2026-10-26; gh search for SpanPayloadCheckQuery and payloadToMatch; the file's blob on upstream main, branch_11x and branch_10_6 | nothing new; the file is unchanged upstream |
Introduced in 5.4.0 by LUCENE-6716 (#7774, 2015-09-14), which gave a null entry its meaning while the new toString() never handled it. Every release from 5.4.0 to 10.6.0 and main are affected; not fixed upstream.
Versions tested8 rows
LUCENE-6716 ("Improve SpanPayloadCheckQuery API", #7774; Alan Woodward (romseygeek), "+1 LGTM" from dsmiley; JIRA/SVN era, no PR): trunk 22cc3a1560e added the null branch (its toString used bytes.utf8ToString(), also null-unsafe), trunk 13a64196d0d added the Term.toString(bytes) line still on main, and branch_5x e135605e718 merges both and shipped in 5.4.0 (tagged 2015-12-10). On 5.3.2 jars, shadowing the two payload-check classes at e135605e718 gives main's behaviour and at its parent stock 5.3.2. Before 5.4.0 (3.1-5.3.2) toString() also threw on a null element, but the matcher gave null no meaning, so the NPE path is as old as the class while the defect as reported starts in 5.4.0. All 91 release tags from 5.4.0 to 10.5.2 have both lines; upstream main 2f424fd428da, branch_11x and branch_10_6 are unchanged (2026-10-30).
| Version or commit | Verdict | Result |
|---|---|---|
| 3.1 to 5.3.2 | outside the defect | toString() also throws on a null element, but null has no matching meaning (5.3.x: never matches; 3.1-5.2.x: the list is not consulted at payload-less positions); run on 5.2.1 and 5.3.0-5.3.2 |
| 5.3.2 + e135605e718 (the commit alone) | introducing commit | search matches, toString and explain throw: main's behaviour; its parent gives stock 5.3.2 |
| 5.4.0 | first release | finder's test (API-adapted): search passes, toString and explain throw |
| 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 | affected | 2 of 3 fail each, the search control passing |
10.6.0 (4ae946e5a9b9) | affected | minimal test fails, -ea and -da |
main 645d4fe14a20 | affected | minimal test fails (gradle and standalone, assertions on and off); finder's test 2 of 3 |
upstream main 2f424fd428da, branch_11x, branch_10_6 | source read | same code (2026-10-30) |
main 645d4fe14a20 + null-marker fix | fix | minimal test passes; whole lucene/queries suite 513 tests, 0 failures |
The finder, a cross-model review, independent verifiers, a devil's advocate, an adjudication, a credibility audit and this doc's runs reproduced it. A reading-only cross-model adversary agreed on the root cause; it said not real only because it could not run code.
Checks and verdicts312 words
Every executed check reproduced the same NPE in Term.toString(BytesRef) called from SpanPayloadCheckQuery.toString (on main Term.java:116 <- SpanPayloadCheckQuery.java:253; the line numbers differ on older releases), and every search control passed ([null, "p"] counts 1, ["p", "p"] 0, [null, null] 0; an empty BytesRef 0 where null gives 1).
| Check | Verdict | What it found |
|---|---|---|
| finder (bounded-exhaustive translation validation) | candidate | 133,080 searches agree with the reference; explain threw in 932 of the 3,380 sampled score/explain checks (every recorded example: the NPE, on a list with a null entry). JUnit 2 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3; 3 of 3 with the fix. |
| cross-model review (OpenAI GPT-5.6, executing) | real, high | Own JUnit with two null entries fails with -ea and -da and passes with the fix; upstream payload suites: 17 tests, 0 failures (2 skipped by assumptions). |
| Independent reproducer | real | Own 4-test JUnit fails 2 of 4 on main, 10.5.2 and 9.12.3; controls pass; 4 of 4 with the fix. |
| Cross-model adversary (OpenAI GPT-5.6, reading only) | not real, low (procedural) | Could not run Java; agrees on the root cause. The verifier's own JUnit fails on main, 10.5.2 and 9.12.3. |
| Intended-behaviour devil's advocate | real, severity low | Stock-analyzer JUnit fails 2 of 4 on main and 10.5.2; an empty BytesRef matches 0 documents, null 1. |
| adjudication | confirmed, medium | Re-ran the independent reproduction with a new seed: 2 of 4 fail with -ea and -da, controls pass. Medium because the javadoc is silent. |
| registry re-run, audit and minimisation | keep | Fresh seeds reproduce; a 4th independent JUnit fails 2 of 4; the one-method test fails on 645d4fe14a20 (-ea, -da) and passes with the fix. |
| this doc's runs (2026-10-30) | reproduced, fix validated | Gradle: stock fails, fix passes (-ea, -da); whole lucene/queries suite 513 tests, 0 failures with the fix; 10.6.0 fails. |
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 420 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,188 words
- Registry title
- SpanPayloadCheckQuery.toString() and IndexSearcher.explain() throw NullPointerException when payloadToMatch contains a null entry, although the matcher accepts null as 'no payload at this position' and the query searches and counts correctly (since 5.4.0, LUCENE-6716)
- Area
- queries / payloads (SpanPayloadCheckQuery.toString, reached from SpanWeight.explain)
- Priority
- Low, rank 705: Search and count results stay correct, and the loud NPE only hits toString or explain of the expert SpanPayloadCheckQuery when payloadToMatch holds an undocumented null entry.
- Impact class
- crash
- Impact note
- Applications that build a SpanPayloadCheckQuery (lucene-queries; an expert API that no Lucene query parser builds) with a null entry in payloadToMatch, which is the only way the matcher offers to require 'no payload at this position', get a NullPointerException from Query.toString() and from IndexSearcher.explain() for every matching document (SpanWeight.explain puts the query string into its description) on every release from 5.4.0 to 10.6.0 (10.6.0 by reading) and on main, with the default IndexWriterConfig, codec and a plain IndexSearcher, -ea and -da; search, count, equals and hashCode are correct and nothing is written to the index, so the failure shows up in logging, debugging and explain (under Lucene's test framework, AssertingIndexSearcher also stringifies the query, so search fails there too [read]).
- Summary
- You build a SpanPayloadCheckQuery over the span 'a b' with payloadToMatch = [null, 'p'], meaning that 'a' must have no payload and 'b' the payload 'p'; the matcher supports exactly this (a null entry matches a position without a payload, a non-null entry never does), and the search finds the document. You expect toString() to print the query and IndexSearcher.explain() to explain the hit. Both throw NullPointerException ('Cannot read field "bytes" because "termText" is null'), because toString() hands every entry, null included, to Term.toString(BytesRef), and explain builds its description from the query string.
- Introduced
- e135605e718644c661aaaf015723fcf08073a4b3 on branch_5x (svn r1702892, 'LUCENE-6716: Change SpanPayloadCheckQuery to take List<BytesRef>', author Alan Woodward (GitHub romseygeek); issue LUCENE-6716 = #7774, '+1 LGTM' from dsmiley; no pull request, JIRA/SVN era). It merges the trunk commits 22cc3a1560e2 (adds the matcher branch 'payloadToMatch.get(upto) == null -> matches = payload == null'; its toString used bytes.utf8ToString(), which also throws on null) and 13a64196d0db ('LUCENE-6716: Better toString() for SpanPayloadCheckQuery', the Term.toString(bytes) line still on main) [exec: on 5.3.2 jars, shadowing the two payload-check classes at e135605e718 gives main's behaviour (the search matches, toString and explain throw) and at its parent gives stock 5.3.2]. Older ingredient: toString() has thrown on a null element since the class was added (LUCENE-2692, 3.1, ToStringUtils.byteArray), but before LUCENE-6716 the matcher gave a null element no meaning [exec on 5.2.1 and 5.3.0-5.3.2] (2015-09-14 (author dates: 22cc3a1560e 08:52:38Z, 13a64196d0d 09:10:26Z, e135605e718 09:57:24Z))
- First affected version
- 5.4.0 [exec] (tagged 2015-12-10, the first release containing e135605e718; on the trunk line 6.0.0); last unaffected in this form 5.3.2 and 5.3.1 [exec]: there a null element never matches, so the query finds nothing, although toString() already throws on it. The NPE itself is older: 3.1-5.3.2 throw from toString() on a null element, and on 5.2.1 explain() of a matching single-term query with [null] throws too, but there the list is not consulted at payload-less positions ([null] and ['x'] both match) [exec on 5.2.1]
- Affected versions
- 5.4.0, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2 [exec: the finder's JUnit, or an API-adapted copy for 5.4.0-9.0.0, fails 2 of 3 with the search control passing]; main a9779ef3c193 [exec, -ea and -da]; all 91 release tags from 5.4.0 to 10.5.2 [read: each has both the null branch and Term.toString(bytes); 8.8.2-8.11.4 from the lucene-solr repository, 8.11.4 executed]; upstream main 20ced71c1e6 (same file blob as a9779ef3c193), branch_10x, branch_10_6 and branch_9_12 [read: same two lines], so the released 10.6.0 has it too. Not affected in this form: 3.1-5.3.2 (a null element has no matching meaning; toString() still throws on it) [exec on 5.2.1, 5.3.0, 5.3.1 and 5.3.2]. Not fixed upstream.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-28, the prior-report record, verdict new: 146 logged queries and reads: gh searches over apache/lucene issues, PRs and commits, apache/lucene-solr, apache/solr, Elasticsearch and OpenSearch; the changed files of all 411 open apache/lucene PRs; git log -S over the full history and the release tags; JIRA LUCENE and SOLR; the dev@, java-user@, issues@ and commits@ lists; the web; plus the finder's pass and the independent reproduction's, the intended-behaviour check's and the adjudication's searches). Re-checked 2026-10-28: none of the PRs and issues created since 2026-10-26 (#16907-#16926) concerns payloads, span queries or toString; no open PR mentions payloadToMatch or payload checks; the newest commit on SpanPayloadCheckQuery.java is dfd536186fe (#15889, author date 2026-04-01) and on Term.java c2825cfc4b2 (#16063, author date 2026-05-15), both already in a9779ef3c193. Nearest items, none of them this bug: #7774 (LUCENE-6716, the introducing change; nobody discussed null entries), #8859 (LUCENE-7808, equals/hashCode), #10698 and lucene-solr #2185 (LUCENE-9659, the last toString change), LUCENE-9524 (a different NPE in SpanWeight.explain, null sim scorer), #9178 (LUCENE-8130) and #3826 (LUCENE-2752), toString NPEs in other classes. Not fixed upstream: main 20ced71c1e6, branch_10x, branch_10_6 and branch_9_12 keep both lines.
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the recommended fix, plus the wholelucene/queriestest suite with the fix and without it; the finder's three-test JUnit (-da, stock and with the fix); the 10.6.0 release jars (-ea,-da). Earlier runs in this hunt: the minimal test on645d4fe14a20(-ea,-da, 2026-10-29); maina9779ef3c193, where the code is identical; the releases 5.4.0, 5.5.5, 6.0.0, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2, and 5.2.1 and 5.3.0-5.3.2 before the change. Upstream main2f424fd428da,branch_11xandbranch_10_6were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:76-100 (at a9779ef3c193) (constructors; javadoc '@param payloadToMatch The List of payloads to match', no null check), :212-233 (PayloadChecker.collectLeaf: a null expected entry -> matches = payload == null; a non-null entry against a payload-less position -> no match), :246-260 (toString: buffer.append(Term.toString(bytes)) for every entry)lucene/core/src/java/org/apache/lucene/index/Term.java:108-122 (at a9779ef3c193) (toString(BytesRef) wraps termText.bytes; line 116)lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:176-192 (at a9779ef3c193) (explain: 'weight(' + getQuery() + ' in ' + doc + ...)
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:47, :76-100, :179-186, :212-237, :246-260, :264-280lucene/core/src/java/org/apache/lucene/index/Term.java:108-122lucene/core/src/java/org/apache/lucene/search/Query.java:47-57lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:166-198lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:941-944, :959-967lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingIndexSearcher.java:64-68; QueryUtils.java:109-112
- Why it looks unintended
lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:76-100, :221-230, :252-255lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:89lucene/analysis/common/src/java/org/apache/lucene/analysis/payloads/DelimitedPayloadTokenFilter.java:56-65lucene/core/src/java/org/apache/lucene/search/Query.java:47-51; lucene/CHANGES.txt:9936-9937
- Diagrams
lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:221-225, :252-255; lucene/core/src/java/org/apache/lucene/index/Term.java:116; lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:182-183
- TestSpanPayloadCheckQueryNullEntryToString.java (lucene/queries test, one method):
repro/LFV-833/src/org/apache/lucene/queries/payloads/TestSpanPayloadCheckQueryNullEntryToString.java(37 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-833/bug.png(420 KB)
![LFV-833: one document whose field f holds a (no payload) and b (payload p). The query SpanPayloadCheckQuery(spanNear[f:a, f:b], [null, "p"]) matches it: count is 1. query.toString() and searcher.explain(query, 0) throw NullPointerException at Term.toString(Term.java:116), called from SpanPayloadCheckQuery.toString(SpanPayloadCheckQuery.java:253), because the null entry is handed to Term.toString.](../assets/LFV-833/bug.png)