All findings · Lucene finding · LFV-1206 · datetools-stringtodate-lenient-parse-accepts-malformed-strings

DateTools.stringToDate("2024123X") returns 2024-12-03 instead of throwing ParseException

DateTools.stringToDate("2024123X") returns 2024-12-03 instead of the ParseException its javadoc promises for strings not in the yyyyMMdd... format: a string of a supported length goes to a lenient SimpleDateFormat that ignores trailing text and rolls impossible fields over. The ISO date "2019-12-31" silently becomes 2018-10-31T17:00Z.

Summary

Priority
Low, rank 537DateTools.stringToDate and stringToTime silently return a different date, sometimes more than a year off, instead of the documented ParseException for a malformed string of a supported length such as an ISO date passed by mistake, in every release since 1.9. The wrong value is silent and the API is public, but no Lucene code calls these methods, so only applications that decode strings DateTools did not produce are hit.
Impact
minor
Since
1.9 (2006; the first DateTools commit, 2004)introduced 2004-09-05 · over 22 years
Affected
every release from 1.9 to 10.6.0 (all 157 run with the month-13 form) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of DateTools's contract, witnessed by an executable repro

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

The finding in 30 seconds

Diagram of the bug: the 8-character string 2024123X is matched against the yyyyMMdd format; 2024 is read as the year, 12 as the month and 3 as the day, and the trailing X is left unread, so DateTools.stringToDate returns 2024-12-03T00:00:00Z instead of throwing ParseException.
stringToDate("2024123X"): the length 8 picks the yyyyMMdd format, the lenient parse reads 2024, 12 and 3 and stops at the X, so 2024-12-03 comes back instead of a ParseException. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You decode a date string with DateTools.stringToDate (or stringToTime) and rely on the documented ParseException to reject strings that are not in the yyyyMMdd... format. It does throw for a wrong length ("2019123") or an unreadable start ("X0191231").
  2. You pass "2024123X": 8 characters, the length of a DAY string, but the last one is a letter. You expect a ParseException.
  3. The length picks the yyyyMMdd format. DateFormat.parse(String) reads 2024, 12 and 3, stops at the X, and does not mind that 1 of the 8 characters is left over.
  4. You get 2024-12-03T00:00:00Z, with no error, assertions on or off.
  5. The same happens to an ISO date ("2019-12-31" gives 2018-10-31T17:00:00Z, the dashes read as minus signs) and to impossible dates ("20041301", month 13, gives 2005-01-01). DateTools' own strings still parse correctly.

Who is affected

Applications that call the public static DateTools.stringToDate or stringToTime on strings that did not come from timeToString (user input, an external feed, an ISO date passed by mistake) and count on the ParseException to reject bad ones. No setting is involved and every release since 1.9 has it; no Lucene code calls these methods outside tests.

Mechanism, terms and numbers390 words

DateTools.stringToDate(String) and stringToTime(String) document @throws ParseException if dateString is not in the expected format, the yyyyMMddHHmmssSSS-or-shorter format that timeToString writes. They throw for a wrong length or an unreadable start, but a string of one of the seven supported lengths (4, 6, 8, 10, 12, 14, 17) goes to a lenient SimpleDateFormat through DateFormat.parse(String) (DateTools.java:117 and :198-199 at 5adff78bb168), which ignores trailing text and rolls impossible fields over. So "2024123X" returns 2024-12-03T00:00:00Z, the ISO date "2019-12-31" returns 2018-10-31T17:00:00Z and "20041301" (month 13) returns 2005-01-01T00:00:00Z, with no exception and the same result with assertions on and off. No Lucene code calls these methods outside tests, and DateTools' own strings parse correctly. Every release since 1.9 (2006) has it.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/document/DateTools.java)

  1. Lenient formats. Resolution builds each format as new SimpleDateFormat("yyyyMMddHHmmssSSS".substring(0, formatLen), Locale.ROOT) with the GMT zone (:198-199, a fix site) and never calls setLenient(false). TL_FORMATS keeps per-thread clones indexed by length (:54-65).
  2. Chosen by length. stringToDate calls TL_FORMATS.get()[dateString.length()].parse(dateString) (:117, the other fix site). A length with no format hits an empty slot or an out-of-range index, and the catch-all (:118-121) turns that into ParseException("Input is not a valid date string: " + dateString); that is why wrong lengths are rejected.
  3. Prefix parse. The JDK documents DateFormat.parse(String) as parsing "from the beginning of the given string"; it "may not use the entire text" and throws only if the beginning cannot be parsed. For "2024123X" the yyyyMMdd parse reads 2024, 12, 3 and stops: 7 of 8 characters used.
  4. Roll-over. The lenient calendar turns month 13 of 2004 into January 2005 and Feb 30 into March 1. Minus signs are read as signs: "2019-12-31" is consumed in full and its result equals a lenient calendar set to year 2019, month -1, day 2, hour -31.

