at Command in Linux to Schedule One-Time Tasks

Tested on RHEL 10.2 (Coughlan)
Package at 3.2.5-14.el10_1
Applies to RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora, Ubuntu, Debian
Privilege Normal user to schedule and cancel own jobs; sudo or root to install the package, manage the atd service, edit access lists, and touch another user's jobs
Scope Scheduling a command or script to run once at a future moment, reading and removing the queue, capturing job output, the environment a job inherits, and per-user access control. Does not cover recurring schedules.
Related guides crontab command
Schedule cron jobs with shell scripts
Schedule tasks with systemd timers
Delay a reboot in Linux
systemctl command

Sooner or later you need a command to run once, at a moment that is not now. Restart a service after the change window opens, delete a scratch directory tonight, warn logged-in users twenty minutes before you reboot. Reaching for cron feels wrong because there is nothing recurring about any of that, and you would only have to remember to remove the entry afterwards.

That is the job at was built for. You hand it a time and a command, it writes the command to disk, and a daemon runs it once and forgets it. This guide walks the whole cycle on a live host, including the one behaviour that makes people think at is broken when it is working exactly as designed.

IMPORTANT
An at job has no terminal. Anything your command prints is handed to the local mail system, and on a server with no mail agent installed that output is thrown away silently. Redirect output to a file inside the job unless you have already confirmed mail delivery works.

What Does the at Command Do?

at splits scheduling from execution across two pieces. The at command itself does almost nothing interesting: it reads your commands, wraps them in a shell script together with a snapshot of your environment, and drops that script into a spool directory. The atd daemon is what wakes up, notices a job is due, and runs it.

That split explains most of what surprises people later. Your job is a file on disk, so it survives logout and reboot. Your job runs with no terminal attached, so it cannot prompt you for anything. And if atd is not running, your job is accepted, listed, and never executed.

It is worth being clear about what at is not, because three other approaches solve neighbouring problems:

  • A backgrounded sleep 600 && command lives inside your shell session and dies with it, unless you wrap it in nohup; see Linux process management for that family of tools
  • cron exists to repeat something on a calendar pattern, so using it for a single run means writing an entry and then remembering to delete it
  • A systemd timer gives you unit dependencies and journal integration, and a calendar timer can catch up a missed run when you configure it with Persistent=true, at the cost of writing two unit files

at wins when the answer to "how many times should this run" is exactly once.


at Command Quick Reference

These are the commands used throughout this guide. Scheduling and cancelling your own jobs needs no elevation at all, and it grants none either: the job runs later as the user who submitted it, so a command that needs root when you type it by hand still needs root when atd runs it.

Schedule privileged work as root, or from an account that can reach it without a password prompt, since there is no terminal to answer one.

Task Command
Schedule interactively for 16:00 at 16:00
Schedule one command without a prompt echo 'date >> /var/tmp/at-job.log' | at 16:00
Run ten minutes from now at now + 10 minutes
Run tomorrow morning at 9am tomorrow
Read the commands from a file at -f ~/disk-report.sh 23:00
Use an absolute numeric time at -t 202612250900
List your queued jobs atq
List one queue only atq -q b
Show a job's generated script at -c 12
Cancel a job atrm 12
Never mail the job output at -M now + 5 minutes
Wait for the host to go idle batch

atq and atrm are not separate programs in spirit: at -l and at -r are documented aliases for exactly the same behaviour, so use whichever name you remember.


Install at and Start the atd Service

On RHEL and its rebuilds the package is often already present, so query it before installing anything.

bash
rpm -q at

Sample output:

output
at-3.2.5-14.el10_1.x86_64

A NEVRA rather than a "not installed" line means you can skip ahead to the service check. When the package is genuinely missing, the first sign is usually the shell rejecting the command itself.

bash
at now + 1 minute

Sample output:

output
bash: at: command not found

Install it from the base repository with dnf. Debian and Ubuntu carry the same upstream package under the same name, so sudo apt install at is the equivalent there.

bash
sudo dnf install at

The interesting part of that transaction is not the package but the scriptlet at the end:

output
Running transaction
  Preparing        :                                                        1/1
  Installing       : at-3.2.5-14.el10_1.x86_64                              1/1
  Running scriptlet: at-3.2.5-14.el10_1.x86_64                              1/1
Created symlink '/etc/systemd/system/multi-user.target.wants/atd.service' → '/usr/lib/systemd/system/atd.service'.

Installed:
  at-3.2.5-14.el10_1.x86_64

Complete!

That symlink means the RPM preset enabled atd for future boots. Enabled is not the same as running, which is exactly the trap, so ask systemd what state the unit is actually in.

bash
systemctl status atd

Sample output:

output
○ atd.service - Deferred execution scheduler
     Loaded: loaded (/usr/lib/systemd/system/atd.service; enabled; preset: enabled)
     Active: inactive (dead) since Sat 2026-08-08 17:42:49 IST; 24s ago
       Docs: man:atd(8)

