All findings · Lucene finding · LFV-645 · datetools-year-outside-1-9999-breaks-order-and-round-trip

DateTools strings stop sorting by date and stop reading back outside the years 1 to 9999

DateTools strings sort by date and read back only for the years 1 to 9999: 1200 BC is stored as 12000601, the string of AD 1200, and year 10000 as 100000101, which sorts before 2026 and does not read back. Every release since 1.9 is affected.

Summary

Priority
Low, rank 534DateTools gives wrong order and round trip only for boundary dates outside AD 1..9999 in a legacy encoding, so it is a Low boundary-value row after the out-of-range parser row LFV-082 and above its DateTools sibling LFV-283.
Impact
silent wrong results
Since
1.9 (2006; the class's first commit, 2004)introduced 2004-09-05 · over 22 years
Affected
1.9 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive check of the javadoc's order and round-trip promises

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

DateTools turns a date into a fixed-format string such as 20260101, so that sorting the strings sorts the dates and range queries work on them. The year is printed with yyyy: the year of the era, with no BC marker and at least four digits.

Diagram of DateTools.timeToString at DAY resolution: 2 BC-01-01 gives 00020101 (era dropped, the same as AD 2-01-01), AD 1-01-01 gives 00010101, 2026-01-01 gives 20260101, and 10000-01-01 gives 100000101 (a five-digit year, 9 characters). In date order versus string order the lines cross twice: 00010101 sorts before 00020101, and 100000101 before 20260101. A table compares expected and actual results: a range AD 1 to 2026 also returns the 2 BC and year-10000 dates, stringToTime("00020101") returns AD 2-01-01, stringToTime("100000101") throws ParseException, and at YEAR resolution 1 BC and AD 1 both give 0001. Dates inside AD 1 to 9999 are not affected.
Four dates and their DAY strings: 2 BC loses its era and 10000 gets a ninth character; sorted as strings, 2 BC lands after AD 1 and 10000 before 2026; and what a range query and stringToTime then return. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index dates as DateTools strings: timeToString(t, Resolution.DAY) gives 20260101 for 2026-01-01. The javadoc promises that sorting the strings sorts the dates, and that stringToTime turns a string back into the time.
  2. You expect that for every date: a document dated 1200 BC does not match a range query for AD 1000 to AD 1500, and year 10000 sorts after 2026.
  3. Before AD 1 the era is dropped: 1200 BC-06-01 and AD 1200-06-01 both become 12000601, so the AD 1000..1500 range matches the 1200 BC document, and 2 BC-01-01 (00020101) sorts after AD 1 (00010101) and reads back as AD 2-01-01. No error.
  4. After 9999 the year gets a fifth digit: 10000-01-01 becomes 100000101, which sorts between 999-12-31 (09991231) and 1000-01-01 (10000101), long before 20260101; stringToTime throws ParseException on it.
  5. The cause is the pattern yyyyMMdd... (DateTools.java:198): y is the year of the era, with no BC marker and at least four digits. stringToDate picks its parser by string length (:117), and no parser has 9 characters.

Who is affected

Applications that index dates as DateTools strings, or use the classic or flexible query parser with a date resolution, and have a date before AD 1 or after 9999: historical BC dates, a far-future "never" date, a typed year-10000 bound. Range hits, sort order and read-back go wrong with no error. Rare; every release since 1.9.

Mechanism, terms and numbers766 words

DateTools (core, org.apache.lucene.document) converts dates to strings for field values and search terms, and its class javadoc says "The strings are structured so that lexicographic sorting orders them by date"; stringToTime converts "a string produced by timeToString or dateToString back to a time". Neither sentence names a range, and timeToString accepts any long.

The year is printed with SimpleDateFormat's yyyy, which is the year of the era, with no era letter, at least four digits. So a BC year prints as the AD year with the same number (1200 BC-06-01 and AD 1200-06-01 both give 12000601; 2 BC-01-01 gives 00020101, sorts after AD 1 and reads back as AD 2-01-01), and a year after 9999 gets a fifth digit (10000-01-01 gives 100000101, sorts between 999 and 1000, and stringToTime throws ParseException on it because the parser is picked by string length). A TermRangeQuery for AD 1000 to AD 1500 matches a 1200 BC document; the classic and flexible query parsers with a date resolution turn date:[1/1/2000 TO 12/31/10000] into an inverted range that matches nothing, and date:[1/1/10000 TO 12/31/10001] into one that matches an AD 1000 document. Dates from 0001-01-01 to 9999-12-31 are unaffected. The code is unchanged since the class was added in 2004; every release from 1.9 to 10.6.0 (10.6.0 by reading) and main is affected. The recommended fix rejects out-of-range times with IllegalArgumentException and documents the range; indexed terms do not change.

Terms

  • DateTools: a public class in core (org.apache.lucene.document). timeToString(long, Resolution) and dateToString(Date, Resolution) write a date as a string in GMT at a chosen resolution, from YEAR (yyyy, 4 characters) to MILLISECOND (yyyyMMddHHmmssSSS, 17); stringToTime / stringToDate read it back. The class javadoc: "The strings are structured so that lexicographic sorting orders them by date, which makes them suitable for use as field values and search terms" (DateTools.java:28-31). It offers LongPoint as another approach (:37-40).
  • Query parser date ranges: with a date resolution set, the classic and flexible query parsers convert the bounds of field:[6/1/2005 TO 6/4/2005] with DateTools (QueryParser.java:50-60).

The bug

  1. timeToString rounds the time and formats it; it checks no range (DateTools.java:90-93).
  2. The format is new SimpleDateFormat("yyyyMMddHHmmssSSS".substring(0, formatLen), Locale.ROOT) (:198). In SimpleDateFormat, y is the year of era and the letter count is a minimum width; there is no era letter G.
  3. So 1200 BC prints 1200, exactly like AD 1200, and year 10000 prints 10000, one character more. Compared character by character, 100000101 (10000-01-01) is smaller than 10000101 (1000-01-01).
  4. stringToDate parses with TL_FORMATS.get()[dateString.length()] (:117); the array has formats only for lengths 4/6/8/10/12/14/17, so a 9-character string hits null, which is wrapped as ParseException (:118-121). A BC string has a normal length and parses as the AD year with the same number.
  5. The classic query parser converts each range bound inside its own catch (Exception e) {} (QueryParserBase.java:454-479); the flexible parser converts both bounds in one try and, in its catch, keeps the texts it has so far (TermRangeQueryNodeProcessor.java:100-142). A bound in year 10000 converts to a 9-character term that sorts among the year-1000 strings. With a later lower bound the range is inverted and matches nothing (date:[1/1/2000 TO 12/31/10000] gives 20000101..100001231). Otherwise it covers the wrong terms: date:[1/1/500 TO 12/31/10000] misses AD 1000, 1200 and 2026 documents, and date:[1/1/10000 TO 12/31/10001] matches an AD 1000 document (executed, classic and flexible). Both parsers read the typed date in the JVM default time zone (QueryParserBase.java:450), so near the edges a typed date can leave the GMT range: under America/Los_Angeles, date:[1/1/2026 TO 12/31/9999] ends in year 10000 GMT and misses a 2026 document.

The numbers

date (DAY resolution unless noted)stringwhat goes wrong
1200 BC-06-0112000601the string of AD 1200-06-01; matches a range AD 1000..1500; reads back as AD 1200
2 BC-01-0100020101sorts after AD 1-01-01 (00010101); reads back as AD 2-01-01, 1,096 days later
1 BC and AD 1 (YEAR)0001 for bothtwo years, one string
10000-01-01100000101sorts between 09991231 (999-12-31) and 10000101 (1000-01-01); stringToTime throws ParseException
query parser date:[1/1/2000 TO 12/31/10000]20000101..100001231inverted range, matches nothing
query parser date:[1/1/10000 TO 12/31/10001]100000101..100011231not inverted, but matches an AD 1000-06-01 document
query parser date:[1/1/500 TO 12/31/10000]05000101..100001231misses AD 1000, AD 1200 and 2026 documents
control: AD 1, 1582-10-15, 1970, 2026, 9999-12-31 23:59:59.999, all 7 resolutionsfour-digit yearssorted and read back exactly
Exposure and likelihood351 words
  • Who is exposed. Users of the legacy DateTools encoding (an explicit choice: its javadoc offers LongPoint, and the document package recommends LongField for dates) and of query-parser date ranges with setDateResolution, whose data or queries contain a date outside the years 1 to 9999: historical collections with BC dates; a "never" or "scheduled" date in year 10000 or later, or Long.MAX_VALUE; a bound typed as 12/31/10000.
  • What the damage looks like. Range and prefix queries match the wrong documents or none: a 1200 BC document matches AD 1000..1500, a range AD 2000..20000 found neither a 2026 nor a year-10000 document in the devil's-advocate verifier's run, and the parsed date:[1/1/10000 TO 12/31/10001] matches an AD 1000 document. Sorts put year 10000 between 999 and 1000. BC dates read back as the AD date with the same number. The only loud symptom is the ParseException when a year-10000 string is read back.
  • Extreme values. Long.MIN_VALUE and Long.MAX_VALUE give strings of 9 to 22 characters that start with 2922; most throw on read-back, and at MINUTE resolution both are silently parsed into a different time (30253281730712). round(Long.MIN_VALUE, YEAR) wraps to a positive time. This edge was confirmed by a verifier's probe only and is not the headline.
  • How likely. Rare: present-day data, date pickers and formatters do not produce such dates, and nobody has reported it since 1.9 (2006).
  • Scale. One call reproduces it; assertions on and off behave the same; nothing depends on index size, settings or concurrency.
  • Not affected. Dates from 0001-01-01 to 9999-12-31 GMT: the control passes at all 7 resolutions, and every day of that range at DAY resolution (3,652,061 days) sorts and reads back exactly; the other resolutions are sampled. Query parsers read typed dates in the JVM default time zone, so a typed date at the very edges can still fall outside the GMT range. Before 1582-10-15 the strings follow the Julian side of GregorianCalendar; they are consistent and in order, so they are not part of this bug. Dates indexed as LongPoint / LongField do not use DateTools.

Minimal repro

The test shows that DateTools.timeToString(2 BC-01-01 GMT, DAY) gives "00020101" (no era), and stringToTime turns DateTools' own string back into AD 2-01-01.

TestDateToolsBCRoundTrip.java (JUnit test)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.document;

import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.TimeZone;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDateToolsBCRoundTrip extends LuceneTestCase {

  public void testBCDateRoundTrip() throws Exception {
    Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"), Locale.ROOT);
    cal.clear();
    cal.set(Calendar.ERA, GregorianCalendar.BC);
    cal.set(2, Calendar.JANUARY, 1);
    long twoBC = cal.getTimeInMillis();
    String s = DateTools.timeToString(twoBC, DateTools.Resolution.DAY);
    // expected: stringToTime gives back 2 BC-01-01; actual: "00020101" parses as AD 2-01-01
    assertEquals("2 BC-01-01 GMT -> " + s, twoBC, DateTools.stringToTime(s));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-645/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.document.TestDateToolsBCRoundTrip
Expected vs actual
Expected -62198928000000 (2 BC-01-01); actual -62104233600000 (AD 2-01-01): 2 BC-01-01 GMT -> 00020101 expected:<-62198928000000> but was:<-62104233600000> (-ea and -da).

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

More files, environment, full output38 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 2 BC-01-01 GMT -> 00020101 expected:<-62198928000000> but was:<-62104233600000>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.document.TestDateToolsBCRoundTrip.testBCDateRoundTrip(TestDateToolsBCRoundTrip.java:35)

Why it looks unintended

  • The javadoc names no range ("lexicographic sorting orders them by date"), and timeToString accepts any long.
  • stringToTime reads back "a string produced by timeToString", yet returns AD 2 for 2 BC and throws on year 10000's string.
  • Earlier ordering breaks here were fixed as bugs (LUCENE-491, LUCENE-1846: "important to guarantee correct ordering of generated terms").
The full argument279 words
  • Contract. The ordering sentence has no condition (DateTools.java:28-31). timeToString takes "the date expressed as milliseconds since January 1, 1970, 00:00:00 GMT" (:85), any long, and validates nothing (:90-93). stringToTime / stringToDate convert "a string produced by timeToString or dateToString back to a time" (:96, :108); for year 10000 they throw on that string, for 2 BC they return another time. The @return text says "a string in format yyyyMMddHHmmssSSS or shorter" (:75, :87); a 9-character string has none of that format's prefix lengths (4, 6, 8, 10, 12, 14, 17), which is why stringToDate has no parser for it.
  • Tests. TestDateTools uses dates from 1961 to 2005 and 1970; its malformed inputs are 97, 200401011235009999 and aaaa. TestDateSort uses dates in October 2007 and TestCustomSearcherSort dates in February and March 1980. No test covers or pins a year outside 1 to 9999.
  • History. The 1.9 CHANGES entry introduced DateTools as the class that "can cope with dates before 1970". Two weeks after adding it, its author answered LUCENE-142 (#1220, historical dates, resolved as Fixed; its patch offered 1970 +/- 5,000 years, BC included) with "The new DateTools class now solves the 'pre-1970' problem". LUCENE-491 (#1569, time zone) and LUCENE-1846 (#2921, locale) fixed earlier ordering breaks; the LUCENE-1846 CHANGES entry says "The usage of the US locale is important to guarantee correct ordering of generated terms", and in that issue uschindler wrote that "the dates in DateTools should sort according to String.compare()".
  • Elsewhere in Lucene the era is handled on purpose: the spatial DateRangePrefixTree was built with explicit BC and +-year handling (LUCENE-5648, LUCENE-7278).
The devil's-advocate check222 words

The strongest "intended" case (built by the devil's-advocate verifier and the reviews by another model family, all of whom rated it moderate): read literally, @return a string in format yyyyMMddHHmmssSSS is exactly what SimpleDateFormat prints, BC and five-digit years included; the design has been fixed-width with four-digit years and a length-based parser since 2004; the class was written to fix "pre-1970", not BC or year 10000; a four-digit year field is commonly read as 0001 to 9999; the class is legacy (its javadoc offers LongPoint); changing the encoding would change indexed terms; nobody reported it in about 20 years.

Why it fails: no range is stated or checked anywhere (not in DateTools.java, and none of the 34 commits that touched it mentions a year limit, BC or an era; nothing found says BC or five-digit years were considered and left out), and the read-back sentence fails under the literal reading too, because the parser rejects or silently shifts DateTools' own output. Maintainers treat the ordering sentence as a contract (LUCENE-1846), and LUCENE-142 shows historical dates were in scope. Legacy status and term compatibility decide how to fix it (document and validate the range), not whether it is a bug. The adjudication's confidence is medium for exactly this reason: maintainers may well answer "document the range", which is the recommended fix.

Suggested fix

Recommended Reject times outside the years 1 to 9999 with IllegalArgumentException, and document the range

Make the limit explicit: timeToString and dateToString throw IllegalArgumentException for a time before 0001-01-01 or after 9999-12-31 GMT, and the javadoc says so (+38 lines). In-range terms do not change. Validated: the repro's 5 cases now fail loudly; core, queryparser and benchmark tests pass. Not covered: the query parsers' silent fallback and terms already indexed.

  public static String timeToString(long time, Resolution resolution) {
    if (time < MIN_TIME || time > MAX_TIME) {
      throw new IllegalArgumentException(
          "time must be between " + MIN_TIME + " (0001-01-01 00:00:00.000 GMT) and " + MAX_TIME
              + " (9999-12-31 23:59:59.999 GMT), got: " + time);
    }
    final Date date = new Date(round(time, resolution));
    return TL_FORMATS.get()[resolution.formatLen].format(date);
  }
All options and trade-offs3 options ยท 946 words

Option A is the compatible fix the adjudication, the devil's-advocate verifier and the security screen point to, validated here with gradle. Option B extends the encoding instead; option C only documents the range. Neither was run. With A, replace the issue repro by the fix's own test (TestDateTools.testYearRange), and add a CHANGES entry: callers that pass out-of-range sentinels get an exception from then on.

Reject times outside the years 1 to 9999 with IllegalArgumentException, and document the range (validated by the checks) (recommended)

timeToString (and through it dateToString) checks the time against two constants computed once with Calendar.getInstance(GMT, Locale.ROOT), the calendar round uses: 0001-01-01 00:00:00.000 GMT (-62135769600000) and 9999-12-31 23:59:59.999 GMT (253402300799999). The class javadoc gains a paragraph on the range, and both methods get @throws IllegalArgumentException (+38/-0 in DateTools.java). Checking time is enough: for a time already in the range, round moves it down to the start of its year, month, day, and so on, which stays in the range (outside it this does not hold: round(Long.MIN_VALUE, YEAR) wraps to a positive time).

  public static String timeToString(long time, Resolution resolution) {
    if (time < MIN_TIME || time > MAX_TIME) {
      throw new IllegalArgumentException(
          "time must be between " + MIN_TIME + " (0001-01-01 00:00:00.000 GMT) and " + MAX_TIME
              + " (9999-12-31 23:59:59.999 GMT), got: " + time);
    }
    final Date date = new Date(round(time, resolution));
    return TL_FORMATS.get()[resolution.formatLen].format(date);
  }

Trade-offs

  • Every string DateTools can still write has a four-digit AD year, so both javadoc promises hold for all of them, and existing in-range terms are byte-identical: no reindex for indexes that hold only in-range dates.
  • A behaviour change: callers that pass Long.MAX_VALUE / Long.MIN_VALUE sentinels or BC dates now get an exception instead of a wrong string. It needs a CHANGES/MIGRATE note; main is the natural target.
  • The query parsers stay silent. Each swallows the exception and keeps the unconverted text of the failing bound (QueryParserBase.java:454-479); the flexible parser converts both bounds in one try, so when the lower bound fails the upper one stays unconverted too (TermRangeQueryNodeProcessor.java:100-142). Executed with A for both parsers: date:[1/1/2000 TO 12/31/10000] becomes 20000101..12/31/10000, still inverted, count 0; date:[1/1/10000 TO 12/31/10001] stays raw text and still matches an AD 1000 document, as on stock; date:[1/1/10000 TO 12/31/2030] becomes 1/1/10000..20301231 in the classic parser (3 wrong hits on a 4-document index) and 1/1/10000..12/31/2030 in the flexible one (1 wrong hit). A follow-up could make an out-of-range date fail the parse, and decide what the flexible parser's shared try should keep.
  • Existing data. A neither detects nor repairs out-of-range terms already in an index: BC terms stay ambiguous (a BC term is the string of an AD date, so the instant cannot be recovered from it) and year-10000 terms stay unreadable. They need reindexing from the original timestamps; the CHANGES/MIGRATE note should say so.
  • Time zones. The range is in GMT, but the query parsers read a typed date in the JVM default time zone: with A, date:[1/1/2026 TO 12/31/9999] under America/Los_Angeles keeps the raw upper text 12/31/9999 (count 0, not 1), and date:[1/1/0001 TO 12/31/2030] under Asia/Tokyo keeps the raw lower text and still misses an AD 1-06-01 document (executed, both parsers).
  • Applications with real BC or year-10000 dates get no DateTools encoding for them; they need LongPoint / LongField, which the javadocs already recommend, or their own encoding. stringToDate is unchanged.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, JDK 25. The repro: on stock 5 of 6 fail (assertions on and off); with A the same 5 fail fast with IllegalArgumentException ("time must be between -62135769600000 ... got: -62198928000000") and the control passes, so no newly written out-of-range term is silently wrong (terms already in an index are untouched).
  • The new TestDateTools.testYearRange (the boundaries sort and read back at all 7 resolutions; one millisecond outside, Long.MIN_VALUE and Long.MAX_VALUE throw, for timeToString and dateToString) passes with A and fails on stock; the other 5 TestDateTools tests pass.
  • The callers of DateTools: org.apache.lucene.document.*, TestDateSort and TestCustomSearcherSort (61 suites, 502 tests), the whole :lucene:queryparser:test (30 suites, 533 tests) and :lucene:benchmark:test (18 suites, 90 tests): 0 failures with assertions on (seed 5EED0645) and off (5EED0646). The whole :lucene:core:test: 748 suites, 8,890 tests, 0 failures, 322 skipped.
  • The core lint run succeeded: checkGoogleJavaFormat, ecjLintMain and renderJavadoc ran and passed; forbiddenApisMain, forbiddenApisTest and ecjLintTest were reported up to date from an earlier successful run in the same worktree, whose log was not kept. Re-run them before the PR.

Not run: extend the encoding outside the range and keep in-range strings

Keep every string for the years 1 to 9999 byte-identical and add new forms only outside it, for example BC years as - followed by 10000 minus the year (- sorts before every digit, and the complement puts earlier BC years first), and years after 9999 behind a character that sorts after 9.

Trade-offs

Existing indexes stay valid and historical dates become usable, but it is a format extension of a legacy class: new parsers per length, a decision for the query parsers, more tests and maintainer agreement. Solr fixed the same SimpleDateFormat pitfall in its own formatter by moving to java.time (SOLR-8904); that is precedent for calling it a bug, not a sort-preserving design to copy (an ISO year with a minus sign does not sort BC years in date order either).

Not recommended: document the range only

A javadoc sentence, no behaviour change. Not run.

Trade-offs

Removes the contract mismatch on paper but leaves the silent wrong terms and the ParseException on DateTools' own output.

Downstream note

Applications that enforce date-based visibility rules as DateTools term ranges should reject dates outside the years 1 to 9999 first: in a test index, a year-10000 "never" date passed a "visible from <= now" filter.

How it was found

A bounded-exhaustive check ran the real DateTools on 142 boundary instants (BC years, the 1582 calendar switch, 9999/10000, the long extremes) at all 7 resolutions and tested every ordered pair against the javadoc's order and read-back promises. Every violation was outside the years 1 to 9999. Public-API tests then reproduced it.

The method in full139 words

Method: bounded-exhaustive check on the stock main jar (the study's "semi-formal" tier), a hunt unit (javadoc equivalence claims, enumerated), 2026-10-26, aimed by a code-reading lead that yyyy is the year of era.

  • The model ran the stock jar on 142 instants: Long.MIN_VALUE / Long.MAX_VALUE, every year start and end from 4 BC to AD 3, 999/1000, 1581-1583 with the Gregorian cutover days, 1969-1971, 2026, 9998-10001 and 12345, each +-1 ms, at all 7 resolutions, and checked all ordered pairs for order and read-back against the javadoc.
  • The finder's log reports violations only outside the years 1 to 9999 and a clean in-range subset. These counts were not re-run independently, so the doc cites only the targeted cases.
  • Then an AI finder wrote the public-API JUnit, and independent AI tools reproduced, reviewed, judged and audited it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): four runs of the scripted prior-report search (196 queries) plus manual GitHub, JIRA, mailing-list and web searches; all 29 Lucene DateTools issues read. Closest: the same SimpleDateFormat pitfall in Solr's own date formatter (SOLR-8904), which never touched DateTools.

Searches3 searches

Nothing reports DateTools strings losing the era before AD 1 or widening to five digits after 9999. Upstream main a95c754ee003 has the same DateTools.java (last change dbaf97f5dcc, 2025-07-18, #14965, a generics cleanup); no open PR touches it.

Related, not the same bug: the 2016 dev@ thread "Solr date formatting/parsing; upgrade to java.time" with SOLR-8880, SOLR-1899, SOLR-2773 and the fix SOLR-8904 (Solr's own formatter); #6710 / #8333 (LUCENE-5648 / LUCENE-7278, DateRangePrefixTree with explicit BC handling); #1569 (LUCENE-491) and #2921 (LUCENE-1846), earlier ordering fixes with other causes; #2810 (LUCENE-1736, the length-indexed parser array); #1124 / #1220 (LUCENE-46 / LUCENE-142, DateField's pre-1970 limit); apache/lucenenet#772 (the .NET port, limited to 0001-9999 by its platform date type). A maintainer may still call the SimpleDateFormat pitfall well known.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), the JIRA archive2026-10-27four scripted prior-report search runs (identifiers and error text; symptom wording; format and round trip; years and sentinels), all 29 DateTools issues read and grepped for era, BC, 9999, 10000, negative and five-digit yearsno report; the closest are earlier ordering fixes with other causes
Downstream repositories2026-10-27apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet; all of GitHub for the error textno use of DateTools in Solr, Elasticsearch or OpenSearch; lucenenet#772 is platform-limited
Mailing lists, JIRA SOLR and the web2026-10-27dev@, java-user@, issues@ and commits@ (tool queries plus 5 threads read in full), 24 SOLR tickets on BC and five-digit years, 15 web searchesthe same pitfall in Solr's own formatter (SOLR-8904), not DateTools

History

Present since DateTools was added (c7fbb991c3e, 2004-09-05; first release 1.9, 2006); no later change added an era, a signed year or a range check. A version-neutral probe fails on 17 releases from 1.9.1 to 10.5.2, on main and on the introducing commit; 1.4.3 has no DateTools.

Versions tested6 rows

Introduced by c7fbb991c3e (svn r150483, 2004-09-05, committed by dnaber: "Add a new class that supports converting dates to readable strings, with limited resolution."; svn era, no PR or JIRA id), with the yyyy patterns, the length-based parser and the ordering sentence. Later changes kept them: f72c1e579fe (2006, static formats), fcf6e305b48 (LUCENE-1472, 2011, ThreadLocal formats), 31479ab48ce (LUCENE-1736, 2011, substring(0, formatLen) and the format array indexed by length), b6a4748f21b (2012, Locale.ROOT), 2971f311a2b (LUCENE-9911, 2021) and a reformat; git log -S finds no commit that added an era, a signed year or a range check. Not a regression.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 5 of 6 (gradle and standalone, assertions on and off)
10.5.2, 9.12.3 affectedthis doc's test fails 5 of 6; the finder's, the reproducer's and the devil's advocate's tests fail too
1.9.1, 2.0.0, 2.1.0, 2.9.4, 3.0.3, 3.2.0, 3.3.0, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 affectedthe version-neutral probe fails 5 of 5; its control passes
c7fbb991c3e (introducing commit) affectedits DateTools.java, compiled on its own, fails the probe 5 of 5
1.4.3 API absentno DateTools class
main + fix rejectedout-of-range times throw IllegalArgumentException; in-range strings unchanged

Independent checks

An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (which ran their own tests) all found it real; the adjudication confirmed it with medium confidence, about how maintainers will resolve it, not the mechanism. A registry re-run, an audit and this doc's own runs reproduced it.

Checks and verdicts419 words

Every check wrote or ran its own test against the stock jars, with assertions on and off unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit 3 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3; the bounded-exhaustive model found violations only outside the years 1 to 9999.
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6) ran its own boundary check on main ea57360912ed (DateTools.java identical to a9779ef3c193), assertions on and off, with an in-range control passing; intended-behaviour argument rated moderate.
Independent reproducer realOwn JUnit 5 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3, control passing; its probe of Long.MIN_VALUE / Long.MAX_VALUE corrected the candidate (MINUTE strings are mis-parsed, not rejected).
Cross-model review, intended lens real (high)OpenAI GPT-5.6: own JUnit 4 of 5 fail on main and 10.5.2, control passing; a separate run re-ran it on a9779ef3c193 (-ea, -da) and 9.12.3 with the same result; root cause agreed, inputs in contract.
Devil's advocate real (medium-high)Built the strongest intended case and found it fails; own reachability JUnit 3 of 4 fail on main, 10.5.2 and 9.12.3 (TermRangeQuery and the classic query parser).
adjudication confirmed (medium)Re-ran the independent reproduction's JUnit (5 of 6 fail, -ea and -da) and the finder's (3 of 3); medium because maintainers may resolve it by documenting the range.
security screen not security-sensitiveOut of scope under SECURITY.md: no denial of service, index damage or data exposure from Lucene; only application date rules built on DateTools term ranges can fail open.
registry re-run and audit keepThe finder's JUnit fails 3 of 3 again (-ea and -da); the intended-behaviour check's fails 3 of 4 with -da; the code read confirms :198 and :117.
this doc's runs reproduced; fix validatedNew test through gradle and the standalone runner on main (-ea, -da), 10.5.2 and 9.12.3; the fix validated against the core module and the queryparser and benchmark suites.
independent fact check reproducedRe-ran the doc's gradle command (5 of 6 fail, assertions on and off) and the standalone runner on main and on 10.5.2 and 9.12.3 with -da; swept every day from 0001-01-01 to 9999-12-31 at DAY resolution with no violation; ran both query parsers with and without the fix, which corrected this doc's query-parser statement.

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

Finding record

LFV-645, snapshot of 2026-11-06

Registry title
DateTools strings stop sorting by date and stop round-tripping outside years AD 1..9999: the 'yyyy' year-of-era pattern drops the BC era (2 BC sorts after AD 1 and parses back as AD 2; 1 BC and AD 1 give the same string) and gives years after 9999 a fifth digit (10000 sorts before 2026, and stringToTime throws ParseException on DateTools' own output)
Area
core / document (DateTools date-to-string term encoding: timeToString/dateToString and stringToTime/stringToDate)
Priority
Low, rank 534: DateTools gives wrong order and round trip only for boundary dates outside AD 1..9999 in a legacy encoding, so it is a Low boundary-value row after the out-of-range parser row LFV-082 and above its DateTools sibling LFV-283.
Impact class
silent wrong results
Impact note
Hits applications that index dates as DateTools strings (the legacy encoding; its javadoc offers LongPoint instead) and have a date outside AD 1..9999 - a historical BC date, a far-future 'never' or sentinel date in year 10000 or later, a Long.MIN_VALUE/Long.MAX_VALUE bound, or a classic or flexible query parser date range with a five-digit year (date resolution set): TermRangeQuery, PrefixQuery and string sorting on those terms give wrong hits and wrong order with no error (executed: a 1200 BC document matches [AD 1000 TO AD 1500], and [AD 2000 TO AD 20000] matches neither a 2026 nor a year-10000 document), BC values read back with stringToTime silently come back as the AD year with the same number, and year-10000 values throw ParseException; no setting and no scale needed, deterministic with -ea and -da, every release since 1.9 (2006); dates inside AD 1..9999 are unaffected.
Summary
You convert a date before AD 1 or after 9999 with DateTools.timeToString (or dateToString) and expect what the class javadoc promises: strings whose lexicographic order is date order, which stringToTime turns back into the same time. Instead the SimpleDateFormat pattern 'yyyy...' prints the year of era with no era and at least four digits: 2 BC-01-01 becomes "00020101", which sorts after AD 1's "00010101" and parses back as AD 2-01-01 (at YEAR resolution 1 BC and AD 1 both give "0001"), and 10000-01-01 becomes "100000101", which sorts before "20260101" and makes stringToTime throw ParseException, because the parser is chosen by string length. Range queries, prefix queries and sorts on such terms therefore return wrong results with no error.
Introduced
c7fbb991c3e55fb6c3c3f3d2f0757176a7eccb06 (trunk, svn r150483, 'Add a new class that supports converting dates to readable strings, with limited resolution.', committed by ASF committer dnaber; svn era, no PR and no JIRA id): the class's first commit already formats with the SimpleDateFormat patterns 'yyyy' .. 'yyyyMMddHHmmssSSS' (year of era, no era letter, no range check), parses by string length (4/6/8/10/12/14/17) and carries the ordering sentence [read: git show; exec: that commit's DateTools.java compiled on its own fails the history probe 5 of 5]. Not a regression: later refactors kept the patterns (f72c1e579fe 2006 static formats; fcf6e305b48 LUCENE-1472 2011 ThreadLocal formats; 31479ab48ce LUCENE-1736 2011 'yyyyMMddHHmmssSSS'.substring(0, formatLen) and the format array indexed by string length; b6a4748f21b 2012 Locale.ROOT; 2971f311a2b LUCENE-9911 2021 ParseException cause; 2d6ad2fee6d reformat), and git log -S finds no commit that ever added an era, a signed year or a range check (2004-09-05 (c7fbb991c3e, trunk))
First affected version
1.9 [read] (2006-02-27, the first release with DateTools; first release candidate 1.9-rc1 2006-02-21; 1.9 is not on Maven Central); 1.9.1 [exec] (2006-03-02) is the first executed release and fails the history probe 5 of 5; 1.4.3 and earlier have no DateTools [exec: ClassNotFoundException on 1.4.3]
Affected versions
Every release that has DateTools, 1.9 through 10.6.0 (10.6.0 by reading), and main a9779ef3c193; never fixed. [exec] the version-history runs's version-neutral main() probe (checks: 2 BC < AD 1 at DAY, 2 BC round trip, 1 BC < AD 1 at YEAR, 2026 < 10000 at DAY, year-10000 round trip; its control AD 1, 1582-10-15, 1970, 2026, 9999-12-31 passes) fails 5 of 5 on 17 releases (1.9.1, 2.0.0, 2.1.0, 2.9.4, 3.0.3, 3.2.0, 3.3.0, 3.6.2, 4.0.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2), on main and on the introducing commit's DateTools.java; the finder's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 6 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's reachability JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3; the cross-model reviewer's own tests fail on main (ea57360912ed -ea and -da; a9779ef3c193 -ea and -da), 10.5.2 and 9.12.3. [read] 147 of the 165 release tags in the history clone contain DateTools.java with only the seven yyyy-prefixed patterns; upstream main a95c754ee003 (2026-10-26) has the same DateTools.java (last change dbaf97f5dcc, 2025-07-18, generics cleanup) and no open PR touches it, so 10.6.0 (released 2026-10-29) and the unreleased 11.0 line are affected (not executed). Not executed: x86 and JDKs other than 25.0.3 (the behaviour is documented java.text.SimpleDateFormat semantics: 'y' is the year of era and the letter count is a minimum width). JDK 25.0.3, aarch64
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check, code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new: four scripted prior-report searches with 196 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; manual GitHub REST searches in apache/lucene (all 29 DateTools issues, bodies and comments grepped for era, BC, 9999, 10000, negative and five-digit years), apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub for the error text; 3 JIRA searches over LUCENE and SOLR; 5 list threads read; 15 web searches; the finder's searches and the independent reproduction's and the intended-behaviour check's scripted prior-report searches, 43 queries each, agree). Closest items, none the same bug (18 hits, all same_bug false): the 2016 dev@ thread 'Solr date formatting/parsing; upgrade to java.time' and SOLR-8880 / SOLR-1899 / SOLR-2773 (fixed by SOLR-8904), the same SimpleDateFormat year-of-era defect class in Solr's own date formatter, never mentioning Lucene's DateTools; #6710 (LUCENE-5648) and #8333 (LUCENE-7278), where the spatial DateRangePrefixTree was built with explicit BC and '+'-year handling; #1569 (LUCENE-491, UTC collation) and #2921 (LUCENE-1846, locale), earlier DateTools ordering fixes with other causes; #2810 (LUCENE-1736, the length-indexed parser array); #1124 / #1220 (LUCENE-46 / LUCENE-142, the predecessor DateField's pre-1970 limit); apache/lucenenet#772 (the .NET port, limited to years 0001..9999 by its platform date type). Not fixed upstream: DateTools.java is unchanged on upstream main a95c754ee003 (last change dbaf97f5dcc, 2025-07-18, #14965, generics cleanup) and no open PR touches it.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-28) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (this doc's test); a version-neutral main() probe on 17 releases from 1.9.1 to 10.5.2, on main and on the introducing commit's DateTools.java. The fix was validated with gradle on a9779ef3c193. Upstream main a95c754ee003 has the same DateTools.java (read, not executed).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-31 (at a9779ef3c193) (class javadoc: 'The strings are structured so that lexicographic sorting orders them by date, which makes them suitable for use as field values and search terms.'), :37-40 (LongPoint offered as the other approach), :75-77 and :87-88 ('@return a string in format yyyyMMddHHmmssSSS or shorter'), :85 ('@param time the date expressed as milliseconds since January 1, 1970, 00:00:00 GMT', no range), :90-93 (timeToString: round, then format; no validation), :95-123 (stringToTime/stringToDate: 'Converts a string produced by timeToString or dateToString back to a time'; :117 picks the format by dateString.length(); :118-121 wraps any failure in ParseException 'Input is not a valid date string'), :146-171 (round on a lenient GMT Calendar), :177-189 (Resolution lengths 4/6/8/10/12/14/17), :198 (new SimpleDateFormat("yyyyMMddHHmmssSSS".substring(0, formatLen), Locale.ROOT), no era letter)
  • lucene/core/src/java/org/apache/lucene/document/package-info.java (at a9779ef3c193) (recommends LongField for dates; cited by the security follow-up at :83)
  • lucene/core/src/test/org/apache/lucene/document/TestDateTools.java (at a9779ef3c193) (dates 1961-2005 and 1970 only; no year outside AD 1..9999; read by the independent reproduction, the cross-model review and the intended-behaviour check)
  • a Lucene main checkout: git diff ea57360912ed a9779ef3c193 -- DateTools.java is empty (the reviewers' runs on ea57360912ed exercise the same code)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-31, (at a9779ef3c193) :37-40, :54-65, :85, :90-93, :95-123 (:117 parser by length; :118-121 ParseException), :177-189, :194-200 (:198 pattern)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.java:50-60 (at a9779ef3c193)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:447-482 (at a9779ef3c193) (getRangeQuery; :454-479 conversion inside catch (Exception))
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/TermRangeQueryNodeProcessor.java:100-142 (at a9779ef3c193) (conversion inside try; :133 'not a date')
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:450 (at a9779ef3c193) (DateFormat.getDateInstance(SHORT, locale): JVM default zone), :461-475 (parser time zone only for the inclusive end of day); TermRangeQueryNodeProcessor.java:101, :110-124
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:37-40 (at a9779ef3c193) (LongPoint offered)
  • lucene/core/src/java/org/apache/lucene/document/package-info.java:83 (at a9779ef3c193) (LongField recommended for dates)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/DateTools.java:28-31, (at a9779ef3c193) :75, :85, :87, :90-93, :96, :108
  • lucene/core/src/test/org/apache/lucene/document/TestDateTools.java (at a9779ef3c193) (dates 1961-2005, 1970; malformed inputs :44, :50, :56)
  • lucene/CHANGES.txt:19114-19118 (at a9779ef3c193) (LUCENE-1846), :21722-21726 (1.9: DateTools added)
Suggested fix
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:454-479; (at a9779ef3c193) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/processors/TermRangeQueryNodeProcessor.java:100-142 (at a9779ef3c193)

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

Repro files

About the diagrams

A light, step-by-step picture of the repro, made in parallel with this doc from executed runs on main a9779ef3c193 and the code.

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.