The java.time bugs that don’t throw exceptions

7 min read

Killian Carlsen-Phelan photo

Killian Carlsen-Phelan

Developer Content Engineer

Jean Jimbo photo

Jean Jimbo

Product manager (Code Quality)

TL;DR overview

  • SonarQube's new java.time rules catch bugs that compile, pass tests, and produce silently wrong results such as duration math across timezones, non-deterministic clocks, and identity comparison on value types.
  • LocalDateTime duration calculations across timezone boundaries return plausible numbers that survive code review but skew billing, SLA, and scheduling systems.
  • AI coding tools default to LocalDateTime because it's the simpler type, and rarely inject a Clock because the pattern requires a design decision absent from most training data.
  • All eight rules are present in Sonar Way and active by default on SonarQube Cloud, with SonarQube Server shipping them in 2026.4.

A flight leaves New York at 10 PM and lands in Paris at 11 AM the next morning, and you compute the elapsed time with java.time.

The silent wrong answer

LocalDateTime departureNY = LocalDateTime.of(2026, 3, 28, 22, 0);  // 10 PM New York
LocalDateTime arrivalParis = LocalDateTime.of(2026, 3, 29, 11, 0); // 11 AM Paris
long hours = ChronoUnit.HOURS.between(departureNY, arrivalParis);   // returns 13