Read the two words that matter together: enabled on the Loaded line, inactive (dead) on the Active line. The daemon will start at the next boot and is not running right now, so anything you schedule in this session sits in the queue untouched. Start it and enable it in one step with systemctl.

bash
sudo systemctl enable --now atd

Sample output:

output
Created symlink '/etc/systemd/system/multi-user.target.wants/atd.service' → '/usr/lib/systemd/system/atd.service'.

The symlink line only appears when the unit was not enabled yet; on a host where the preset already enabled it, the command prints nothing and simply starts the service. Either way, confirm the daemon is up before you trust the queue.

bash
systemctl is-active atd

Sample output:

output
active

active is the state every later section assumes. It is worth seeing what happens when it is not, because the message is easy to miss, so I stopped the daemon and queued a job anyway.

bash
echo 'date >> /tmp/at-noatd.log' | at now + 1 minute

Sample output:

output
warning: commands will be executed using /bin/sh
job 1 at Sat Aug  8 17:44:00 2026
Can't open /run/atd.pid to signal atd. No atd running?

The job number is real and the job is on disk, so that third line is a warning rather than a rejection. After starting atd the job fired at 17:44 on the dot and wrote its log, because the scheduled moment had not passed yet. Had it passed, the job would still not be lost: atd runs overdue jobs as soon as it starts.


Schedule Your First One-Time Job

The plain form of at takes a time and then reads your commands from the keyboard. Start it two minutes into the future so you do not have to wait long for the payoff.

bash
at now + 2 minutes

at answers with a warning about the shell, echoes the moment it resolved your time to, and then gives you its own prompt. Type one command per line and press Ctrl+D on an empty line to close the job:

output
warning: commands will be executed using /bin/sh
at Sat Aug  8 17:47:00 2026
at> date >> /tmp/at-test.log
at> <EOT>
job 3 at Sat Aug  8 17:47:00 2026

Three things in that transcript are worth naming. The at Sat Aug 8 17:47:00 2026 line is at telling you how it understood your time specification before it read anything, which is your chance to spot a mistake. The <EOT> marker is the echo of Ctrl+D. The final line gives you job number 3, and that number is the handle you use for everything else.

Typing at a prompt is awkward in scripts, so the more common form pipes a single command in. There is no interactive prompt in this shape, just the warning and the confirmation.

bash
echo 'date >> /tmp/at-test.log' | at now + 2 minutes

Sample output:

output
warning: commands will be executed using /bin/sh
job 4 at Sat Aug  8 17:57:00 2026

The warning appears every single time and does not mean anything is wrong. Once the scheduled minute arrives, the proof is in the file the job wrote.

bash
cat /tmp/at-test.log

Sample output:

output
Sat Aug  8 05:47:00 PM IST 2026

The timestamp is 17:47:00 exactly, which is the point: atd polls once a minute, so jobs fire on the minute rather than on the second you happened to submit them.


Time Formats at Understands

at accepts a small English-like grammar rather than a rigid field layout. The fastest way to learn it is to queue one job per format and let at tell you what it resolved to. Each line below is the confirmation at printed for that specification, submitted on Saturday 8 August 2026 at 17:47.

Specification Resolved to
now + 10 minutes Sat Aug 8 17:57:00 2026
now + 2 weeks Sat Aug 22 17:47:00 2026
midnight Sun Aug 9 00:00:00 2026
noon Sun Aug 9 12:00:00 2026
teatime Sun Aug 9 16:00:00 2026
4pm tomorrow Sun Aug 9 16:00:00 2026
10am Aug 15 Sat Aug 15 10:00:00 2026
09:00 2026-12-25 Fri Dec 25 09:00:00 2026

A few rules fall out of that table. The units after now + are minutes, hours, days, or weeks. The words midnight, noon, and teatime are literal keywords, and teatime means 16:00. When a clock time has already passed today, at rolls it forward to tomorrow rather than complaining, which is why midnight landed on the ninth and noon did too.

Most importantly, a date must come after the time of day, so 09:00 2026-12-25 parses and 2026-12-25 09:00 does not.

When you would rather be unambiguous than readable, -t takes a single numeric argument in [[CC]YY]MMDDhhmm form. This is the form to generate from scripts.

bash
echo 'echo demo' | at -t 202612250900

Sample output:

output
job 11 at Fri Dec 25 09:00:00 2026

Same moment, no English parsing involved. The parser is also where most first attempts fail, because at takes only a time on its command line and reads the commands from somewhere else. Passing the command as an argument produces a message that reads like a bug:

bash
at 18:30 "df -h"

Sample output:

output
syntax error. Last token seen: d
Garbled time

at tried to parse df -h as more time syntax, choked on the d, and exited with status 1. Nothing was scheduled. Pipe the command in, use -f, or type it at the prompt instead. Writing the date before the time fails the same way, with Last token seen: 09:00 instead, which is the ordering rule making itself felt.

