Cron-based Scheduling

A cron expression is a short text pattern that tells Spring exactly when a task should run, such as “every day at 8:30 AM” or “every Monday at 9:00 AM”. Unlike fixed rate and fixed delay, which repeat after an interval, cron ties execution to clock and calendar times. You use it with @Scheduled(cron = "..."), optionally with a zone so that “8:30” means the same time on every server. It is used for daily reports, weekly summaries, month-end processing, and jobs that must run at specific business times.

Features

  • Precise control over second, minute, hour, day, month, and weekday
  • Lists (1,15), ranges (9-17), steps (*/15), and names (MON-FRI)
  • L for the last day of the month
  • Time zone support: zone = "Asia/Kolkata"
  • Cron text read from application.properties, so times change without editing code
  • A single dash (-) switches a cron job off without deleting it
  • Macros such as @daily, @hourly, and @weekly
  • CronExpression class lets you check the next run times before using an expression

Hands-on Experiment: Creating a spring boot application to understand how cron expressions provide precise control over when scheduled tasks execute.

The following steps are involved in developing this hands-on experiment.

Step 1: Generate the Project

Step 2: Project Structure

Step 3: pom.xml

Step 4: application.properties

Step 5: Create Entity Class

Step 6: Create Repository Interface

Step 7: Create Service Class

Step 8: Create Controller Class

Step 9: Main Application Class

Step 10: Run the Main Application Class

Step 1: Configure the Project on Spring Initializr

Go to start.spring.io and set:

  • Project: Maven
  • Language: Java
  • Spring Boot version: 3.2.x
  • Group: com.example
  • Artifact: cron
  • Name: cron
  • Package name: com.example.cron
  • Packaging: Jar
  • Java: 17
  • Dependencies: Spring Web, Spring Data JPA, MySQL Driver.

Click Generate to download the ZIP, then extract and import it into your IDE as a Maven project.

Step 2: Generated Project Structure

Step 3: Explore pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>student-cron-demo</artifactId>
    <version>1.0.0</version>
    <name>student-cron-demo</name>
    <description>Cron-based Scheduling - Student reports at specific times</description>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- @Scheduled and CronExpression are part of spring-context -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Step 4: application.properties

spring.application.name=student-cron-demo

# ---------- MySQL ----------
# Create the database first:  CREATE DATABASE student_cron_db;
spring.datasource.url=jdbc:mysql://localhost:3306/student_cron_db
spring.datasource.username=root
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=update
server.port=8082
# ---------- Scheduler ----------
spring.task.scheduling.pool.size=2
spring.task.scheduling.thread-name-prefix=cron-job-

# ---------- Logging ----------
logging.level.root=INFO
logging.level.com.example.cron=DEBUG
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} | %-5level | %thread | %msg%n

# ---------- Cron expressions ----------
# Format (6 fields):  second  minute  hour  day-of-month  month  day-of-week
#
# Time zone used by ALL cron jobs below (and to decide what "today" means in the reports)
app.cron.zone=Asia/Kolkata

# JOB 1 - THE EXPERIMENT: Student report every day at 08:30:00 AM
app.cron.daily=0 30 8 * * *

# JOB 2 - weekly summary: every Monday at 09:00:00 AM
app.cron.weekly=0 0 9 * * MON

# JOB 3 - student count snapshot: every hour on the hour, 9 AM to 5 PM, Monday to Friday
app.cron.office-hours=0 0 9-17 * * MON-FRI

# JOB 4 - monthly report: last day of every month at 6:00 PM  (L = last day)
app.cron.monthly=0 0 18 L * *

# TIP 1: to test quickly, set the daily job to run every 20 seconds:   app.cron.daily=*/20 * * * * *
# TIP 2: to switch a job OFF without deleting it, set its value to a single dash:   app.cron.weekly=-Code language: PHP (php)

Step 5: Create Entity Class

package com.example.cron.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