The method returns 13 hours for a flight that took seven, because ChronoUnit.HOURS.between on two LocalDateTime values does clock-face subtraction: 11:00 minus 22:00 the day before is 13 hours. The calculation has no reason to account for timezones because LocalDateTime carries no timezone information (that's what "Local" means in the class name). Departed New York in EDT (UTC-4), arrived in Paris in CEST (UTC+2), and the six-hour offset difference between the two zones is invisible to the calculation. Tests that assert hours > 0 pass, and the wrong number flows into billing systems, SLA timers, or scheduling logic without triggering an exception.

The pattern appears anywhere duration math crosses timezone boundaries. A billing system computes hours between a session start in one timezone and end in another; the wrong duration inflates or deflates the invoice by a fraction that looks like rounding, not a bug. SLA monitoring across globally distributed services makes the same mistake, skewing response times in whichever direction the offset difference dictates. The code doesn't crash but returns a number plausible enough to survive code review and specific enough to cause real damage downstream.

SonarQube rule S8700 flags Duration.between() and ChronoUnit.X.between() when both operands are LocalDateTime, because the result is a calendar delta rather than physical elapsed time. The fix converts both timestamps to ZonedDateTime with explicit zones:

ZonedDateTime departure = LocalDateTime.of(2026, 3, 28, 22, 0)
    .atZone(ZoneId.of("America/New_York"));
ZonedDateTime arrival = LocalDateTime.of(2026, 3, 29, 11, 0)
    .atZone(ZoneId.of("Europe/Paris"));
long hours = ChronoUnit.HOURS.between(departure, arrival); // returns 7

The six-hour offset between EDT and CEST accounts for the entire discrepancy between the two calculations.

java.time's type system is not oblivious to the timezone boundary problem, and if you try to convert a LocalDateTime to an Instant directly, the API throws DateTimeException at runtime because the conversion requires timezone information that the local type doesn't carry. Rule S8220 catches these conversions statically before they crash in production, since java.time is designed to fail loud at the boundary between local and timezone-aware types. Duration.between is where it fails quiet instead, because both operands are LocalDateTime and the type system has no boundary to enforce when they already share a type. The method cannot distinguish two wall-clock readings in the same zone from two readings in different zones, which is why S8700 exists: LocalDateTime arithmetic is a domain where the type system permits operations that the physical world doesn't.

S8220's false-positive handling is careful about this, suppressing findings in test code that intentionally triggers DateTimeException and supporting JUnit 4, TestNG, JUnit 5 assertThrows, AssertJ, and try-catch-fail patterns. Google's Error Prone catches overlapping patterns in the same space (FromTemporalAccessor covers the same ground as S8220, MisusedWeekYear overlaps with S3986), which confirms these as industry-validated bug categories rather than Sonar-specific opinions.

LLMs default to LocalDateTime because it's the simpler type, and GitHub Copilot's CLI has an open bug (at time of writing) where its <current_datetime> tag reports UTC regardless of the system timezone.

When your tests lie about time

@Test
void testTokenExpiry() {
    Instant issued = Instant.now();
    Instant checked = Instant.now();
    assertTrue(issued.isBefore(checked)); // passes when JVM is cold, fails when warm
}

Two consecutive Instant.now() calls can return identical values on modern hardware because the system clock's resolution means both calls sample the same instant. The non-determinism compounds with JVM behavior, leading to a @RepeatedTest passing on the first iteration while the JVM is cold, then failing on subsequent iterations as the warmed-up code executes fast enough to collapse both timestamps into a single reading. The test is correct in isolation, but because the unreliability depends on clock precision, CPU load, and JVM warmup state, it manifests differently across development machines and CI environments.

The underlying problem mirrors the duration bug, because java.time's type system lets you call .now() without a Clock even though deterministic testing requires explicit control over time. Rule S8692 flags .now() calls without a fixed clock in test code, and since the fix requires injecting a Clock that tests can control, it's an architectural change rather than a quick patch.

public class TokenService {
    private final Clock clock;
    public TokenService(Clock clock) { this.clock = clock; }

    public boolean isExpired(Instant issuedAt, Duration maxAge) {
        return Instant.now(clock).isAfter(issuedAt.plus(maxAge));
    }
}

@Test
void tokenExpiresAfterMaxAge() {
    Clock fixed = Clock.fixed(Instant.parse("2026-06-01T10:00:00Z"), ZoneOffset.UTC);
    TokenService service = new TokenService(fixed);
    assertTrue(service.isExpired(
        Instant.parse("2026-06-01T09:00:00Z"), Duration.ofMinutes(30)));
}

S8692's quick fix is explicitly marked "infeasible" because fixing requires dependency injection, not a search-and-replace. The one-hour remediation estimate (vs. five minutes for most other rules in this set) reflects the real cost of retrofitting testable time into code that wasn't designed for it.

S8692 carries INFO severity because nearly every test suite calls .now() without a Clock, and flagging all of them at higher levels produces too much noise. AI-generated test code rarely injects a Clock because the pattern requires a design decision that doesn't appear in most training examples.

When equals isn't ==

Instant a = Instant.parse("2026-01-01T00:00:00Z");
Instant b = Instant.parse("2026-01-01T00:00:00Z");
System.out.println(a == b);      // false
System.out.println(a.equals(b)); // true

== on Instant compares object references rather than values, so two variables that hold the same point in time but live at different heap addresses will compare as unequal even though they represent identical moments. As with the duration and clock bugs, java.time's type system permits an operation that the domain makes meaningless for value-based types. The pattern is even harder to catch on the primitive wrappers that S8696 also covers, because the JVM's caching behavior makes == intermittently correct: Integer.valueOf(100) == Integer.valueOf(100) returns true because the JVM caches integers in the -128 to 127 range, but increase the value past 127 and == starts returning false. A test that exercises small values passes, a production workload with larger values fails, and the defect appears to be environment-dependent rather than logic-dependent.

Rule S8696 was born from the java.time investigation but generalized to cover every value-based class in the standard library so that it includes all primitive wrappers, Optional and its variants, and every java.time type except Clock. The rule flags both == comparison and System.identityHashCode() on these types. S8696 is the only new rule in this set included in SonarQube's agentic AI quality profile and carries the second-highest severity (HIGH).

What else shipped

Seven additional rules round out the java.time coverage, all present in Sonar Way and active by default on SonarQube Cloud.

RuleNameTypeImpact
S8688.now() should specify a ZoneId or ClockCode SmellINFO
S8694Use Month/DayOfWeek enums, not numeric literalsCode SmellLOW
S8695Simplify redundant time instantiationCode SmellLOW
S2143Use java.time, not Date/Calendar/JodaCode SmellINFO
S3986Week year (YYYY) should not be used for formattingBugMEDIUM
S5917Don't mix year types in DateTimeFormatterBuilderBugMEDIUM
S2718Replace DateUtils.truncate with ZonedDateTime.truncatedToCode SmellMEDIUM

S3986 and S5917 form a pair that catches the same conceptual bug at two API levels. S3986 flags YYYY in format pattern strings, the week-year specifier that produces the wrong year in late December and early January (formatting December 31, 2015 with YYYY/MM/dd yields "2016/12/31"). S5917 catches the equivalent mismatch in DateTimeFormatterBuilder field combinations, and both bugs surface once a year and pass every test run that doesn't land in the last week of December or first week of January.

What's next

If you want to find where these silent wrong answers live in your own codebase, search for Duration.between and ChronoUnit.X.between where both operands are LocalDateTime, since those calls will survive every test that doesn't independently verify the result with timezone-aware math. All eight new and revisited rules are in Sonar Way and active by default in SonarQube Cloud, and SonarQube Server ships them in 2026.4.

Build trust into every line of code

Integrate SonarQube into your workflow and start finding vulnerabilities today.

Rating image

4.6 / 5

Unsubscribe