One documented behaviour does not match this build. The man page states that a job set for a time in the past runs as soon as possible, but at 3.2.5 refuses it outright.

bash
echo 'echo x' | at 09:00 today

Sample output:

output
at: refusing to create job destined in the past

The same refusal came back from an explicit past date and from -t, so treat "schedule it in the past to run it now" as something that no longer works here.

The reason it differs from the manual page is that the refusal arrived as a distribution patch rather than upstream: the package changelog for this build records "Add patch to fix past date handling" against at-3.2.5-13, and the host is running -14.el10_1.

bash
rpm -q --changelog at | grep -B2 "past date"

Sample output:

output
* Mon Jun 30 2025 Ondřej Pohořelský <opohorel@redhat.com> - 3.2.5-13
- Add patch to fix past date handling

Run that query on your own host before relying on either behaviour, because an older build or a different distribution may still accept a past time and run it immediately.

Note also the difference from a bare 09:00 with no today, which is not a past time at all as far as at is concerned, because it means the next nine in the morning.


List Queued Jobs with atq

atq prints the pending queue and nothing else. As an ordinary user you see only your own jobs; run it as root and you see everybody's.

bash
atq

Sample output:

output
4	Sat Aug  8 17:57:00 2026 a root
9	Sat Aug 22 17:47:00 2026 a root
10	Sat Aug 15 10:00:00 2026 a root
11	Fri Dec 25 09:00:00 2026 a root
12	Sun Aug  9 09:00:00 2026 a root
18	Sat Aug  8 18:24:00 2026 a atlab

Each line carries four pieces of information: the job number, the scheduled moment, the queue letter, and the owning user. The queue letter is a for everything submitted with at and b for everything submitted with batch.

Read the ordering carefully, because it catches people out. atq makes no promise about order at all: it walks the spool directory and prints jobs in whatever order the filesystem hands them back.

On this host that happened to come out looking like job-number order, which is why job 9 in a fortnight sits above job 12 tomorrow morning, but you cannot rely on either interpretation. Treat atq as an inventory rather than a schedule, and when execution order is what you need, start by asking for a sortable time format with -o.

bash
atq -o '%Y-%m-%d %H:%M'

Sample output:

output
9	2026-08-22 17:47 a root
10	2026-08-15 10:00 a root
11	2026-12-25 09:00 a root
12	2026-08-09 09:00 a root

The format string is passed through strftime, so any specifier you would use with date works here.

Notice what did not change though: the dates are now sortable text, but the lines still came back in spool order, because -o only rewrites the display. Piping that through sort on the date and time columns is what actually reorders the queue.

bash
atq -o '%Y-%m-%d %H:%M' | sort -k2,3

Sample output:

output
12	2026-08-09 09:00 a root
10	2026-08-15 10:00 a root
9	2026-08-22 17:47 a root
11	2026-12-25 09:00 a root

Tomorrow morning's job 12 now leads and the Christmas job falls to the bottom, which is the order atd will really work through. Restricting the view to one queue is useful once you mix at and batch work on the same host.

bash
atq -q b

Sample output:

output
23	Sat Aug  8 17:58:00 2026 b root

Only the batch job came back, which confirms the queue letter really is a filter and not just decoration in the listing.

Inspect a Queued Job with at -c

A job number tells you when something will run but not what it will do. at -c prints the script at generated, which is the only complete answer.

bash
at -c 24

The output is longer than people expect. On my host it was 60 lines, 48 of which were exported environment variables copied from the shell where I submitted the job, so most of that block is cut out below:

output
#!/bin/sh
# atrun uid=0 gid=0
# mail root 0
umask 22
SHELL=/bin/bash; export SHELL
HOSTNAME=vm1.lab.example; export HOSTNAME
PWD=/root; export PWD
LOGNAME=root; export LOGNAME
HOME=/root; export HOME
LANG=en_US.UTF-8; export LANG
USER=root; export USER
MAIL=/var/spool/mail/root; export MAIL
... 36 more exported variables ...
cd /root || {
	 echo 'Execution directory inaccessible' >&2
	 exit 1
}
${SHELL:-/bin/sh} << 'marcinDELIMITER02bea859'
systemctl restart httpd

marcinDELIMITER02bea859

Four details in that script explain behaviour you will meet later:

  • # atrun uid=0 gid=0 records who the job runs as, fixed at submission time
  • umask 22 and the long block of exports are your environment, frozen when you pressed Ctrl+D
  • cd /root || { ... exit 1 } reproduces your working directory and aborts the whole job if that directory has gone
  • ${SHELL:-/bin/sh} re-executes your commands under the shell you were using, despite the /bin/sh shebang above

Your actual commands are the lines inside the heredoc at the bottom. Asking for a job number that is not in the queue fails plainly rather than printing nothing.

bash
at -c 999

Sample output:

output
Cannot find jobid 999

Exit status is 1 there, which makes it usable in a script that checks whether a job is still pending.


Remove a Queued Job with atrm