The numbers (unpatched main; the same probe on the fixed code in the last column)

input (length)todaywith the fix
"20191231" (8), a real DateTools string2019-12-31T00:00:00Zsame
"2024123X" (8)2024-12-03T00:00:00ZParseException
"201X" (4)0201-01-01T00:00:00ZParseException
"2019-12-31" (10), an ISO date2018-10-31T17:00:00ZParseException
"20041301" (8), month 132005-01-01T00:00:00ZParseException
"20240230" (8), Feb 302024-03-01T00:00:00ZParseException
"2019123" (7), "X0191231", "aaaa"ParseExceptionParseException

The one-test repro checks only "2024123X"; the other rows come from a separate probe (sources).

Exposure and likelihood132 words
  • Who. Code that decodes or validates date strings it did not write with DateTools, for example a form field or a feed value checked with stringToDate before it becomes a range bound or a stored value.
  • Effect. A silently wrong Date, sometimes far from the input: "2019-12-31" lands about 14 months earlier, "201X" in the year 201.
  • Default vs opt-in. A plain public static utility; no setting.
  • Not hit. Lucene itself: outside tests no Lucene code calls either method (the only callers are TestDateTools and the benchmark's TestTrecContentSource, which decodes DateTools' own output). DateTools' own strings for years AD 1..9999 parse correctly, today and with the fix; outside that range they do not round-trip even today (LFV-645, a different bug).
  • Scale. One call; no data, no concurrency.

Minimal repro

One JUnit test method on the public API: expectThrows(ParseException.class, () -> DateTools.stringToDate("2024123X")). Deterministic (DateTools uses GMT and Locale.ROOT), single thread; it fails with assertions on and off.

TestDateToolsTrailingCharacter.java (lucene/core, package org.apache.lucene.document, 1 test)28 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.document;

import java.text.ParseException;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDateToolsTrailingCharacter extends LuceneTestCase {

  // Expected: ParseException ("2024123X" is not yyyyMMdd); actual: returns 2024-12-03T00:00:00Z.
  public void testTrailingLetterIsRejected() {
    expectThrows(ParseException.class, () -> DateTools.stringToDate("2024123X"));
  }
}
Command
cp $REPRO/TestDateToolsTrailingCharacter.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDateToolsTrailingCharacter -Ptests.seed=DEADBEEF
# same failure with -Ptests.asserts=false
Expected
stringToDate("2024123X") throws ParseException.
Observed
AssertionFailedError: Expected exception ParseException but no exception was thrown (the call returns 2024-12-03T00:00:00Z), with assertions on and off.

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

More files, environment, full output172 words