// One row for every report run (by cron or manually)
@Entity
@Table(name = "report_logs")
public class ReportLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // DAILY, WEEKLY, OFFICE_HOURS, MONTHLY
    private String reportType;

    // CRON or MANUAL
    private String triggeredBy;

    // The cron expression of the job that produced this row
    private String cronExpression;

    private String zone;

    // Local time in the configured zone
    private LocalDateTime generatedAt;

    private long totalStudents;

    private long newStudentsInPeriod;

    private Double averageMarks;

    @Column(length = 1000)
    private String courseSummary;

    public ReportLog() {
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getReportType() { return reportType; }
    public void setReportType(String reportType) { this.reportType = reportType; }

    public String getTriggeredBy() { return triggeredBy; }
    public void setTriggeredBy(String triggeredBy) { this.triggeredBy = triggeredBy; }

    public String getCronExpression() { return cronExpression; }
    public void setCronExpression(String cronExpression) { this.cronExpression = cronExpression; }

    public String getZone() { return zone; }
    public void setZone(String zone) { this.zone = zone; }

    public LocalDateTime getGeneratedAt() { return generatedAt; }
    public void setGeneratedAt(LocalDateTime generatedAt) { this.generatedAt = generatedAt; }

    public long getTotalStudents() { return totalStudents; }
    public void setTotalStudents(long totalStudents) { this.totalStudents = totalStudents; }

    public long getNewStudentsInPeriod() { return newStudentsInPeriod; }
    public void setNewStudentsInPeriod(long newStudentsInPeriod) { this.newStudentsInPeriod = newStudentsInPeriod; }

    public Double getAverageMarks() { return averageMarks; }
    public void setAverageMarks(Double averageMarks) { this.averageMarks = averageMarks; }

    public String getCourseSummary() { return courseSummary; }
    public void setCourseSummary(String courseSummary) { this.courseSummary = courseSummary; }
}
package com.example.cron.entity;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "students")
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    private String course;

    private Integer marks;

    // Exact moment of registration (time-zone independent); used to count "new students" in a period
    private Instant createdAt;

    @PrePersist
    void onCreate() {
        this.createdAt = Instant.now();
    }

    public Student() {
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getCourse() { return course; }
    public void setCourse(String course) { this.course = course; }

    public Integer getMarks() { return marks; }
    public void setMarks(Integer marks) { this.marks = marks; }

    public Instant getCreatedAt() { return createdAt; }
    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

Step 6: Create below Repository Interface

package com.example.cron.repository;

import com.example.cron.entity.ReportLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface ReportLogRepository extends JpaRepository<ReportLog, Long> {

    List<ReportLog> findTop20ByOrderByIdDesc();

    List<ReportLog> findTop20ByReportTypeOrderByIdDesc(String reportType);
}
package com.example.cron.repository;

import com.example.cron.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.Instant;
import java.util.List;

@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {

    boolean existsByEmail(String email);

    long countByCreatedAtBetween(Instant start, Instant end);

    @Query("select avg(s.marks) from Student s")
    Double findAverageMarks();

    // Each row: [course, number of students]
    @Query("select s.course, count(s) from Student s group by s.course order by s.course")
    List<Object[]> countStudentsByCourse();
}

Step 7: Create below Service class

package com.example.cron.service;

import com.example.cron.entity.ReportLog;
import com.example.cron.repository.ReportLogRepository;
import com.example.cron.repository.StudentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;

@Service
public class ReportService {

    private static final Logger log = LoggerFactory.getLogger(ReportService.class);

    private final StudentRepository studentRepository;
    private final ReportLogRepository reportLogRepository;

    // The same properties that drive the @Scheduled annotations (kept here to store them in the report rows)
    @Value("${app.cron.zone}")
    private String zone;

    @Value("${app.cron.daily}")
    private String dailyCron;

    @Value("${app.cron.weekly}")
    private String weeklyCron;

    @Value("${app.cron.office-hours}")
    private String officeHoursCron;

    @Value("${app.cron.monthly}")
    private String monthlyCron;

    public ReportService(StudentRepository studentRepository, ReportLogRepository reportLogRepository) {
        this.studentRepository = studentRepository;
        this.reportLogRepository = reportLogRepository;
    }

    // ==================================================================
    // JOB 1 - THE EXPERIMENT: daily Student report at a specific time
    //   0 30 8 * * *   ->   second 0, minute 30, hour 8, every day, every month, every weekday
    //   zone = the time zone in which "8:30" is measured
    // ==================================================================
    @Scheduled(cron = "${app.cron.daily}", zone = "${app.cron.zone}")
    public void dailyReport() {
        runJob("DAILY", dailyCron);
    }

    // JOB 2 - every Monday at 09:00
    @Scheduled(cron = "${app.cron.weekly}", zone = "${app.cron.zone}")
    public void weeklySummary() {
        runJob("WEEKLY", weeklyCron);
    }

    // JOB 3 - on the hour, 9 to 17, Monday to Friday
    @Scheduled(cron = "${app.cron.office-hours}", zone = "${app.cron.zone}")
    public void officeHoursSnapshot() {
        runJob("OFFICE_HOURS", officeHoursCron);
    }

    // JOB 4 - last day of the month at 18:00 (L = last day of the month)
    @Scheduled(cron = "${app.cron.monthly}", zone = "${app.cron.zone}")
    public void monthlyReport() {
        runJob("MONTHLY", monthlyCron);
    }

    // Common wrapper: log, run, and never let an exception escape
    private void runJob(String type, String cron) {
        log.info("[CRON] {} job fired (cron='{}', zone={})", type, cron, zone);
        try {
            ReportLog report = generate(type, cron, "CRON");
            log.info("[CRON] {} report saved: id={}, total={}, new in period={}, average marks={}",
                    type, report.getId(), report.getTotalStudents(),
                    report.getNewStudentsInPeriod(), report.getAverageMarks());
        } catch (Exception ex) {
            log.error("[CRON] {} job failed", type, ex);
        }
    }

    // ==================================================================
    // Report logic - used by the cron jobs AND by the manual API
    // ==================================================================
    public ReportLog generate(String type, String cron, String triggeredBy) {
        ZoneId zoneId = ZoneId.of(zone);
        ZonedDateTime now = ZonedDateTime.now(zoneId);

        // Start of the period this report covers, measured in the configured time zone
        Instant periodStart;
        switch (type) {
            case "WEEKLY" -> periodStart = now.minusDays(7).toInstant();
            case "OFFICE_HOURS" -> periodStart = now.minusHours(1).toInstant();
            case "MONTHLY" -> periodStart = LocalDate.now(zoneId).withDayOfMonth(1).atStartOfDay(zoneId).toInstant();
            default -> periodStart = LocalDate.now(zoneId).atStartOfDay(zoneId).toInstant();   // DAILY
        }

        long total = studentRepository.count();
        long newInPeriod = studentRepository.countByCreatedAtBetween(periodStart, now.toInstant());
        Double average = studentRepository.findAverageMarks();

        List<String> parts = new ArrayList<>();
        for (Object[] row : studentRepository.countStudentsByCourse()) {
            parts.add((row[0] == null ? "Not set" : row[0]) + "=" + row[1]);
        }

        ReportLog report = new ReportLog();
        report.setReportType(type);
        report.setTriggeredBy(triggeredBy);
        report.setCronExpression(cron);
        report.setZone(zone);
        report.setGeneratedAt(LocalDateTime.now(zoneId));
        report.setTotalStudents(total);
        report.setNewStudentsInPeriod(newInPeriod);
        report.setAverageMarks(average == null ? null : Math.round(average * 100.0) / 100.0);
        report.setCourseSummary(String.join(", ", parts));
        return reportLogRepository.save(report);
    }

    // Manual run: same logic, uses the cron text of the matching job
    public ReportLog runManually(String type) {
        String upper = type.toUpperCase();
        String cron = switch (upper) {
            case "DAILY" -> dailyCron;
            case "WEEKLY" -> weeklyCron;
            case "OFFICE_HOURS" -> officeHoursCron;
            case "MONTHLY" -> monthlyCron;
            default -> throw new IllegalArgumentException("Unknown type: " + type
                    + ". Use DAILY, WEEKLY, OFFICE_HOURS or MONTHLY");
        };
        return generate(upper, cron, "MANUAL");
    }

    public List<ReportLog> getReports(String type) {
        if (type == null || type.isBlank()) {
            return reportLogRepository.findTop20ByOrderByIdDesc();
        }
        return reportLogRepository.findTop20ByReportTypeOrderByIdDesc(type.toUpperCase());
    }
}
package com.example.cron.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;

import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Helps you SEE what a cron expression means: lists the configured jobs and previews run times
@Service
public class ScheduleService {

    private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss z");

    @Value("${app.cron.zone}")
    private String zone;

    @Value("${app.cron.daily}")
    private String dailyCron;

    @Value("${app.cron.weekly}")
    private String weeklyCron;

    @Value("${app.cron.office-hours}")
    private String officeHoursCron;

    @Value("${app.cron.monthly}")
    private String monthlyCron;

    // The next run times of a cron expression
    public List<String> preview(String cron, String zoneName, int count) {
        if (cron == null || !CronExpression.isValidExpression(cron)) {
            throw new IllegalArgumentException("Invalid cron expression: '" + cron
                    + "'. Use 6 fields: second minute hour day-of-month month day-of-week");
        }
        ZoneId zoneId;
        try {
            zoneId = ZoneId.of(zoneName == null || zoneName.isBlank() ? zone : zoneName);
        } catch (DateTimeException ex) {
            throw new IllegalArgumentException("Invalid time zone: " + zoneName);
        }

        CronExpression expression = CronExpression.parse(cron);
        List<String> times = new ArrayList<>();
        ZonedDateTime next = ZonedDateTime.now(zoneId);
        for (int i = 0; i < Math.min(Math.max(count, 1), 20); i++) {
            next = expression.next(next);
            if (next == null) {
                break;
            }
            times.add(next.format(FORMAT));
        }
        return times;
    }

    // Every configured job with its cron expression and its next 3 run times
    public List<Map<String, Object>> listJobs() {
        List<Map<String, Object>> jobs = new ArrayList<>();
        jobs.add(job("DAILY", "Student report every day", dailyCron));
        jobs.add(job("WEEKLY", "Weekly summary", weeklyCron));
        jobs.add(job("OFFICE_HOURS", "Hourly snapshot in office hours", officeHoursCron));
        jobs.add(job("MONTHLY", "Month-end report", monthlyCron));
        return jobs;
    }

    private Map<String, Object> job(String name, String description, String cron) {
        Map<String, Object> info = new LinkedHashMap<>();
        info.put("job", name);
        info.put("description", description);
        info.put("cron", cron);
        info.put("zone", zone);
        if ("-".equals(cron)) {
            info.put("status", "DISABLED");
        } else {
            info.put("status", "ENABLED");
            info.put("nextRuns", preview(cron, zone, 3));
        }
        return info;
    }
}
package com.example.cron.service;

import com.example.cron.entity.Student;
import com.example.cron.repository.StudentRepository;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class StudentService {

    private final StudentRepository studentRepository;

    public StudentService(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }

    public Student add(Student student) {
        if (studentRepository.existsByEmail(student.getEmail())) {
            throw new IllegalArgumentException("Email already registered: " + student.getEmail());
        }
        return studentRepository.save(student);
    }

    // Adds many students at once; duplicate emails are skipped
    public List<Student> addAll(List<Student> students) {
        List<Student> saved = new ArrayList<>();
        for (Student s : students) {
            if (!studentRepository.existsByEmail(s.getEmail())) {
                saved.add(studentRepository.save(s));
            }
        }
        return saved;
    }

    public List<Student> getAll() {
        return studentRepository.findAll();
    }
}

Step 8: Create Controller class

package com.example.cron.controller;

import com.example.cron.entity.ReportLog;
import com.example.cron.service.ReportService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/reports")
public class ReportController {

    private final ReportService reportService;

    public ReportController(ReportService reportService) {
        this.reportService = reportService;
    }

    // Latest 20 reports. Optional filter: ?type=DAILY
    @GetMapping
    public List<ReportLog> getReports(@RequestParam(required = false) String type) {
        return reportService.getReports(type);
    }

    // Runs a report right now (for testing): DAILY, WEEKLY, OFFICE_HOURS or MONTHLY
    @PostMapping("/run/{type}")
    public ResponseEntity<?> runNow(@PathVariable String type) {
        try {
            return ResponseEntity.ok(reportService.runManually(type));
        } catch (IllegalArgumentException ex) {
            return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
        }
    }
}
package com.example.cron.controller;

import com.example.cron.service.ScheduleService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/schedule")
public class ScheduleController {

    private final ScheduleService scheduleService;

    public ScheduleController(ScheduleService scheduleService) {
        this.scheduleService = scheduleService;
    }

    // All jobs with their cron expression and next 3 run times
    @GetMapping("/jobs")
    public List<Map<String, Object>> jobs() {
        return scheduleService.listJobs();
    }

    // Try any cron expression before putting it in application.properties
    // Example: /schedule/preview?cron=0 30 8 * * *&count=5&zone=Asia/Kolkata
    @GetMapping("/preview")
    public ResponseEntity<?> preview(@RequestParam String cron,
                                     @RequestParam(defaultValue = "5") int count,
                                     @RequestParam(required = false) String zone) {
        try {
            return ResponseEntity.ok(Map.of("cron", cron, "nextRuns", scheduleService.preview(cron, zone, count)));
        } catch (IllegalArgumentException ex) {
            return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
        }
    }
}
package com.example.cron.controller;

import com.example.cron.entity.Student;
import com.example.cron.service.StudentService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/students")
public class StudentController {

    private final StudentService studentService;

    public StudentController(StudentService studentService) {
        this.studentService = studentService;
    }

    @PostMapping
    public ResponseEntity<?> add(@RequestBody Student student) {
        try {
            return ResponseEntity.status(HttpStatus.CREATED).body(studentService.add(student));
        } catch (IllegalArgumentException ex) {
            return ResponseEntity.badRequest().body(Map.of("error", ex.getMessage()));
        }
    }

    @PostMapping("/bulk")
    public ResponseEntity<List<Student>> addAll(@RequestBody List<Student> students) {
        return ResponseEntity.status(HttpStatus.CREATED).body(studentService.addAll(students));
    }

    @GetMapping
    public List<Student> getAll() {
        return studentService.getAll();
    }
}

Step 9: Main Application

package com.example.cron;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class CronApplication {

    public static void main(String[] args) {
        SpringApplication.run(CronApplication.class, args);
    }
}

Step 10: Run the CronApplication

Scroll to Top