atrm takes job numbers and deletes the spooled scripts. It succeeds silently, so do not read the empty response as a failure.

bash
atrm 5

Nothing is printed when the job is gone. The only way to be sure is to look at the queue again.

bash
atq

Sample output:

output
4	Sat Aug  8 17:57:00 2026 a root
6	Sun Aug  9 00:00:00 2026 a root
7	Sun Aug  9 16:00:00 2026 a root
8	Fri Dec 25 09:00:00 2026 a root

Job 5 is missing from the listing, which is the confirmation. Several numbers can go in one invocation, which is handy after a round of testing.

bash
atrm 6 7 8

That clears all three with no output at all. A number that was never in the queue, or that already ran, is a hard error instead.

bash
atrm 999

Sample output:

output
Cannot find jobid 999

Root can remove any user's job, which is the only way to cancel work somebody else queued. Ordinary users are restricted to their own job numbers and cannot even see anybody else's.


Schedule a Shell Script with at

Real one-off work is usually a script rather than a single command, and there are two ways to hand a script to at that behave differently. Start with a small report script written the way you would write any bash script, sending its own output to a log so nothing depends on mail.

bash
cat /root/at-lab/disk-report.sh

Sample output:

output
#!/bin/bash
# One-shot disk usage report, meant to be run once by at.
LOG=/var/tmp/disk-report.log
{
  echo "=== disk report $(date '+%F %T %Z')"
  df -h --output=source,size,used,avail,pcent / /boot
  echo
} >> "$LOG" 2>&1

Note the permissions before scheduling it, because this is the difference between the two methods:

bash
ls -l /root/at-lab/disk-report.sh

Sample output:

output
-rw-r--r--. 1 root root 224 Aug  8 17:48 /root/at-lab/disk-report.sh

No execute bit anywhere. The -f option reads the file and embeds its lines in the job, so the file is a list of commands rather than a program and it runs anyway.

bash
at -f /root/at-lab/disk-report.sh now + 2 minutes

Sample output:

output
warning: commands will be executed using /bin/sh
job 13 at Sat Aug  8 17:50:00 2026

Two minutes later the script had done its work, non-executable and all:

bash
cat /var/tmp/disk-report.log

Sample output:

output
=== disk report 2026-08-08 17:50:00 IST
Filesystem             Size  Used Avail Use%
/dev/mapper/rhel-root   25G  9.2G   16G  37%
/dev/sda2              2.0G  509M  1.5G  26%

That is convenient, but it hides something. Inspecting the generated job shows the whole file, shebang included, pasted inside the heredoc:

bash
at -c 30

Sample output:

output
${SHELL:-/bin/sh} << 'marcinDELIMITER1f5a21f4'
#!/bin/bash
# One-shot disk usage report, meant to be run once by at.
LOG=/var/tmp/disk-report.log
... rest of the script verbatim ...
marcinDELIMITER1f5a21f4

The #!/bin/bash line is now an ordinary comment, so it chooses nothing. With -f, your interpreter is whatever SHELL was in the submitting session, not what your script asked for.

The second method keeps the shebang meaningful: put the path of the script in the job instead of feeding the file to -f. Now the execute bit matters, and leaving it off produces a failure you can actually read because the redirection catches it.

bash
echo '/root/at-lab/disk-report.sh >> /var/tmp/at-path.log 2>&1' | at now + 1 minute

Once that job ran, the log held the error rather than a report:

bash
cat /var/tmp/at-path.log

Sample output:

output
/bin/bash: line 1: /root/at-lab/disk-report.sh: Permission denied

The message names /bin/bash as the shell that refused, which is that ${SHELL:-/bin/sh} line proving itself again. Grant the execute bit and the same job succeeds.

bash
chmod +x /root/at-lab/disk-report.sh

Rerunning the identical at command afterwards left /var/tmp/at-path.log empty at zero bytes and wrote a fresh entry into the script's own log, which is what success looks like when a script handles its own output. Pick -f when you want at to own a list of commands, and the path form when the script is a real program with an interpreter it cares about.

Whichever form you choose, use absolute paths inside the script. Your working directory is reproduced faithfully, but the directory you happened to be in when you scheduled the job is rarely the directory you were thinking about weeks later.


Capture Output from at Jobs

This is the single most common reason people conclude at does not work. A job with no terminal cannot print to your screen, so at hands standard output and standard error to the local mail system instead. Watch what that means on a server with no mail agent installed. First a job that prints something and redirects nothing:

bash
echo 'df -h /' | at now + 3 minutes

Sample output:

output
warning: commands will be executed using /bin/sh
job 14 at Sat Aug  8 17:51:00 2026

Alongside it, queue the same work with its output pointed at a file so you can compare the two outcomes directly.

bash
echo 'df -h / >> /var/tmp/at-job.log 2>&1' | at now + 3 minutes

Both jobs were accepted for 17:51 and both ran. Ask the journal what actually happened, using the unit filter described in viewing logs with journalctl.

bash
sudo journalctl -u atd --since "6 min ago"