TestDateToolsTrailingCharacter (one method, extends LuceneTestCase, package org.apache.lucene.document of the lucene/core tests). It carries the Apache license header and passes checkGoogleJavaFormat. It uses the same expectThrows idiom as upstream TestDateTools.testStringToDate; as the regression test, the same line fits there next to the "aaaa" case. The test uses no randomness; the gradle run used a random test locale and time zone and still fails, so the seed does not matter. With the strict-parse fix it passes unchanged.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; the same call (in equivalent tests) also fails on 1.9.1, 9.12.3, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (pure Java; only this platform was run)
Command (environment)
cp $REPRO/TestDateToolsTrailingCharacter.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDateToolsTrailingCharacter -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF
TestDateToolsTrailingCharacter > testTrailingLetterIsRejected FAILED
    junit.framework.AssertionFailedError: Expected exception ParseException but no exception was thrown
        at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at org.apache.lucene.document.TestDateToolsTrailingCharacter.testTrailingLetterIsRejected(TestDateToolsTrailingCharacter.java:26)

# gradle, same, -Ptests.asserts=false: the same failure

# with the fix: PASS with assertions on and off

Why it looks unintended

  • Both methods document @throws ParseException if dateString is not in the expected format (yyyyMMdd...); a letter where a digit belongs is not in it.
  • Upstream TestDateTools already expects ParseException for strings DateTools never writes ("97", "aaaa").
  • The JDK's own javadoc calls leniently parsed input "not in the form" of the format and offers setLenient(false); DateTools never calls it.
The full argument231 words
  • Contract. @throws ParseException if <code>dateString</code> is not in the expected format (DateTools.java:101, :113); the format is yyyyMMddHHmmssSSS or shorter (:75, :87).
  • Tests. TestDateTools.testStringToDate (TestDateTools.java:41-57) expects ParseException for "97", "200401011235009999" and "aaaa", each commented "no date". No test covers a trailing character or an out-of-range field.
  • History. The first DateTools (c7fbb991c3e, 2004) threw its own ParseException("Input is not valid date string: " + dateString) for every unsupported length, then parsed with the default leniency. git log -S'setLenient' and -S'ParsePosition' over the file's full history find no commit, and the prior-report searches found no Lucene issue or thread about it: the leniency is the JDK default, and no recorded decision either way was found.
  • The JDK's words. DateFormat.parse(String, ParsePosition): "By default, parsing is lenient: If the input is not in the form used by this object's format method but can still be parsed as a date, then the parse succeeds. Clients may insist on strict adherence to the format by calling setLenient(false)."
  • Precedent. #9686 (LUCENE-8640, d482312b854, 2019) added delimiter checks and per-field range checks to a sibling date parser, spatial-extras' DateRangePrefixTree.parseCalendar, so that it rejects strings such as 2000-11T13 instead of reinterpreting them. A precedent for validating the syntax, not a fully strict parser: it checks the day only as 1..31 and still rolls Feb 30 over (LFV-619).
The devil's-advocate check204 words

