Cron and at in Linux: The Two Schedulers a Developer Actually Uses

Sean

Platform Writer

Jun 10, 2026
6 min read

Cron and at are the two built-in Linux schedulers: cron runs a job on a recurring schedule, at runs a job once at a specific moment. Cron is the one that runs the same job every minute, every hour, every Monday at 3 a.m. — the heartbeat of every Linux server. at is the one-off cousin that runs a job tomorrow at 4 p.m., or in 5 minutes, or never again. Most teams only ever learn cron and end up reaching for hacks like sleep 86400 for one-off work. The honest answer is that both schedulers have a job to do, and the developer who knows the difference ships cleaner jobs and clearer ops.

The reason this is its own question and not just “how to schedule” is that cron and at are different by design. Cron is a persistent daemon that wakes up every minute and asks “is there a job for this exact time?” at is a one-shot scheduler that takes a job, queues it, runs it, and forgets it. The choice is not “cron or at” — the choice is “is this recurring, or is this a single moment?” Once that question is answered the rest of the syntax, debugging, and deploy story follows.

Cron and at in Linux: the two schedulers a developer actually uses

Table of contents

The short version

A cron job is a line in a crontab that says “run this command at these minutes, hours, days, months, and weekdays.” The cron daemon wakes up every minute, evaluates every crontab on the system, and runs the jobs that match. An at job is a command queued with the at command, given a wall-clock time, and run exactly once by the atd daemon. Cron is for “every Monday at 3 a.m.” at is for “tomorrow at 4 p.m.” The two schedulers share almost nothing in implementation, and that is the point — they cover different shapes of work.

Cron is the recurring scheduler; at is the one-shot scheduler

The split is intentional. A system that has to run the same job every hour does not want to think about the job after it is set up — cron forgets the job after each run, then re-evaluates the schedule the next minute. A system that has to run a job once at a specific time does not want to remember the job after it runs — at discards the job from its queue as soon as the daemon finishes it. Two schedulers, two mental models, two daemons (crond and atd).

Cron for recurring work. Database backups at 2 a.m. every day. Log rotation on the 1st of the month. A health-check ping every 5 minutes. A newsletter send at 9 a.m. every Monday. Anything that has a “this happens on a rhythm” property belongs in cron. The job is a line in a crontab, the daemon handles the rest.

at for one-off work. Restart a service at 3 a.m. during the maintenance window. Send a one-time reminder email in 20 minutes. Run a cleanup script when the maintenance ends. Anything that has a “this happens once, at this moment, then never again” property belongs in at. The job is queued with at <time>, the daemon runs it, and the job is gone.

The split is not a religious war. It is a useful default. A team that uses cron for one-off work is a team that has to remember to delete the crontab entry after the job runs. A team that uses at for recurring work is a team that has to re-queue the job every time it runs. Both failures are real. Pick the right tool for the shape of the work.

The five crontab fields that decide when a job runs

A cron line has five time fields, followed by a command. The order is minute, hour, day of month, month, day of week. The wildcard * means “every.” A comma means “or.” A slash means “step.” A dash means “range.” That is the entire syntax. The rest is pattern.

# m  h  dom  mon  dow  command
  0  2  *    *    *    /usr/local/bin/backup-db.sh
  */5 * *   *    *    curl -fsS https://example.com/healthz
  0  9  *    *    1    /usr/local/bin/newsletter-send.sh

The minute field is 0–59, the hour field is 0–23. The day of week is 0–7, where both 0 and 7 are Sunday. The day of month is 1–31. The month is 1–12 or named. The fields are evaluated by the cron daemon every minute, and the job runs if every field matches the current time. If both day-of-month and day-of-week are restricted, the job runs when either matches — that is the “OR” rule, not “AND,” and it surprises a lot of developers the first time.

Special strings save typing. @reboot runs once at startup, @hourly is 0 * * * *, @daily is 0 0 * * *, @weekly is 0 0 * * 0, @monthly is 0 0 1 * *, @yearly is 0 0 1 1 *. Use them. They read better than the expanded form and they are easier to grep for when something breaks.