Sample output:

output
Aug 08 17:51:00 vm1.lab.example atd[56797]: Starting job 14 (a0000e01c64085) for user 'root' (0)
Aug 08 17:51:00 vm1.lab.example atd[56798]: Starting job 15 (a0000f01c64085) for user 'root' (0)
Aug 08 17:51:00 vm1.lab.example atd[56806]: Exec failed for mail command: No such file or directory

Job 14 ran and its output is gone forever. atd tried to invoke a mail command to deliver it, found nothing to invoke, and dropped the text on the floor. The redirected job left no such line and its output is on disk:

bash
cat /var/tmp/at-job.log

Sample output:

output
Filesystem             Size  Used Avail Use% Mounted on
/dev/mapper/rhel-root   25G  9.2G   16G  37% /

Same command, same daemon, one line of redirection between visible and vanished. The habit worth building is to append both streams inside every job:

bash
echo '/usr/local/bin/maintenance.sh >> /var/tmp/maintenance.log 2>&1' | at 23:00

The 2>&1 half matters more than the first. Errors are exactly the output you cannot afford to lose to a missing mail agent, and they are what tell you why a job did nothing.

There are two other ways to deal with the mail path. If you want the mail and have somewhere for it to go, configure a mail transfer agent such as Postfix as an SMTP relay, and add -m when you want a message even from a job that printed nothing. If you never want mail attempted at all, -M says so, and the difference shows up in the generated script.

bash
echo 'df -h /' | at -M now + 1 minute

The job header records the choice as a number rather than a flag name:

bash
at -c 25 | sed -n '3p'

Sample output:

output
# mail root -1

A default job carries # mail root 0 on that line, so -1 is at recording that delivery is switched off. That job produced plenty of output and the journal stayed clean of any mail error, which is the right way to run something noisy whose output you genuinely do not want.


Understand the at Execution Environment

Your job does not run in a copy of your shell session. It runs in a reconstruction of it, and the gap between the two is where "it works when I type it" comes from. The reconstruction is deliberately generous: the working directory, the umask, and almost every exported variable are captured when you submit the job. Prove it by setting something up first and asking the job to report back.

bash
cd /var/tmp && export GOLC_LAB=set-before-submit

Now schedule a job that prints what it can see, being careful to single-quote the payload so the variables are expanded at run time rather than now.

bash
echo 'printf "pwd=%s\nGOLC_LAB=%s\nTERM=%s\nshell=%s\n" "$(pwd)" "${GOLC_LAB:-unset}" "${TERM:-unset}" "$BASH" >> /var/tmp/at-env.log 2>&1' | at now + 3 minutes

When that job ran, it answered from inside the reconstruction:

bash
cat /var/tmp/at-env.log

Sample output:

output
pwd=/var/tmp
GOLC_LAB=set-before-submit
TERM=dumb
shell=/bin/bash

Three of those four lines confirm the capture worked: the working directory came across, a variable I exported by hand came across, and the payload really is running under bash.

The TERM line is the interesting one, because at documents TERM as one of the variables it refuses to export, and grepping the generated job script confirms there is no TERM= line in it. What the job sees is bash supplying its own fallback for an unset TERM, which you can reproduce in your own shell.

bash
env -u TERM bash -c 'echo "bash sees TERM=${TERM:-unset}"'

Sample output:

output
bash sees TERM=dumb

So dumb is not your terminal type leaking through, it is the absence of one. at also refuses to export these variables for the same reason:

  • DISPLAY, EUID, GROUPS, PPID, SHELLOPTS, UID, BASH_VERSINFO, and the _ shortcut
  • LD_LIBRARY_PATH and LD_PRELOAD, because at is a setuid program

PPID is worth calling out because the manual page on this build omits it from that list while the binary's exclusion table includes it. Set any of them inside the job when something genuinely needs them.

The absence of a terminal is not a detail you can work around, so it is worth seeing directly.

bash
echo 'printf "tty=%s\nterm=%s\nsh=%s\n" "$(tty 2>&1)" "${TERM:-unset}" "$0" >> /var/tmp/at-tty.log 2>&1' | at now + 1 minute

The result is unambiguous:

bash
cat /var/tmp/at-tty.log

Sample output:

output
tty=not a tty
term=dumb
sh=/bin/bash

not a tty rules out a whole category of commands. Anything that prompts for a password, waits for a confirmation, asks for an SSH key passphrase, or draws a full-screen interface will fail or hang rather than ask you. That is also why sudo inside an at job needs a rule that does not require a password.

Two more gaps are worth knowing because nothing warns you about either.

Aliases and ordinary non-exported shell functions are not recreated in the job. A job that calls a helper you defined in your interactive shell finds nothing there; when I scheduled a call to a function that had worked seconds earlier by hand, the job ran and the function's log line never appeared.

Bash can technically push a function into the environment with export -f, and a child bash will pick it up, but scheduled work is far more reproducible when shared logic lives in a script you call by absolute path.

