How a hidden server timezone shifted a Spring Boot reporting job by seven hours and changed the transactions included in the report.
The daily report contained the wrong transactions.
Quartz showed that the job had completed successfully. The Spring Boot application logged no errors. The cron expression also looked correct.
But the report ran seven hours later than the business expected.
Worse, the delay changed the database query window. Transactions from the beginning of the business day were excluded, while transactions from the next day could be included.
This was not only a scheduling problem.
It was a data-correctness incident caused by a hidden timezone default.
The Job Succeeded, but the Business Process Failed
The reporting job was supposed to run every day at 1:00 AM in Bangkok.
Its responsibilities were straightforward:
- Determine the previous business date.
- Query transactions for that date.
- Calculate totals.
- Generate the report.
- Store the result for users.
The Quartz cron expression looked like this:
report.schedule.cron=0 0 1 * * ?
This means “run at 1:00 AM every day.”
At least, that was how we read it.
The problem is that a cron expression does not specify which timezone defines 1:00 AM.
Our developers expected Asia/Bangkok. The server was configured for UTC.
Because the Quartz trigger did not have an explicit timezone, Quartz inherited the server timezone.
The result became obvious once we compared both clocks:
| Event | UTC | Asia/Bangkok |
|---|---|---|
| Intended execution | 18:00 on the previous day | 01:00 |
| Actual execution | 01:00 | 08:00 |
| Difference | 7 hours | 7 hours |
Quartz was running at 1:00 AM UTC.
For the business, that was 8:00 AM in Bangkok.
We Suspected the Cron Expression First
When a scheduled job starts at the wrong time, the cron expression is the obvious suspect.
We checked whether the fields were in the correct order.
We verified that the trigger had been registered and that another configuration value was not overriding the schedule.
We also considered server load, database delays, and a previous execution blocking the next run.
None of those theories matched the evidence.
The offset was always exactly seven hours.
CPU pressure does not normally delay a job by precisely seven hours every day. Database load does not create such a consistent offset either.
That consistency was the useful clue.
Bangkok uses UTC+7. The server used UTC. Quartz had no explicit timezone.
An infrastructure default had quietly become part of our business logic.
Why the Report Contained the Wrong Data
A seven-hour delay does not always produce incorrect output.
If a scheduled job only emails an existing file, the file may be late but still correct.
Our job calculated its database query boundaries when execution started. The old implementation also used the server’s default timezone to determine the previous date.
The logic was effectively similar to this:
LocalDate reportDate = LocalDate.now()
.minusDays(1);
LocalDateTime start = reportDate.atStartOfDay();
LocalDateTime end = reportDate.plusDays(1).atStartOfDay();Neither LocalDate.now() nor these LocalDateTime values express the business timezone.
Their meaning depends on JVM and server defaults. The code looks reasonable during review, but its behavior can change when the application moves to another machine or container.
Suppose the report should cover April 14 in Bangkok.
The intended business period is:
Bangkok business window: 2026-04-14 00:00:00 Asia/Bangkok 2026-04-15 00:00:00 Asia/Bangkok
Converted to UTC, that becomes:
Correct UTC query window: 2026-04-13T17:00:00Z 2026-04-14T17:00:00Z
Code based on UTC midnight could instead produce:
Incorrect UTC query window: 2026-04-14T00:00:00Z 2026-04-15T00:00:00Z
That range represents 7:00 AM to 7:00 AM in Bangkok.
The report would exclude transactions created between midnight and 7:00 AM on April 14. It could also include transactions created between midnight and 7:00 AM on April 15.
Quartz could mark the job as successful while the report contained data from the wrong business period.
Technical success did not mean business success.
A Cron Expression Is Not a Complete Schedule
This configuration looks complete:
report.schedule.cron=0 0 1 * * ?
It is not.
The expression defines the second, minute, hour, day, month, and day-of-week fields. It does not communicate the business timezone.
A complete schedule needs both values:
report.schedule.cron=0 0 1 * * ? report.schedule.zone-id=Asia/Bangkok
The schedule now has an unambiguous meaning:
Run every day at 1:00 AM in the Asia/Bangkok timezone.
Timezone should be treated as part of the scheduling contract, not as an infrastructure detail.
Configure the Schedule Explicitly
This example uses Spring Boot 3, Java 17 or later, Quartz, Spring Data JPA, and PostgreSQL.
Add the required dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Create configuration properties for the cron expression and timezone:
@ConfigurationProperties(prefix = "report.schedule")
public record ReportScheduleProperties(
String cron,
String zoneId
) {
}Enable the properties:
@Configuration
@EnableConfigurationProperties(ReportScheduleProperties.class)
public class ReportConfiguration {
}Supply the values through application.properties:
report.schedule.cron=0 0 1 * * ? report.schedule.zone-id=Asia/Bangkok
For production, the values can come from environment variables:
report.schedule.cron=${REPORT_CRON:0 0 1 * * ?}
report.schedule.zone-id=${REPORT_ZONE_ID:Asia/Bangkok}Keeping the timezone in application configuration makes the business intent visible. Different reports can also use different business zones without changing the JVM timezone.
Set the Quartz Trigger Timezone
The trigger must apply the configured timezone explicitly:
@Bean
public Trigger dailyReportTrigger(
JobDetail dailyReportJob,
ReportScheduleProperties properties) {
TimeZone triggerTimeZone = TimeZone.getTimeZone(
ZoneId.of(properties.zoneId())
);
CronScheduleBuilder schedule = CronScheduleBuilder
.cronSchedule(properties.cron())
.inTimeZone(triggerTimeZone);
return TriggerBuilder.newTrigger()
.forJob(dailyReportJob)
.withIdentity("daily-report-trigger")
.withSchedule(schedule)
.build();
}The important call is inTimeZone(...).
Quartz now interprets 0 0 1 * * ? as 1:00 AM in Bangkok, even when the application runs on a UTC server.
Using ZoneId.of() also validates the configured timezone. This avoids silently accepting an invalid identifier that falls back to GMT.
The corresponding job detail can be configured like this:
@Bean
public JobDetail dailyReportJob() {
return JobBuilder.newJob(DailyReportJob.class)
.withIdentity("daily-report-job")
.storeDurably()
.build();
}If Quartz uses JDBC job storage, verify the persisted trigger after deployment. Depending on the initialization strategy, changing Java configuration may not replace a trigger already stored in the Quartz tables.
Fix the Reporting Window Separately
Correcting the trigger timezone fixes when the job runs.
It does not automatically fix which data the job reads.
The reporting-window calculation must also use an explicit business timezone.
A small value object makes the boundaries visible:
public record ReportingWindow(
LocalDate businessDate,
ZoneId zoneId,
Instant start,
Instant end
) {
}Create a component responsible for calculating the window:
@Component
public class ReportingWindowFactory {
private final Clock clock;
private final ZoneId businessZone;
public ReportingWindowFactory(
Clock clock,
ReportScheduleProperties properties) {
this.clock = clock;
this.businessZone = ZoneId.of(properties.zoneId());
}
public ReportingWindow previousBusinessDay() {
LocalDate currentBusinessDate = ZonedDateTime
.now(clock)
.withZoneSameInstant(businessZone)
.toLocalDate();
LocalDate reportDate = currentBusinessDate.minusDays(1);
Instant start = reportDate
.atStartOfDay(businessZone)
.toInstant();
Instant end = reportDate
.plusDays(1)
.atStartOfDay(businessZone)
.toInstant();
return new ReportingWindow(
reportDate,
businessZone,
start,
end
);
}
}This component does not depend on the machine’s default timezone.
It determines the current date in the configured business zone, selects the previous business date, and converts its boundaries to Instant.
The same code can run on a UTC server, a Bangkok workstation, or a container configured for another timezone without changing the reporting period.
Inject a Clock for Testable Time Logic
Injecting Clock makes time-dependent behavior deterministic.
Configure a UTC system clock for production:
@Bean
public Clock reportClock() {
return Clock.systemUTC();
}A UTC clock does not force the application to use a UTC business date.
The responsibilities are separate:
Clockanswers, “What is the current instant?”ZoneIdanswers, “Which local business date contains that instant?”
That separation makes the code easier to test and review.
Query With UTC Instants
Assume created_at represents an absolute timestamp.
The report query should use a half-open range:
SELECT id,
customer_id,
amount,
created_at
FROM transactions
WHERE created_at >= :start
AND created_at < :end;The start is inclusive. The end is exclusive.
This prevents a transaction at exactly midnight from appearing in two reports. A transaction at the next day’s boundary belongs only to the next report.
A Spring Data repository can accept Instant values:
public interface TransactionRepository
extends JpaRepository<TransactionEntity, Long> {
@Query("""
SELECT transaction
FROM TransactionEntity transaction
WHERE transaction.createdAt >= :start
AND transaction.createdAt < :end
""")
List<TransactionEntity> findForReportingWindow(
@Param("start") Instant start,
@Param("end") Instant end
);
}For a large table, index the timestamp used by the range query:
CREATE INDEX idx_transactions_created_at
ON transactions (created_at);Returning every transaction as a Java entity may still consume too much memory. For large reports, aggregate in SQL, stream the results, or process them in pages.
Also verify how the production database and JDBC driver map Instant to the selected timestamp type. PostgreSQL timestamp with time zone stores an instant, while timestamp without time zone has different semantics.
Build an Auditable Report Flow
The service should calculate the window, query the data, generate the output, and record the exact boundaries used:
@Service
public class DailyReportService {
private final ReportingWindowFactory windowFactory;
private final TransactionRepository transactionRepository;
private final ReportGenerator reportGenerator;
private final ReportRunRepository reportRunRepository;
public DailyReportService(
ReportingWindowFactory windowFactory,
TransactionRepository transactionRepository,
ReportGenerator reportGenerator,
ReportRunRepository reportRunRepository) {
this.windowFactory = windowFactory;
this.transactionRepository = transactionRepository;
this.reportGenerator = reportGenerator;
this.reportRunRepository = reportRunRepository;
}
@Transactional
public void generatePreviousDayReport() {
ReportingWindow window =
windowFactory.previousBusinessDay();
List<TransactionEntity> transactions =
transactionRepository.findForReportingWindow(
window.start(),
window.end()
);
ReportOutput output =
reportGenerator.generate(window, transactions);
reportRunRepository.save(ReportRun.completed(
window.businessDate(),
window.zoneId().getId(),
window.start(),
window.end(),
transactions.size(),
output.location()
));
}
}Persisting the business date, timezone, and query boundaries creates an audit trail.
When someone asks why a transaction was included, the team can inspect the exact window instead of reconstructing it from server settings and old logs.
The Quartz job should remain small:
@Component
public class DailyReportJob extends QuartzJobBean {
private final DailyReportService dailyReportService;
public DailyReportJob(
DailyReportService dailyReportService) {
this.dailyReportService = dailyReportService;
}
@Override
protected void executeInternal(
JobExecutionContext context) {
dailyReportService.generatePreviousDayReport();
}
}Quartz owns schedule execution. Business-date calculation and report generation stay in testable application services.
Test the Exact Reporting Boundaries
A fixed clock allows the test to control the current instant:
class ReportingWindowFactoryTest {
@Test
void createsPreviousBangkokBusinessDayWindow() {
Clock fixedClock = Clock.fixed(
Instant.parse("2026-04-15T01:00:00Z"),
ZoneOffset.UTC
);
ReportingWindowFactory factory =
new ReportingWindowFactory(
fixedClock,
new ReportScheduleProperties(
"0 0 1 * * ?",
"Asia/Bangkok"
)
);
ReportingWindow window =
factory.previousBusinessDay();
assertEquals(
LocalDate.of(2026, 4, 14),
window.businessDate()
);
assertEquals(
Instant.parse("2026-04-13T17:00:00Z"),
window.start()
);
assertEquals(
Instant.parse("2026-04-14T17:00:00Z"),
window.end()
);
}
}At 2026-04-15T01:00:00Z, Bangkok local time is 8:00 AM on April 15.
The previous Bangkok business day is April 14, with UTC boundaries from April 13 at 17:00 to April 14 at 17:00.
This test proves more than “the method returned yesterday.” It verifies the exact instants sent to the database.
Test Records Around Midnight
Timezone bugs are easiest to expose near date boundaries.
An integration test should insert records immediately before, at, and after Bangkok midnight:
@SpringBootTest
@Transactional
class DailyReportRepositoryTest {
@Autowired
private TransactionRepository repository;
@Test
void selectsOnlyTransactionsInsideBangkokBusinessDay() {
repository.save(transactionAt(
Instant.parse("2026-04-13T16:59:59Z")
));
repository.save(transactionAt(
Instant.parse("2026-04-13T17:00:00Z")
));
repository.save(transactionAt(
Instant.parse("2026-04-14T16:59:59Z")
));
repository.save(transactionAt(
Instant.parse("2026-04-14T17:00:00Z")
));
List<TransactionEntity> result =
repository.findForReportingWindow(
Instant.parse("2026-04-13T17:00:00Z"),
Instant.parse("2026-04-14T17:00:00Z")
);
assertEquals(2, result.size());
}
}The record immediately before the start is excluded. The record exactly at the start is included.
The record immediately before the end is included. The record exactly at the end is excluded.
For production confidence, run this test against the same database engine used in production, preferably with Testcontainers. H2 and PostgreSQL can behave differently when mapping timestamp types.
Prove the Machine Timezone Does Not Matter
A useful regression test changes the JVM default timezone while keeping the current instant fixed:
@Test
void machineTimezoneDoesNotChangeTheReportingWindow() {
TimeZone original = TimeZone.getDefault();
try {
Clock fixedClock = Clock.fixed(
Instant.parse("2026-04-15T01:00:00Z"),
ZoneOffset.UTC
);
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
ReportingWindow utcResult = createFactory(fixedClock)
.previousBusinessDay();
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"));
ReportingWindow newYorkResult = createFactory(fixedClock)
.previousBusinessDay();
assertEquals(utcResult, newYorkResult);
} finally {
TimeZone.setDefault(original);
}
}This proves that a server or container timezone change does not alter the report period.
Because the JVM timezone is global state, tests that change it should not run concurrently with other timezone-sensitive tests.
Log the Business Operation, Not Just Job Completion
This message is not enough:
Daily report job completed successfully
It confirms execution but provides no evidence about the selected data.
Log the business date and query boundaries:
log.info(
"Daily report completed businessDate={} zoneId={} " +
"windowStart={} windowEnd={} rowCount={}",
window.businessDate(),
window.zoneId(),
window.start(),
window.end(),
transactions.size()
);A structured production event might look like this:
{
"event": "daily_report_completed",
"businessDate": "2026-04-14",
"zoneId": "Asia/Bangkok",
"windowStart": "2026-04-13T17:00:00Z",
"windowEnd": "2026-04-14T17:00:00Z",
"scheduledTime": "2026-04-14T18:00:00Z",
"actualStartTime": "2026-04-14T18:00:03Z",
"rowCount": 184392,
"status": "COMPLETED"
}These fields show when Quartz intended to fire, when execution actually began, and which business period was processed.
Useful alerts include:
- Actual start time differs significantly from scheduled time.
- The calculated business date is unexpected.
- The reporting window has an unexpected duration.
- Row count or control totals change sharply.
- The report runs more than once for the same business date.
For idempotency, consider a unique constraint on the business date and report version. Quartz misfires, retries, or clustered schedulers should not silently create duplicate finalized reports.
Keep Servers on UTC
Keeping servers and containers on UTC is usually a sound operational standard.
UTC makes infrastructure logs easier to compare across regions and reduces confusion when services operate in multiple countries.
The container can remain on UTC:
ENV TZ=UTC
The JVM can also use UTC as its default:
java -Duser.timezone=UTC -jar reporting-service.jar
The Quartz trigger and reporting-window calculation should still use Asia/Bangkok explicitly.
Changing the server timezone can hide the application’s dependency on an implicit default. The same bug may return when the service moves to another environment.
Settings That Do Not Fix the Schedule
Some timezone settings solve different problems.
For example:
spring.jackson.time-zone=Asia/Bangkok
This affects how some date and time values are serialized by Jackson. It does not configure the Quartz trigger timezone.
A database session timezone may influence timestamp display and conversion, but it does not define the business meaning of a Quartz cron expression.
Configure each responsibility separately:
- Quartz timezone for schedule interpretation.
- Business
ZoneIdfor reporting boundaries. - UTC instants for database comparisons.
- JSON timezone or format for API presentation.
- Server timezone for operational consistency.
No single timezone setting controls all of them.
Production Rules for Time-Based Reports
Avoid using LocalDateTime as an absolute timestamp.
A LocalDateTime contains a date and time but no timezone or UTC offset. 2026-04-14T01:00 could represent many different instants.
Use:
Instantfor absolute database timestamps and event times.ZonedDateTimewhen a named regional timezone matters.OffsetDateTimewhen an explicit offset is sufficient.LocalDatefor a business date after its timezone is established.
Do not manually add seven hours:
Instant bangkokTime = instant.plus(7, ChronoUnit.HOURS);
Bangkok currently has no daylight-saving transitions, but manual offsets still hide business intent and make the code dangerous to reuse.
ZoneId.of("Asia/Bangkok") is clearer and uses the platform timezone rules.
Persist enough information to reconstruct every report run:
- Business date
- Business timezone
- Window start and end
- Scheduled fire time
- Actual start and completion time
- Report version
- Row count and control totals
- Output location
- Success or failure status
This information supports audits, incident investigation, and controlled regeneration.
What Changed After the Fix
After we made timezone intent explicit, Quartz ran at 1:00 AM Bangkok time while the server remained on UTC.
For April 14, the report selected:
Business date: 2026-04-14 Zone: Asia/Bangkok Start: 2026-04-13T17:00:00Z End: 2026-04-14T17:00:00Z
Transactions from the beginning of the Bangkok business day were no longer missing. Transactions from the following day were no longer included.
Deploying to a machine with a different default timezone no longer changed the schedule or query window.
More importantly, the logs and tests could now prove which business day each successful job had processed.
Lessons Learned
- A cron expression without a timezone is an incomplete schedule.
- Infrastructure defaults can silently become business logic.
- Quartz success proves that code executed, not that it selected the correct data.
- Trigger configuration and reporting-window calculation need explicit timezone semantics.
- Database queries should use half-open time boundaries.
- Injecting
Clockmakes time-dependent behavior deterministic in tests. - Boundary tests should cover records immediately before, at, and after midnight.
Conclusion
Quartz was not broken.
It interpreted the cron expression using the timezone available to it. The server was configured for UTC, and the application did not provide a more specific instruction.
Our mistake was treating “1:00 AM” and “the previous day” as complete definitions.
They were not.
We fixed the incident by giving both the Quartz trigger and the reporting-window calculation an explicit business timezone. We converted the resulting boundaries to UTC instants, added midnight boundary tests, and recorded the exact window used by every report run.
A scheduled reporting system must answer two separate questions:
Did the job run?
And:
Which business day did it process?
A reliable system must prove both.