Output handling is the part everyone gets wrong. A cron job runs without a terminal, so stdout and stderr go nowhere unless the script redirects them. The best practice is to redirect both to a log file or to a mail program. The better practice for a deploy environment is to redirect to a log file, and to ship that log file to a log service. The “cron emailed me about this for 200 days and I never read it” failure mode is the most common cron bug in production.

The at syntax that beats the sleep hack

The at command takes a time spec, reads the command from stdin or a file, and queues it. The time spec is permissive — at 4pm, at 4pm tomorrow, at now + 20 minutes, at 02:00 2026-06-15, at 3am + 7 days all work. The daemon runs the job at the moment, captures the output, and emails the user when it finishes (unless the system is configured not to).

# Run in 20 minutes
echo "systemctl restart my-service" | at now + 20 minutes

# Run at 3 a.m. tomorrow
echo "/usr/local/bin/cleanup.sh > /var/log/cleanup.log 2>&1" | at 3am tomorrow

# Run at a specific date and time
echo "echo done" | at 14:30 2026-06-15

The win over sleep is that at survives reboots (if the system was off, the job runs at the next opportunity), the job is queryable (atq), and the job can be removed (atrm <job-id>). The sleep hack fails the first time the box restarts, and the developer has no clean way to see or cancel the job.

atq shows the queue. The output is a job ID, the date, the time, the queue letter (always a on most systems unless the user is using multiple queues), and the user. at -c <job-id> dumps the full command and environment. atrm <job-id> removes a queued job. The three commands together are the operational surface for one-off scheduled work.

Where cron and at actually live on a Linux system

The crontab for a user lives at /var/spool/cron/crontabs/<user> and is edited with crontab -e. The system crontab is at /etc/crontab and is edited directly. There are drop-in directories at /etc/cron.d/, /etc/cron.daily/, /etc/cron.hourly/, /etc/cron.weekly/, /etc/cron.monthly/ for system jobs that run on a fixed cadence without a crontab line.

The at queue is at /var/spool/at/ (older systems) or /var/spool/cron/atjobs/ (some distros). The jobs are plain text files, the spool is a directory, and the daemon walks the directory every minute. The default at queue limit is 256 jobs per user — more than enough for one-off work, not enough for a developer who is using at as a poor man’s cron.

The PATH and environment are the part the manuals skip. A cron job runs with a near-empty PATH (usually /usr/bin:/bin and not much else), no TERM, and no locale. A script that works in the developer’s terminal can fail in cron because /usr/local/bin is not on the PATH, or because the script relies on ~/.bashrc. The fix is to set PATH at the top of the crontab and to call the script with an absolute path. The same applies to at jobs.

The six things that quietly break a scheduled job

A short, opinionated list of bugs that have cost real teams real hours on real schedulers. None of them are dramatic. They are the boring ones.

A script that depends on the developer’s shell environment. Cron runs with a near-empty environment. A crontab line that calls python instead of /usr/bin/python3 can fail when the system has both Python 2 and Python 3, or no python symlink at all. The fix is absolute paths and an explicit PATH at the top of the crontab.

Output that goes nowhere. A cron job that produces output but does not redirect it is a cron job that emails the user. If the local mail system is not configured, the mail sits in the local spool and the developer never sees it. The fix is to redirect stdout and stderr to a log file and to ship the log file to a log service the developer actually reads.

A job that runs as the wrong user. System cron is run as root. User cron is run as the user. A job that needs to read /etc/secrets/db.env should not run as the developer whose crontab it lives in. The fix is to put the job in /etc/cron.d/<jobname> and set the user explicitly, or to use sudo -u <user> <command> in the crontab line.

A script that does not chmod +x. A crontab line that calls /usr/local/bin/backup.sh will silently fail if the file is not executable. The fix is to make the script executable and to confirm the shebang is on the first line.

Daylight saving time transitions. A job scheduled at 2:30 a.m. on the day of a DST jump either runs twice or not at all, depending on the direction. For most workloads the answer is to move the job outside the DST window, or to schedule it in UTC and convert at the user layer.

The daemon is not running. A box that has not been rebooted in two years still has crond running, but a fresh container, a hardened image, or a system that has been “minimized” sometimes does not. The fix is systemctl status cron (or crond on RHEL-flavored systems) before the developer spends an hour debugging a script that is fine.