The captured working directory is a hard dependency rather than a preference. Schedule a job from a directory, delete the directory, and the job aborts on that cd line before touching your commands. On a host with no mail agent that failure leaves no trace at all beyond a mail error in the journal.


Control Who Can Use at

Access is decided by two files, /etc/at.allow and /etc/at.deny, and the rules are strict about which one wins. Look at what your host ships with before changing anything.

bash
ls -l /etc/at.allow /etc/at.deny

Sample output:

output
ls: cannot access '/etc/at.allow': No such file or directory
-rw-r--r--. 1 root root 1 Sep 19  2025 /etc/at.deny

That is the RHEL default: no allow list, and a deny list that contains a single newline and no usernames. An empty deny list permits everyone, which is why a normal account can queue work with no configuration at all.

bash
su - atlab -c "echo 'echo hi' | at now + 30 minutes"

Sample output:

output
warning: commands will be executed using /bin/sh
job 18 at Sat Aug  8 18:24:00 2026

To take that away, name the user in the deny file. One username per line, no whitespace, no comments.

bash
echo atlab >> /etc/at.deny

The same submission is now refused outright:

bash
su - atlab -c "echo 'echo hi' | at now + 30 minutes"

Sample output:

output
You do not have permission to use at.

Exit status is 1 and nothing is queued. The block is wider than it first looks, because it also removes the ability to see the queue.

bash
su - atlab -c "atq"

Sample output:

output
You do not have permission to use atq.

A denied user cannot list or cancel their own pending work, which matters for the gotcha further down. Creating /etc/at.allow changes the logic completely rather than adding to it, so create one that names only root while atlab is still in the deny file.

bash
printf 'root\n' > /etc/at.allow

atlab is refused, which is expected. The revealing test is emptying the deny file and trying again, because the deny file is no longer consulted at all.

bash
printf '\n' > /etc/at.deny

With atlab in neither file, the answer is still no:

bash
su - atlab -c "echo 'echo hi' | at now + 30 minutes"

Sample output:

output
You do not have permission to use at.

Once at.allow exists it is the only list that counts, and absence from it is a refusal. Adding the user there restores access immediately.

bash
printf 'root\natlab\n' > /etc/at.allow

The job goes through again on the next attempt. That leaves one case that catches people who tidy up too enthusiastically: deleting both files does not open access, it closes it to everyone but root.

bash
su - atlab -c "echo 'echo hi' | at now + 30 minutes"

Sample output:

output
You do not have permission to use at.

Root scheduled a job perfectly well in the same state. The four rules together are worth memorising in this order:

  • at.allow exists: only the usernames in it may use at, and at.deny is ignored entirely
  • at.allow missing and at.deny present: everyone except the names in at.deny may use at
  • at.allow missing and at.deny present but empty: everyone may use at, which is the shipped default
  • Neither file exists: only root may use at

Now the gotcha. These files gate submission, not execution, and atd never consults them. I let a user queue a short job, denied them before it was due, and confirmed they could no longer even run atq. The job ran anyway.

bash
sudo journalctl -u atd --since "2 min ago"

Sample output:

output
Aug 08 17:57:00 vm1.lab.example atd[59210]: Starting job 4 (a0000401c6408b) for user 'root' (0)
Aug 08 17:57:00 vm1.lab.example atd[59211]: Starting job 19 (a0001301c6408b) for user 'atlab' (1011)

Job 19 executed as atlab while atlab was sitting in /etc/at.deny. Revoking access stops new jobs and nothing else, so when you take at away from an account, list their pending jobs as root and remove the ones you do not want with atrm.


Defer Work Until the Host Is Idle with batch

batch ships in the same package and shares the same queue machinery, but it answers a different question. Instead of "run this at 23:00", it means "run this when the machine is not busy". Submit it with no time specification at all.

bash
echo 'echo batch-demo >> /var/tmp/at-batch.log' | batch

Sample output:

output
warning: commands will be executed using /bin/sh
job 23 at Sat Aug  8 17:58:00 2026

The time in that confirmation is when atd will first consider the job, not a promise to run it then. Whether it actually runs depends on the load average at that moment against a threshold your distribution chose at build time.

On this RHEL 10.2 host man atd documents a compile-time choice of 0.8, while current Debian and Ubuntu packages document 1.5, so check the manual page on the host you are working on rather than trusting a number from an article.

bash
cat /proc/loadavg

Sample output:

output
2.04 2.58 2.90 2/576 60357

At 2.04 the host was far too busy, so 17:58 came and went with nothing happening. Checking the b queue a minute and a half later showed the job still sitting there.

bash
atq -q b

Sample output:

output
23	Sat Aug  8 17:58:00 2026 b root

Ten minutes after its nominal time the load finally dropped under the threshold and atd released it on its own.

bash
sudo journalctl -u atd --since "15 min ago" | grep "job 23"

Sample output:

output
Aug 08 18:08:09 vm1.lab.example atd[66694]: Starting job 23 (b0001701c6408c) for user 'root' (0)

