# Cron syntax and examples

> How to write pg_cron schedules on SupaHost. The five cron fields, ranges, lists and steps, sub-minute "N seconds" schedules, UTC times, and a table of ready-to-use examples.

Source: https://supahost.app/docs/cron-queues/cron-syntax · Updated 2026-11-11

A pg_cron schedule is either a standard five-field cron expression or an interval in seconds. Both are written as text in `cron.schedule()` or in the Schedule field of the dashboard's Cron tab. All times are **UTC** on SupaHost.

## The five fields

```
┌───────────── minute        (0-59)
│ ┌─────────── hour          (0-23)
│ │ ┌───────── day of month  (1-31)
│ │ │ ┌─────── month         (1-12)
│ │ │ │ ┌───── day of week   (0-6, Sunday = 0)
│ │ │ │ │
* * * * *
```

A job runs whenever the current UTC time matches every field. A `*` means "any value".

## Building blocks

| Syntax | Meaning | Example | Reads as |
|---|---|---|---|
| `*` | Any value | `* * * * *` | Every minute |
| `n` | Exactly n | `15 4 * * *` | 04:15 every day |
| `a-b` | Range | `0 9-17 * * *` | On the hour, 09:00 to 17:00 |
| `a,b,c` | List | `0 8,12,18 * * *` | 08:00, 12:00 and 18:00 |
| `*/n` | Every n | `*/10 * * * *` | Every 10 minutes |
| `a-b/n` | Every n within a range | `0 8-20/4 * * *` | 08:00, 12:00, 16:00, 20:00 |

When both day of month and day of week are restricted (neither is `*`), the job runs when **either** matches. `0 9 1 * 1` runs at 09:00 on the 1st of each month and also every Monday. To run on "the first Monday", schedule it every Monday and check the date in SQL.

## Examples

| Schedule | When it runs (UTC) | Typical job |
|---|---|---|
| `* * * * *` | Every minute | Process a queue, send due notifications |
| `*/5 * * * *` | Every 5 minutes | Sync with an external API |
| `0 * * * *` | At the start of every hour | Refresh a materialized view |
| `30 2 * * *` | Every day at 02:30 | Delete expired rows |
| `0 3 * * 0` | Sundays at 03:00 | Weekly cleanup or report |
| `0 9 * * 1-5` | Weekdays at 09:00 | Send a morning digest |
| `0 0 1 * *` | Midnight on the 1st of each month | Generate monthly invoices |
| `0 6 1 1,4,7,10 *` | 06:00 on the first day of each quarter | Quarterly summaries |
| `15,45 * * * *` | At :15 and :45 every hour | Spread two runs across the hour |
| `30 seconds` | Every 30 seconds | Short polling work |

The dashboard's Schedule presets cover the common cases: Every minute, Every 5 minutes, Hourly, Daily at 03:00 UTC and Every 30 seconds.

## Sub-minute schedules

Cron expressions can't go below one minute. For more frequent jobs, pg_cron accepts an interval in seconds, from 1 to 59:

```sql
select cron.schedule(
  'drain-email-queue',
  '20 seconds',
  $$ select net.http_post(
       url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
              || '/functions/v1/email-worker',
       headers := jsonb_build_object(
         'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key')
       )
     ); $$
);
```

Seconds schedules are intervals, not clock times, so use them for polling-style work rather than "at exactly :30". Keep each run short: a job never overlaps with itself, so a slow run delays the next one.

## Working with UTC

Schedules are evaluated in UTC, so convert local times before you write them:

| You want | Local offset | Write |
|---|---|---|
| 08:00 in London (winter, UTC+0) | +0 | `0 8 * * *` |
| 08:00 in Berlin (winter, UTC+1) | +1 | `0 7 * * *` |
| 08:00 in New York (winter, UTC-5) | -5 | `0 13 * * *` |
| 09:00 in Tokyo (UTC+9, no daylight saving) | +9 | `0 0 * * *` |

Cron doesn't follow daylight saving changes. A job at `0 7 * * *` runs at 08:00 Berlin time in winter and 09:00 in summer. If the local time matters exactly, either accept the one-hour shift, or schedule the job hourly and check the local time in SQL:

```sql
select cron.schedule(
  'berlin-morning-digest',
  '0 * * * *',
  $$
  do $job$
  begin
    if extract(hour from now() at time zone 'Europe/Berlin') = 8 then
      insert into public.digest_runs (ran_at) values (now());
      -- ...build and send the digest here
    end if;
  end
  $job$;
  $$
);
```

Note the different dollar-quote tag (`$job$`) inside the outer `$$ ... $$`, so the two don't clash.

## Checking a schedule

Before relying on a new schedule, confirm it registered the way you expect:

```sql
select jobname, schedule, active from cron.job where jobname = 'berlin-morning-digest';
```

Then watch the first few runs in *Dashboard → your project → **Cron** tab → Recent runs* or in `cron.job_run_details`. See [Monitoring and debugging cron jobs](/docs/cron-queues/monitoring-jobs).

If an expression isn't valid, `cron.schedule` raises an error and no job is created.

## Spreading the load

Many jobs scheduled at `0 * * * *` or `0 0 * * *` all start at the same moment, on the same server as your API and database. Offset them by a few minutes (`7 * * * *`, `23 * * * *`) so they don't compete. Also avoid 03:00 to 05:00 UTC for heavy jobs if your instance has automatic upgrades turned on, since that's the maintenance window.

## Related

- [Scheduled jobs with pg_cron](/docs/cron-queues/cron)
- [Cron job not running](/kb/cron-job-not-running)
- [Timezones and wrong local time in Postgres](/kb/timezones)
- [pg_cron on GitHub](https://github.com/citusdata/pg_cron)