The strongest case for "not a bug" (the independent cross-model reviewer's verdict, high confidence): the javadoc's first sentence is "Converts a string produced by timeToString or dateToString back to a time", so other strings are outside the contract; leniency is the documented JDK default; the design is unchanged since 2004 and no report of it was found; a javadoc sentence would do.

Why it fails: that sentence states the purpose, not a precondition (no "must" or "only"), and the @throws clause in the same javadoc says what happens to other strings. Read strictly, the purpose sentence would leave the clause only DateTools' own output after year 9999 (timeToString writes "10000" and stringToTime throws on it: LFV-645, itself a bug). The reviewer did not read it that strictly: they took the clause to cover "strings that cannot be parsed in a supported resolution format", which already puts strings DateTools never writes in scope, as the upstream test does with "aaaa". What remains is how strict "the expected format" is, and a letter where the format has a digit is outside it under any reading. The argument does bear on likelihood, hence Low; a maintainer may still prefer a javadoc fix (option C).

Suggested fix

Recommended Strict parse: setLenient(false) plus a whole-string ParsePosition check (fix A)

Make the parse strict: setLenient(false) on each Resolution format, and parse(String, ParsePosition) with a check that the whole string was used (+9/-1 in DateTools.java). Both parts are needed. The repro passes with assertions on and off; the closest core, query-parser and benchmark suites: 188 tests, 0 failures, 1 skipped by its suite.

      SimpleDateFormat format = TL_FORMATS.get()[dateString.length()];
      ParsePosition pos = new ParsePosition(0);
      Date date = format.parse(dateString, pos);
      if (date == null || pos.getIndex() != dateString.length()) {
        throw new ParseException("Unparseable date: " + dateString, pos.getErrorIndex());
      }
      return date;
      // and in Resolution(int formatLen): this.format.setLenient(false);
All options and trade-offs3 options · 486 words

Option A was built and checked through gradle on 5adff78bb168; B and C were not run. Both parts of A are needed: non-leniency alone still accepts "2024123X" (the prefix parse), and the whole-string check alone still accepts Feb 30 (all 8 characters are used), as a JDK-only replica of the format shows. With A, every malformed string in the table above throws, real DateTools strings parse to the same value, and 2,100,000 timeToString outputs (7 resolutions x 300,000 random times in AD 1..9999) round-trip with 0 rejected and 0 mismatched, as on unpatched main. Separately, a strict, whole-string SimpleDateFormat (not the patched class) read 14,000,000 real timeToString outputs, with the days around the 1582 calendar switch, back identically: compatibility evidence for strict parsing. Outside AD 1..9999 DateTools' output already fails to round-trip today (LFV-645); this fix does not address that. Formatting does not validate fields, so timeToString is unaffected.

Strict parse: setLenient(false) plus a whole-string ParsePosition check (fix A) (validated by the checks) (recommended)

In Resolution, call this.format.setLenient(false); in stringToDate, parse with a ParsePosition and throw unless the whole string was used. The inner ParseException goes through the existing catch-all, so callers see the same exception type and message as for a wrong length.

      SimpleDateFormat format = TL_FORMATS.get()[dateString.length()];
      ParsePosition pos = new ParsePosition(0);
      Date date = format.parse(dateString, pos);
      if (date == null || pos.getIndex() != dateString.length()) {
        throw new ParseException("Unparseable date: " + dateString, pos.getErrorIndex());
      }
      return date;
      // and in Resolution(int formatLen): this.format.setLenient(false);

Trade-offs

A caller that relies on today's leniency (Feb 30 on purpose) now gets ParseException; no Lucene code does. It is not a full syntax check: SimpleDateFormat's number parsing still accepts non-ASCII digits (full-width "20241203" gives 2024-12-03) and a space before a field ("2004 101" gives 2004-01-01), with and without the fix; an ASCII-digit check before parsing would close those too (not built). One ParsePosition per call.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off).
  • With the fix: the repro, TestDateTools (5), TestDateSort (1), TestCustomSearcherSort (2), TestQueryParser (73), TestQPHelper (38, 1 skipped by the suite), TestStandardQP (46), TestPrecedenceQueryParser (16) and TestTrecContentSource (6): 188 tests, 0 failures. :lucene:core:checkGoogleJavaFormat passes.

A java.time formatter with ResolverStyle.STRICT (not built)

Parse with a DateTimeFormatter per resolution in strict mode instead of SimpleDateFormat.

Trade-offs

Also strict, but java.time uses the proleptic ISO calendar while DateTools formats with GregorianCalendar (Julian before 1582) and the year-of-era pattern, so dates before 1582 would no longer round-trip unless timeToString changes too: a larger change that overlaps LFV-645.

Document the leniency only (not built)

Say in both javadocs that strings of a supported length are parsed leniently and may return a rolled-over date.

Trade-offs

Smallest change, but callers who rely on the @throws clause still get a wrong date silently.

Downstream note

Downstream, an application that validates user-supplied date strings with stringToDate accepts a malformed one as a different date instead of rejecting it; Lucene's own code never calls it.

How it was found

A proof-obligation audit of DateTools' contract: the @throws ParseException clause obliges both methods to reject every string that is not in the yyyyMMdd... format, and a lenient prefix parse cannot meet that for strings of a supported length. A one-call public-API JUnit test witnessed it.