That is batch behaving correctly rather than being late. On a multi-core host a default of 0.8 is conservative, since a three-CPU box can be perfectly idle at a load of 1, and this is exactly why the packaged value varies between distributions.

Whatever your package was compiled with, atd -l overrides it, so raise the limit through the daemon's own option file when the default bites, where -l sets the load ceiling and -b the minimum gap between two batch job starts:

bash
grep -v '^#' /etc/sysconfig/atd

The shipped file is entirely comments, and the example it suggests is OPTS="-l 4 -b 120". Restart atd after uncommenting a line there. Use batch for work that is heavy but not urgent, such as a one-off reindex or a large compression job, and plain at for anything that has to happen at a particular moment.


at vs cron vs systemd Timers

All three run commands you did not type yourself, and picking between them is mostly about how many times the work should happen and how much visibility you need afterwards.

Requirement Best fit
Run something exactly once, later today or next week at
Repeat on a simple calendar pattern in one user's crontab cron
Repeat with unit dependencies, journal output, and optional catch-up after downtime systemd timer
Run heavy work whenever the host goes quiet batch
Delay work only until the current shell finishes a plain background job

The practical differences that decide it are narrower than the table suggests:

  • Cleanup: an at job deletes itself after running, while a cron entry for a single run has to be removed by hand afterwards
  • Missed runs: an overdue at job always catches up, a calendar timer only does so with Persistent=true, and ordinary cron simply skips the run
  • Environment: at copies your shell environment into the job, whereas cron gives you a deliberately minimal PATH that trips up scripts working fine in your terminal
  • Visibility: a timer's output lands in the journal by default, while at and cron both push output into mail unless you redirect it

For recurring schedules and crontab field syntax, work through the crontab command guide, and for unit-based scheduling with OnCalendar expressions see systemd timers. Neither replaces at, because neither has a comfortable way to say "once, at 23:00 tonight, and never again".


Practical One-Time Scheduling Examples

These are the shapes that come up most often on real hosts. Each one is a single at submission you can adapt.

Warn logged-in users before a reboot

wall broadcasts to every terminal on the host, which makes it a good way to give people notice without watching the clock yourself.

bash
echo 'wall "Maintenance reboot at 23:00. Save your work."' | at now + 1 minute

The message appeared on schedule in every open session. Chain two or three of these at different offsets for a countdown, and note that the reboot itself is better handled by shutdown with a time argument than by at, for the reasons covered in delaying a reboot properly.

Clean up a scratch directory later

Temporary space you created for one task is the classic thing everybody forgets. Schedule its removal at the same moment you create it.

bash
echo 'rm -rf /var/tmp/golc-scratch' | at now + 2 minutes

When the job ran, ls -ld /var/tmp/golc-scratch reported that the path no longer existed. Use absolute paths in destructive jobs without exception, because a relative path resolved against a working directory you no longer remember is how you delete the wrong thing.

Run a report tonight

A read-only report is the safest kind of scheduled job, and the one where redirection pays off most obviously.

bash
at -f /root/at-lab/disk-report.sh 23:00

at confirms with job 30 at Sat Aug 8 23:00:00 2026. The script writes its own log, so nothing depends on mail and the output is waiting for you in the morning.

Apply a change once, inside a maintenance window

The everyday case for at is a change you have already prepared but must not apply yet.

bash
echo 'systemctl restart httpd >> /var/tmp/restart.log 2>&1' | at 02:00 tomorrow

Queue it, verify it with at -c while you still remember the context, and go home. If plans change, atq and atrm cancel it in two commands.


Troubleshoot at Jobs

Most at problems are one of these, and the diagnosis nearly always starts with sudo journalctl -u atd because that is the only place atd reports what it did.

Symptom Likely cause Fix
bash: at: command not found Package not installed on a minimal image sudo dnf install at, or sudo apt install at on Debian and Ubuntu
Can't open /run/atd.pid to signal atd. No atd running? Job accepted but the daemon is stopped sudo systemctl enable --now atd; the job still runs if its time has not passed
atq lists jobs whose time passed and nothing happened atd inactive, so nothing dispatches the queue Start atd; missed jobs run immediately when it comes back
Job ran, no output anywhere, Exec failed for mail command in the journal Output was handed to mail and no mail agent exists Redirect inside the job with >> /path/log 2>&1, or install a mail transfer agent
syntax error. Last token seen: d and Garbled time Command passed as an argument instead of on standard input Pipe the command into at, use -f file, or type it at the at> prompt
at: refusing to create job destined in the past Resolved time is earlier than now Use a future time; a bare HH:MM already rolls to tomorrow on its own
Works when typed, does nothing through at No terminal, so prompts and passphrases cannot be answered Remove interactive steps; use a passwordless sudo rule or a key with no passphrase
Permission denied on your own script Script called by path without the execute bit chmod +x the script, or schedule it with at -f instead
Job fails instantly with nothing in its log Working directory captured at submission no longer exists Schedule from a stable directory and use absolute paths inside the job
A helper command or function is not found Aliases and non-exported functions do not reach the job, and setuid drops LD_* variables Call scripts by absolute path and set the variables inside the job
You do not have permission to use at. User is excluded by at.deny, missing from at.allow, or both files are absent Add the user to at.allow when it exists, otherwise remove them from at.deny
Cancelled a user's access but their jobs still run Access files gate submission only, never execution List their jobs as root with atq and delete them with atrm
batch job never starts Load average is above the threshold atd was configured with Wait for the host to quieten, or raise -l in /etc/sysconfig/atd and restart atd

