The cron expression */30 * * * * runs at :00 and :30 every hour (48 times per day). Covers */30 vs 0,30 syntax, phase shifting with 15,45, and use cases.
⏰Every 30 minutes
*Any value,List separator-Range/Step1-5Range 1 through 5*/15Every 15 units*/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.
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.
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.
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.
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.
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.
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.
@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.
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.
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.
Nothing you paste leaves this tab. Every tool runs entirely in your browser — no upload, no server, no account.