The method in full115 words

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

  • The finder read stringToTime and stringToDate against their @throws obligation and traced which strings reach the parse: only the length is checked, then DateFormat.parse(String) on a lenient format, which by its own javadoc needs only a parseable beginning.
  • The finder wrote a public-API test (month 13) that fails on main with assertions on and off, on 10.6.0 and 9.12.3; the tie-breaker replaced it with the trailing-letter test above, which no reading of the format allows.
  • An independent reproducer, an executed cross-model review, a devil's-advocate verifier, an adjudication and the tie-breaker reran it with their own tests ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-03). The nearest item, #9686 (LUCENE-8640), added delimiter and per-field range checks to a sibling date parser in spatial-extras.

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report: #9686 (LUCENE-8640, delimiter and per-field range checks added to DateRangePrefixTree.parseCalendar, a precedent), #2546 (LUCENE-1472, lock contention in the same method), #2810 (LUCENE-1736, the restructuring behind today's formats) and #2842 (LUCENE-1768), where Uwe Schindler wrote in 2011 that DateTools "is somehow deprecated (not officially)". Limits: keyword search; GitHub search does not index review comments; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03, re-checked the same daytwo scripted prior-report searches; 52 manual searches (setLenient, ParsePosition, "not a valid date string", "not in the expected format", "month 13"); bodies and comments of 55 items grepped for lenien/setLenient/ParsePosition; everything updated since 2026-11-01no report; #9686, #2546, #2810, #2842 are the nearest items
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRsnone touches DateTools.java or TestDateTools.java
Mailing lists, JIRA, downstream, web2026-11-03dev@, java-user@, issues@ and commits@ (7 threads read in full), 9 JIRA queries, apache/solr, Elasticsearch, OpenSearch and Lucene.NET trackers, 10 web queriesnothing about this bug; only generic SimpleDateFormat leniency Q&A

History

Present since DateTools was added (c7fbb991c3e, Daniel Naber, 2004; first release 1.9, 2006); every rewrite kept the lenient parse. All 157 final releases from 1.9.1 to 10.6.0 fail the month-13 form; the trailing-letter call (the repro or an equivalent test) fails on 1.9.1, 9.12.3, 10.5.2, 10.6.0 and main. Not fixed upstream.

Versions tested5 rows

Introduced by c7fbb991c3e "Add a new class that supports converting dates to readable strings, with limited resolution..." (Daniel Naber, 2004-09-05, SVN r150483; no PR or JIRA id): the length check, a lenient parse(dateString) and today's @throws text from the start. First release 1.9 (2006-02-27); 1.4.3 has no DateTools.

