The */30 * * * * Expression
*/30 * * * * fires at minute 0 and minute 30 of every hour, totaling 2 runs per hour, 48 per day. The step */30 starts at 0 (the minimum of the minute field) and advances by 30, which yields exactly two values: 0 and 30.
The schedule is as simple as it gets: top of the hour and bottom of the hour, every hour, all day. There is no ambiguity about which minutes are selected and no edge cases about field ranges.
*/30 vs 0,30: Which to Use
Both expressions produce the same schedule. The choice is about intent communication:
*/30 * * * * # "every 30 minutes"
0,30 * * * * # "at minute 0 and minute 30"
The explicit 0,30 form is arguably more defensive. It leaves no room for misreading. A developer unfamiliar with step syntax can read 0,30 * * * * correctly on first glance. */30 requires knowing that the step starts at 0 in the minute field.
In code comments or runbooks where the audience includes non cron experts, 0,30 is the safer choice. In a team of engineers comfortable with cron syntax, */30 is fine and is what most people reach for.
Use Cases for Half Hourly Schedules
Digest emails and notifications
Many notification systems batch updates and send summaries on a half hourly cadence. A 30 minute window is long enough to accumulate meaningful content but short enough that users do not feel out of the loop. Twice hourly digests are a common default in alerting and newsletter platforms.
Report generation
Dashboards that show “last 30 minutes” metrics align naturally with a half hourly generation schedule. Running the report at :00 and :30 means the data is always at most 30 minutes stale, which is acceptable for most internal reporting.
Data sync with moderate update frequency
If a source system updates every 15 to 20 minutes, a 30 minute sync means you are at most one update cycle behind. This is often the right tradeoff when the sync involves a nontrivial database write or file transfer.
Caching and index refresh
For search indexes or materialized views that are expensive to rebuild, twice hourly refresh limits the blast radius of a slow rebuild while keeping content reasonably fresh.
Shifting the Phase
The default */30 always fires at :00 and :30. If you want the half hourly job offset from the hour mark, use an explicit start:
15,45 * * * * # fires at :15 and :45
This is equivalent to 15/30 * * * *, but 15,45 is more readable.
Phase shifted schedules are useful when you have multiple half hourly jobs and want to spread the load:
0,30 * * * * /opt/jobs/report-generator
15,45 * * * * /opt/jobs/data-sync
7,37 * * * * /opt/jobs/cache-warmer
These three jobs are now evenly staggered across the 30 minute window. No two fire at the same minute.
Comparison with @hourly
@hourly (which expands to 0 * * * *) fires 24 times per day. */30 * * * * fires 48 times per day, exactly twice as often.
The choice between hourly and half hourly comes down to your acceptable staleness window. If the job maintains a cache and the underlying data changes faster than every hour, half hourly keeps the cache fresher. If the data changes once per hour at most, @hourly is sufficient and generates half the load.
Neither is always right. Measure how often your data source actually changes before picking the interval.
Running the Job vs @hourly: Side by Side
0 * * * * # @hourly: 00:00, 01:00, 02:00 ... (24/day)
0,30 * * * * # twice-hourly: 00:00, 00:30, 01:00, 01:30 ... (48/day)
If you upgrade from hourly to half hourly, double check that the job is idempotent or designed for twice as frequent execution. Some jobs (like sending a summary email) should only fire once per trigger, and running twice per hour would double the emails. Others (like a cache refresh) are perfectly safe to run more frequently.
Verifying the Schedule
from croniter import croniter
from datetime import datetime
cron = croniter("*/30 * * * *", datetime.now())
for _ in range(6):
print(cron.get_next(datetime))
This prints the next 6 execution times, covering three hours of data, showing the :00/:30 pattern clearly. Compare it against 15,45 * * * * to see how a phase shift affects the output.
To confirm the job is running on a live system:
journalctl -u cron --since "3 hours ago" | grep myjob | wc -l
Over 3 hours you should see exactly 6 entries. Fewer suggests the job is failing or exiting without output; more suggests duplicate entries in the crontab.