All findings · Lucene finding · LFV-833 · spanpayloadcheckquery-tostring-null-payload-entry-npe

SpanPayloadCheckQuery.toString() and explain() throw NullPointerException for a null payload entry, which the search itself accepts as "no payload here"

SpanPayloadCheckQuery reads a null entry in its payload list as "this position has no payload" and searches correctly with it, but toString() and IndexSearcher.explain() on that query throw NullPointerException. Every release from 5.4.0 to 10.6.0 and main have it.

Summary

Priority
Low, rank 705Search 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
crash
Since
5.4.0 (LUCENE-6716, 2015), which gave a null entry its meaningintroduced 2015-09-14 · over 11 years
Affected
every release from 5.4.0 to 10.6.0, and main
Status
documented
Found by
bounded-exhaustive comparison with a reference model of payload checks

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

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.

Background: payload checks

  1. A payload is a small byte array that a token can carry at its position in the index; many tokens carry none.
  2. SpanPayloadCheckQuery takes 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.
  3. In that list a null entry means "this position must have no payload"; a non-null entry never matches a position without one.
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.
The two-token example: the search reads the 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.

The finding, step by step

  1. Index one document whose field f holds two tokens: a with no payload, then b with the payload p.
  2. Query it with SpanPayloadCheckQuery(spanNear[f:a, f:b], [null, "p"]), meaning "a has no payload, b has p". The search honours the null: count returns 1, and ["p", "p"] returns 0.
  3. You expect query.toString() to print the query and searcher.explain(query, 0) to explain the hit.
  4. Instead both throw NullPointerException: Cannot read field "bytes" because "termText" is null, with assertions on and off: toString() hands every entry, the null too, to Term.toString(BytesRef), and explain puts the query string into its description.
  5. Search, count, equals and hashCode stay correct and the index is untouched; only printing and explaining such a query fail.

Who is affected

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 mechanism (main 645d4fe14a20)

  1. 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 mentions null entries.
  2. The matcher reads null as "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;
  1. toString passes every entry to Term.toString (:246-260):
for (BytesRef bytes : payloadToMatch) {   // :252
  buffer.append(Term.toString(bytes));    // :253  NullPointerException when bytes == null
  1. Term.toString(BytesRef) dereferences it: decoder.decode(ByteBuffer.wrap(termText.bytes, termText.offset, termText.length)) (Term.java:116-117, reported at :116). Query.toString() calls toString("") (Query.java:55-57).
  2. Explain prints the query. IndexSearcher.explain(Query, int) (IndexSearcher.java:941-944) reaches SpanWeight.explain, whose description is "weight(" + getQuery() + " in " + doc + ... (SpanWeight.java:180-189; the no-score branch at :192-193 formats getQuery() too), so in an index with positions explain throws for every document that matches; a non-matching document gets Explanation.noMatch (SpanWeight.java:198), which does not print the query.
  3. Not affected: equals and hashCode (SpanPayloadCheckQuery.java:264-280) use List.equals and Objects.hashCode, which accept null; in an index with positions, search and count do not print the query. One error path does: on a field indexed without positions, SpanPayloadCheckWeight.scorerSupplier builds an IllegalStateException message that contains the query (SpanPayloadCheckQuery.java:179-186), so on such a field the query throws this NPE instead of that IllegalStateException. It fails there either way; only the exception changes (read, not run).
  4. Test framework only: AssertingIndexSearcher.rewrite calls QueryUtils.check (AssertingIndexSearcher.java:64-68), whose checkUnequal builds q1 + " equal to " + q2 eagerly (QueryUtils.java:110), so under LuceneTestCase.newSearcher even search fails (read, not run separately). The repro uses no searcher at all.
Exposure and likelihood203 words
  • Who is hit. Code that builds SpanPayloadCheckQuery with a null entry in payloadToMatch and 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 plain IndexSearcher; assertions on and off. Opt-in on the API side: a lucene-queries class that applications build directly in Java code; no Lucene query parser builds it (outside tests only SpanPayloadCheckQuery.java and PayloadMatcherFactory.java name it). Solr's payload_check parser adds only encoded, non-empty payloads, never null. List.of(...) rejects null, so a caller uses Arrays.asList, Collections.singletonList or an ArrayList.
  • Effect. One unchecked NullPointerException from toString() or explain(). 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 BytesRef cannot stand in for null, see below); callers can only avoid printing or explaining such a query.

Minimal repro

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.