Later rewrites kept the lenient SimpleDateFormat and parse(String): LUCENE-491 (GMT), the static formats of 2.1, LUCENE-1653, LUCENE-1472 (ThreadLocal formats), LUCENE-1736 (today's TL_FORMATS), the Locale.ROOT change and LUCENE-9911 (initCause). Not fixed upstream: DateTools.java has the same blob on upstream main 1c2072f0284 (2026-11-02) and on 10.6.0; its last commit is dbaf97f5dcc (#14965, 2025-07-18, a lint cleanup). All runs used JDK 25.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
1.9.1, 9.12.3, 10.5.2, 10.6.0 trailing letter"2024123X" returns 2024-12-03T00:00:00Z (JUnit, or a main() form on 1.9.1)
1.9.1 to 10.6.0, all 157 final releases month 13stringToTime("20041301") returns 2005-01-01T00:00:00Z
c7fbb991c3e (2004) introducing commitits own DateTools.java fails the month-13 form
1.4.3 no DateToolsthe class does not exist

Independent checks

A finder, an independent reproducer, a devil's-advocate verifier and a tie-breaker say real (Low). An independent cross-model reviewer (OpenAI GPT-5.6) reproduced it and agreed on cause and fix but judged it intended; the adjudication called it disputed. This doc's gradle runs reproduced it and validated the fix.

Checks and verdicts282 words

The cross-model review ran on main ea57360912ed, where DateTools.java is unchanged, and its test was re-run on 5adff78bb168 with the same result. The other checks on main ran on 5adff78bb168; the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedMonth-13 test fails on main (assertions on and off), 10.6.0 and 9.12.3; the probe shows trailing text, minus signs, inner spaces and out-of-range fields accepted; the fix passes the repro and the 5 upstream TestDateTools tests.
independent reproducer real, LowOwn test (Feb 30, "2024123X") fails on main, 10.6.0, 10.5.2 and 9.12.3; its JDK-only check reports the two causes, so both fix parts are needed.
cross-model review, refute lens (OpenAI GPT-5.6, executed) not a bug (high)Reproduced the behaviour (own test fails 2 of 3, controls pass) and agreed on cause and fix; argued the inputs are outside the contract (see the devil's advocate).
devil's-advocate verifier real, LowRebutted each intent argument; its own strict parser read 14,000,000 timeToString outputs back identically; "2004-01-15" gives 2003-11-30T09:00:00Z.
adjudication disputed (medium)Facts and cause agreed by all, only intent disputed; re-ran two tests on main: fail.
tie-breaker confirmed, LowOwn test fails on main and 10.6.0 ("201X", "2019-12-31"); "2024123X" also accepted on 1.9.1; chose the one-test repro.
separate re-run, audit and final repro check fails, fails; confirmed, LowThe one-test repro fails on main with assertions on and off; the final check set the impact class to minor.
this doc reproduced; fix A validatedGradle: fails without the fix, passes with it; 188 tests of the closest suites, 0 failures.

Diagram file

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

Sources and provenance

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

Finding record

LFV-1206, snapshot of 2026-11-06

Registry title
DateTools.stringToDate and stringToTime parse with a lenient SimpleDateFormat and ignore trailing text, so a string of a supported length that is not in the yyyyMMdd... format ("2024123X", the ISO date "2019-12-31", month 13) silently returns another date instead of the documented ParseException (since 1.9)
Area
core / document (DateTools string-to-date decoding: stringToDate and stringToTime)
Priority
Low, rank 537: DateTools.stringToDate and stringToTime silently return a different date, sometimes more than a year off, instead of the documented ParseException for a malformed string of a supported length such as an ISO date passed by mistake, in every release since 1.9. The wrong value is silent and the API is public, but no Lucene code calls these methods, so only applications that decode strings DateTools did not produce are hit.
Impact class
minor
Impact note
Hits applications that call the public static DateTools.stringToDate or stringToTime on date strings that did not come from timeToString/dateToString (a user-typed or external value, an ISO date such as "2019-12-31" passed by mistake, a value with a stray character, an impossible date such as month 13 or February 30) and rely on the documented ParseException to reject bad ones: a string whose length is one of the seven DateTools lengths (4, 6, 8, 10, 12, 14, 17) and whose beginning reads as digits silently comes back as another Date, sometimes more than a year away ("2019-12-31" gives 2018-10-31T17:00:00Z), with no setting and no scale needed, identically with -ea and -da, in every release since 1.9 (2006); strings of other lengths and unreadable beginnings still throw, DateTools' own output in years AD 1..9999 parses correctly, and no Lucene code calls these methods outside tests.
Summary
You call DateTools.stringToDate (or stringToTime) on a string that is not in DateTools' yyyyMMdd... format, for example "2024123X" or the ISO date "2019-12-31", and expect the ParseException that both methods document ('if dateString is not in the expected format') and already give for a wrong length or an unreadable start such as "aaaa". Instead, because the string has one of the seven supported lengths, it goes to a lenient SimpleDateFormat through DateFormat.parse(String), which ignores trailing text and rolls impossible fields over, so a different date comes back with no error: "2024123X" gives 2024-12-03T00:00:00Z, "2019-12-31" gives 2018-10-31T17:00:00Z and "20041301" (month 13) gives 2005-01-01T00:00:00Z.
Introduced
c7fbb991c3e55fb6c3c3f3d2f0757176a7eccb06 (trunk, svn r150483, 'Add a new class that supports converting dates to readable strings, with limited resolution...', Daniel Naber; svn era, no PR and no JIRA or Bugzilla id; CHANGES.txt 1.9 'New features' item 4): the first DateTools already chose the pattern by dateString.length(), threw its own ParseException for any other length and returned new SimpleDateFormat(pattern).parse(dateString), a lenient (JDK default) prefix parse, under today's '@throws ParseException if dateString is not in the expected format' [read: git show in the full-history clone; exec: that commit's own DateTools.java, shadowed over the 1.9.1 core, fails the month-13 test]. Its companion test commit bdcaaa7e623 asserted ParseException only for '97', '200401011235009999' and 'aaaa'. Not a regression: every later rewrite kept a lenient SimpleDateFormat and parse(String) (646d9646cbb LUCENE-491 GMT zone, 2006; f72c1e579fe static formats, 2006; 3c3b283e3c0 LUCENE-1653, 2009; fcf6e305b48 LUCENE-1472 ThreadLocal formats, 2011; 31479ab48ce LUCENE-1736 TL_FORMATS indexed by length and built from Resolution.format, 2011, today's :117 and :194-200; b6a4748f21b Locale.ROOT, 2012; 2971f311a2b LUCENE-9911 initCause, 2021), and git log -S'setLenient' and -S'ParsePosition' over the full history of DateTools.java find no commit [read]. (2004-09-05 (author and commit date of c7fbb991c3e, 2004-09-05T21:27:29Z))
First affected version
1.9 [read] (1.9-rc1 2006-02-21, 1.9 final 2006-02-27; the first release with DateTools, so no release has the API without the defect; DateTools.java at the 1.9-rc1 and 1.9 tags is identical to 1.9.1's, and 1.9 is not on Maven Central); 1.9.1 [exec] (2006-03-02) is the first executed release and fails; 1.4.3 and earlier have no DateTools [exec: compile error]
Affected versions
Every release from 1.9 (2006) through 10.6.0 (4ae946e5a9b, released 2026-10-29) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec] month-13 form (stringToTime("20041301") returns 2005-01-01T00:00:00Z): all 157 final lucene-core releases on Maven Central from 1.9.1 to 10.6.0 (a main() form of the same call for releases without today's test framework; only the 4.0.0-ALPHA and -BETA pre-releases were not run), the introducing commit's own DateTools.java, 10.6.0 also with -da, and main with -ea and -da. Trailing-letter form (stringToDate("2024123X") returns 2024-12-03T00:00:00Z): main with -ea and -da (tie-breaker; re-run), 10.6.0 (-ea and -da), 10.5.2 and 9.12.3, and 1.9.1 (tie-breaker main() probe on the SHA-1-checked jar). [read] every release tag from 1.9-rc1 to 10.6.0 parses with SimpleDateFormat parse(dateString) and has no setLenient or ParsePosition; DateTools.java has the same blob dda55bf2fb13 on 5adff78bb168, on releases/lucene/10.6.0 and on upstream main 1c2072f0284 (2026-11-02), and its newest upstream commit is dbaf97f5dcc (#14965, author and committer date 2025-07-18, an ecj cleanup that does not touch parsing). Not affected: 1.4.3 and earlier (no DateTools). Not new in 10.6 (10.5.2 and every earlier executed release fail). All runs used JDK 25.0.3 on Linux aarch64; older JDKs were not executed (documented java.text behaviour).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it, an adjudication that rated it disputed and a tie-breaker that confirmed it (medium confidence), 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 tie-breaker's searches; rechecked the same day): two scripted prior-report searches (run 2: 30 GitHub issue/PR, 3 commit, 2 downstream, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 5 JIRA-archive and 9 web queries; the finder's run 1: 51 queries), 52 manual GitHub searches (apache/lucene including setLenient, ParsePosition, 'not a valid date string', 'not in the expected format' and 'month 13'; apache/solr, apache/lucene-solr, Elasticsearch, OpenSearch and apache/lucenenet; all of GitHub; items updated since 2026-11-01), the bodies and comments of 55 apache/lucene items grepped for lenien/setLenient/ParsePosition, the changed files of all 409 open apache/lucene PRs (none touches DateTools.java or TestDateTools.java), 9 JIRA queries, 7 list threads read in full, git log -S'setLenient' and -S'ParsePosition' over the full history (no commit) and 10 web queries: no issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports DateTools.stringToDate/stringToTime accepting a malformed string of a supported length. Nearest items, not this bug: #2546 (LUCENE-1472, lock contention in the same method), #2810 (LUCENE-1736, the restructuring that produced today's Resolution formats), #9686 (LUCENE-8640, the same kind of over-acceptance fixed in spatial-extras DateRangePrefixTree.parseCalendar, another class and fix site), #2842 (LUCENE-1768, where Uwe Schindler called DateTools 'somehow deprecated (not officially)' in 2011) and #7975 (LUCENE-6917, which only quotes a javadoc lint error on the @throws lines). Same-day recheck: GitHub search on apache/lucene for 'DateTools lenient', 'DateTools ParseException' and 'DateTools malformed' (0 each), 'stringToDate' (#2147, #2727, #2546), 'stringToTime' (#1146, the older DateField class, and #2546), open PRs 'DateTools' (0), DateTools items updated since 2026-11-01 (0); the changed files of the 28 open and 13 closed PRs updated since 2026-11-01 include neither DateTools.java nor TestDateTools.java; the newest upstream main commit on DateTools.java is still dbaf97f5dcc (author and committer date 2025-07-18, #14965).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle in a scratch worktree with assertions on and off, without and with the strict-parse fix (the repro, TestDateTools, TestDateSort, TestCustomSearcherSort, four query-parser suites, TestTrecContentSource, checkGoogleJavaFormat), a supporting probe on the unpatched jar and the patched classes, and a standalone JUnit runner on the main jars. The hunt ran the month-13 form on all 157 final releases from 1.9.1 to 10.6.0 and on the 2004 commit's own code, and the trailing-letter form on 1.9.1, 9.12.3, 10.5.2, 10.6.0 and main. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:70-93 (at 5adff78bb168) (dateToString/timeToString: '@return a string in format yyyyMMddHHmmssSSS or shorter, depending on resolution' at :75 and :87), :95-105 (stringToTime: 'Converts a string produced by timeToString or dateToString back to a time' at :96-97, '@throws ParseException if dateString is not in the expected format' at :101, delegates to stringToDate at :104), :107-123 (stringToDate: the same @throws at :113; :117 TL_FORMATS.get()[dateString.length()].parse(dateString), fix site; :118-121 catch-all that turns any exception into ParseException 'Input is not a valid date string'), :194-200 (Resolution: new SimpleDateFormat("yyyyMMddHHmmssSSS".substring(0, formatLen), Locale.ROOT) with the GMT zone and no setLenient(false), fix site)
  • lucene/core/src/test/org/apache/lucene/document/TestDateTools.java:41-57 (at 5adff78bb168) (testStringToDate asserts ParseException for '97', '200401011235009999' and 'aaaa', each commented 'no date'; no out-of-range or trailing-character case)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:54-65, (at 5adff78bb168) :107-123, :194-200
  • JDK 25 java.base java/text/DateFormat.java:410-421 (parse(String)) and :438-441 (lenient by default)
Who is affected
  • Lucene main 5adff78bb168 grep of lucene/ for stringToDate/stringToTime (TestDateTools, TestTrecContentSource only)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:70-123 (at 5adff78bb168) (javadoc and stringToDate)
  • lucene/core/src/test/org/apache/lucene/document/TestDateTools.java:41-57 (at 5adff78bb168)
  • JDK 25 java.base java/text/DateFormat.java:438-441
  • https://github.com/apache/lucene/issues/9686 (LUCENE-8640)
Diagrams
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:107-123, (at 5adff78bb168) :194-200

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

Repro files

About the diagrams

The diagram illustrates the repro's call (main 5adff78bb168, DateTools.java:107-123, :194-200).

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.