References


Summary

at is the right tool whenever the honest answer to "how often" is once. You give it a time and some commands, it freezes your environment and working directory into a script under /var/spool/at, and atd runs that script one time and removes it.

Three commands cover the whole life cycle: at to schedule, atq to see what is pending, and atrm to cancel. at -c is the fourth one worth remembering, because it is the only way to see exactly what a job will do before it does it.

The pitfall that wastes the most time has nothing to do with scheduling. A job has no terminal, so everything it prints goes to mail, and on a server with no mail agent that output is discarded with only an Exec failed for mail command line in the journal to show for it.

That is not a failure of at, and it swallows error messages as readily as normal output, which is why a job with a broken path or a missing execute bit appears to do nothing at all.

Redirect both streams into a log file in every job you write and the problem disappears. While you are at it, remember that atq prints the queue in spool order rather than by time, so sort it yourself when the running order matters. A job queued while atd was stopped is not lost either; it will run the moment the daemon returns.

Two behaviours on this build are worth carrying forward because they contradict what is widely repeated:

  • A job scheduled for a time in the past is refused with at: refusing to create job destined in the past rather than running immediately as the manual page describes. That came from a distribution patch in at-3.2.5-13, so check your own build rather than assuming.
  • Revoking a user's access through /etc/at.deny stops them submitting and even listing jobs, but does nothing about work they already queued. Those jobs keep running on schedule until root removes them with atrm.

Start with a two-minute job that appends date to a log file, since that single exercise proves the daemon is running, the time parser understood you, and your redirection works. From there, keep at for one-shot work and reach for cron or a systemd timer the moment the same command needs to run twice.


Frequently Asked Questions

1. Why did my at job run but produce no output anywhere?

Because at hands the standard output and standard error of your commands to the local mail system instead of a terminal. If no mail transfer agent is installed, which is the default on a minimal server, atd tries to run a mail command that does not exist and the output is discarded. The journal records that as an Exec failed for mail command line. The fix is to redirect inside the job itself, appending both streams to a log file with >> /path/to/log 2>&1 so that errors are captured as well.

2. Do at jobs survive a reboot or a stopped atd service?

Yes. Each job is a script written into the spool directory under /var/spool/at, so it stays on disk when the daemon stops or the machine reboots. If the scheduled moment passes while atd is not running, the job is not lost and not skipped either: atd runs it immediately the next time it starts. That is different from ordinary cron, which simply misses the run and waits for the next matching time.

3. What is the difference between at and sleep followed by a command?

A sleep in your shell is a process that belongs to your session, so it dies when the terminal closes unless you wrap it in nohup or a multiplexer, and it disappears completely on reboot. An at job is written to disk and executed later by the atd daemon in a process with no controlling terminal, so logging out changes nothing and the job still runs after a reboot. at also gives you a job number you can list and cancel.

4. Why does at say Garbled time when I pass my command as an argument?

Because at only accepts a time specification on the command line and reads the commands themselves from standard input or from a file. Anything extra after the time is parsed as more time syntax, so it fails with a syntax error and Garbled time. Pipe the command into at instead, or use the -f option to point at a script file, or type the commands at the interactive at prompt and finish with Ctrl+D.

5. Does adding a user to /etc/at.deny cancel the jobs they already scheduled?

No. The allow and deny files are only consulted when someone submits or lists a job, not when atd executes one. A user you deny can no longer run at or atq, but every job already sitting in the spool still fires on schedule. To actually stop that work you have to remove the pending jobs yourself with atrm as root, which is the only account that can see and delete a job belonging to another user.

6. Which shell runs the commands in an at job?

at prints a warning that commands will be executed using /bin/sh, and the wrapper script it generates does start with that shebang. The wrapper then re-executes your commands under the value of SHELL captured from the session where you submitted the job, falling back to /bin/sh only when SHELL is unset. On a normal login that means bash, so bash syntax usually works, but relying on it is fragile because a submission from a different shell changes the interpreter.

7. Does at -f need the script to be executable?

No. With -f at reads the contents of the file and embeds those lines in the job, so the file is treated as a list of commands rather than a program and the execute bit is irrelevant. The shebang line is also inert in that case because it becomes an ordinary comment. If you want the shebang honoured, put the path of the script in the job instead of using -f, and then the execute bit does matter.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)