When to use a managed scheduler instead

A platform that needs dozens of jobs, jobs that depend on each other, jobs that need to scale, or jobs that need to be observed from a dashboard is a platform that has outgrown crontab. The signal is when the team is wrapping cron jobs in shell scripts that poll a database, or in systemd timers that need their own service files, or in a long-running daemon that has a queue inside it.

A managed scheduler is the right answer when the job list lives in a database, when the schedule is in a UI, when the job is a Docker image, when the job has retries and timeouts and a success/failure feed, and when the job is part of the same deploy story as the rest of the services. The win is that the team stops maintaining a crontab file on a box somewhere and starts treating scheduled work as a first-class deployable. The cost is that the team has to learn the managed scheduler.

For a small project that has three jobs and one database, cron is fine. For a project that has thirty jobs, two databases, a worker tier, and an operations team, the managed scheduler pays for itself the first time a job silently fails on a Saturday at 2 a.m.

How this fits the rest of the stack

A scheduled job rarely lives in isolation. The job usually calls into an API, queries a database, sends an email, writes a file to storage, or fires a webhook. The hosting platform that handles the job should make the rest of the stack feel like part of the same conversation.

The services layer is the part of the platform that runs the long-lived API the cron job calls. The database layer is the part that holds the data the job reads and writes. The static layer is the part that hosts the dashboard where the job’s status is reported. The environment variables are the part that holds the secrets the job reads at runtime.

A job that runs on a platform where the API, the database, the storage, and the secrets are all in the same console is a job the team is going to be able to debug. A job that runs on a platform where each piece is in a different console is a job the team is going to spend the first hour just opening the right tab.

For a team that wants to see the full cost of the project before it commits, the RunxBuild hosting calculator shows the line items together. The API, the database, the storage, the worker, the bandwidth — each one is a separate number, and the team’s mental model for the platform is the sum of those numbers.

FAQ

What is the difference between cron and at in Linux?

Cron runs a job on a recurring schedule, defined in a crontab and managed by the crond daemon. at runs a job once at a specific moment, queued with the at command and managed by the atd daemon. Cron forgets the job after each run and re-evaluates the schedule the next minute. at discards the job from its queue as soon as the daemon finishes it. The choice is “is this recurring, or is this a one-off moment?”

Where is the crontab file stored on Linux?

A user’s crontab is at /var/spool/cron/crontabs/<user> and should be edited with crontab -e, not by editing the file directly. The system crontab is at /etc/crontab. There are also drop-in directories at /etc/cron.d/, /etc/cron.daily/, /etc/cron.hourly/, /etc/cron.weekly/, and /etc/cron.monthly/ for jobs that run on a fixed cadence without a crontab line.

Why is my cron job not running?

The most common causes are: a script that is not executable, a script that depends on a shell environment that is not present in the cron environment, output that goes nowhere and is being silently dropped, a job that runs as the wrong user, a PATH that does not include /usr/local/bin, and a cron daemon that is not running. The fix for most of these is crontab -e with absolute paths, an explicit PATH at the top of the crontab, output redirection to a log file, and systemctl status cron.

How do I schedule a one-time job in Linux?

Use at. Run echo "your-command" | at <time>, where <time> can be now + 20 minutes, 4pm tomorrow, 02:00 2026-06-15, or any of the many other time formats the command accepts. The job is queued, runs at the moment, and is removed from the queue when it finishes. Use atq to see queued jobs, at -c <job-id> to inspect one, and atrm <job-id> to cancel it.

Can cron and at run as different users?

Yes. User crontabs run as the user who owns them. The system crontab at /etc/crontab has a user field between the time fields and the command, so a system job can run as any user. The at queue can hold jobs for any user with permission to use the command, and atq shows the user. The default at.allow and at.deny files in /etc/ control which users can submit at jobs.

What is the best practice for cron output?

Redirect both stdout and stderr to a log file, and ship the log file to a log service the developer actually reads. The default cron behavior is to email the user with the output, which is fine in 1995 and a footgun in 2026. The form command >> /var/log/job.log 2>&1 is the minimum. The form that wins in production is the same redirect plus a log-shipping sidecar or a managed platform that captures stdout from the worker.