TestSpanPayloadCheckQueryNullEntryToString.java (lucene/queries test, one method)37 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.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);
    }
  }
}
Command
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 prints SpanPayloadCheckQuery(f:a, payloadRef: null;, payloadType:STRING;, operation:EQ;).
Observed
NullPointerException: Cannot read field "bytes" because "termText" is null at Term.java:116 from SpanPayloadCheckQuery.java:253, with assertions on and off.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

# 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)

Why it looks unintended

  • The matcher gives null a 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 BytesRef matches 0 documents where null matches 1.
  • The constructor accepts it, and toString() and explain() state no precondition against it.
The full argument235 words
  • The null meaning is deliberate code. LUCENE-6716 (22cc3a1560e, Alan Woodward (romseygeek), 2015-09-14) replaced the old collector, which skipped positions without payloads, with PayloadChecker and 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 stock WhitespaceTokenizer + DelimitedPayloadTokenFilter (a token without a delimiter gets setPayload(null), DelimitedPayloadTokenFilter.java:64-65), an empty-BytesRef entry matches 0 documents where a null entry matches 1.
  • No precondition. Query.toString(String) "Prints a query to a string" (Query.java:47-51); IndexSearcher.explain states nothing that excludes a query it executes correctly.
  • Nothing shows that the toString line considered it. It came 18 minutes after the null branch, from the same author, in "LUCENE-6716: Better toString() for SpanPayloadCheckQuery" (13a64196d0d); the first List version (bytes.utf8ToString()) also threw on null, and the available history and tests show no handling of a null entry in toString.
  • Precedent. Upstream fixed toString and explain NPEs of other classes as bugs, e.g. LUCENE-8130 (#9178, TermStates.toString) and LUCENE-9524 (an NPE in SpanWeight#explain with another cause).
  • Tests and docs. No upstream test builds a null entry; 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.

Suggested fix

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 payload
All 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 payload

Trade-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 645d4fe14a20 with the repro test added, seed DEADBEEF: the repro passes with -Ptests.asserts=true and false.
  • The whole :lucene:queries:test with 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:checkGoogleJavaFormat passes with the fix.
  • With the fixed class shadowed, the finder's three-test JUnit passes 3 of 3: toString(), explain() (a matching Explanation) and the search control.

Reject null entries in the constructor and document it (not run)

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.

Make Term.toString(BytesRef) null-safe (not run)

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 null terms elsewhere.

Downstream note

Downstream products are affected only if their own code builds a SpanPayloadCheckQuery with a null entry; Solr's payload_check parser never adds one.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, all of GitHub), JIRA LUCENE and SOLR, the Lucene mailing lists, the web2026-10-28146 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 PRsno report of this bug; 20 hits, none the same bug
GitHub (apache/lucene), read-only2026-10-28 and 2026-10-30issues 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_6nothing new; the file is unchanged upstream

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
3.1 to 5.3.2 outside the defecttoString() 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 commitsearch matches, toString and explain throw: main's behaviour; its parent gives stock 5.3.2
5.4.0 first releasefinder'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 affected2 of 3 fail each, the search control passing
10.6.0 (4ae946e5a9b9) affectedminimal test fails, -ea and -da
main 645d4fe14a20 affectedminimal test fails (gradle and standalone, assertions on and off); finder's test 2 of 3
upstream main 2f424fd428da, branch_11x, branch_10_6 source readsame code (2026-10-30)
main 645d4fe14a20 + null-marker fix fixminimal test passes; whole lucene/queries suite 513 tests, 0 failures

Independent checks

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).

Each independent check and its verdict
CheckVerdictWhat it found
finder (bounded-exhaustive translation validation) candidate133,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, highOwn 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 realOwn 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 lowStock-analyzer JUnit fails 2 of 4 on main and 10.5.2; an empty BytesRef matches 0 documents, null 1.
adjudication confirmed, mediumRe-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 keepFresh 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 validatedGradle: stock fails, fix passes (-ea, -da); whole lucene/queries suite 513 tests, 0 failures with the fix; 10.6.0 fails.

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,188 words

Finding record

LFV-833, snapshot of 2026-11-06

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 whole lucene/queries test 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 on 645d4fe14a20 (-ea, -da, 2026-10-29); main a9779ef3c193, 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 main 2f424fd428da, branch_11x and branch_10_6 were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

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-280
  • lucene/core/src/java/org/apache/lucene/index/Term.java:108-122
  • lucene/core/src/java/org/apache/lucene/search/Query.java:47-57
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:166-198
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:941-944, :959-967
  • lucene/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-255
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:89
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/payloads/DelimitedPayloadTokenFilter.java:56-65
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.

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.