| 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.
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 && commandlives inside your shell session and dies with it, unless you wrap it innohup; 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.
rpm -q atSample output:
at-3.2.5-14.el10_1.x86_64A 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.
at now + 1 minuteSample output:
bash: at: command not foundInstall 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.
sudo dnf install atThe interesting part of that transaction is not the package but the scriptlet at the end:
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.
systemctl status atdSample 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.
sudo systemctl enable --now atdSample 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.
systemctl is-active atdSample output:
activeactive 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.
echo 'date >> /tmp/at-noatd.log' | at now + 1 minuteSample 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.
at now + 2 minutesat 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:
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 2026Three 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.
echo 'date >> /tmp/at-test.log' | at now + 2 minutesSample output:
warning: commands will be executed using /bin/sh
job 4 at Sat Aug 8 17:57:00 2026The 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.
cat /tmp/at-test.logSample output:
Sat Aug 8 05:47:00 PM IST 2026The 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.
echo 'echo demo' | at -t 202612250900Sample output:
job 11 at Fri Dec 25 09:00:00 2026Same 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:
at 18:30 "df -h"Sample output:
syntax error. Last token seen: d
Garbled timeat 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.
echo 'echo x' | at 09:00 todaySample output:
at: refusing to create job destined in the pastThe 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.
rpm -q --changelog at | grep -B2 "past date"Sample output:
* Mon Jun 30 2025 Ondřej Pohořelský <opohorel@redhat.com> - 3.2.5-13
- Add patch to fix past date handlingRun 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.
atqSample 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 atlabEach 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.
atq -o '%Y-%m-%d %H:%M'Sample 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 rootThe 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.
atq -o '%Y-%m-%d %H:%M' | sort -k2,3Sample 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 rootTomorrow 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.
atq -q bSample output:
23 Sat Aug 8 17:58:00 2026 b rootOnly 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.
at -c 24The 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:
#!/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
marcinDELIMITER02bea859Four details in that script explain behaviour you will meet later:
# atrun uid=0 gid=0records who the job runs as, fixed at submission timeumask 22and the long block of exports are your environment, frozen when you pressed Ctrl+Dcd /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/shshebang 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.
at -c 999Sample output:
Cannot find jobid 999Exit 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.
atrm 5Nothing is printed when the job is gone. The only way to be sure is to look at the queue again.
atqSample 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 rootJob 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.
atrm 6 7 8That 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.
atrm 999Sample output:
Cannot find jobid 999Root 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.
cat /root/at-lab/disk-report.shSample 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>&1Note the permissions before scheduling it, because this is the difference between the two methods:
ls -l /root/at-lab/disk-report.shSample output:
-rw-r--r--. 1 root root 224 Aug 8 17:48 /root/at-lab/disk-report.shNo 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.
at -f /root/at-lab/disk-report.sh now + 2 minutesSample output:
warning: commands will be executed using /bin/sh
job 13 at Sat Aug 8 17:50:00 2026Two minutes later the script had done its work, non-executable and all:
cat /var/tmp/disk-report.logSample 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:
at -c 30Sample 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 ...
marcinDELIMITER1f5a21f4The #!/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.
echo '/root/at-lab/disk-report.sh >> /var/tmp/at-path.log 2>&1' | at now + 1 minuteOnce that job ran, the log held the error rather than a report:
cat /var/tmp/at-path.logSample output:
/bin/bash: line 1: /root/at-lab/disk-report.sh: Permission deniedThe 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.
chmod +x /root/at-lab/disk-report.shRerunning 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:
echo 'df -h /' | at now + 3 minutesSample output:
warning: commands will be executed using /bin/sh
job 14 at Sat Aug 8 17:51:00 2026Alongside it, queue the same work with its output pointed at a file so you can compare the two outcomes directly.
echo 'df -h / >> /var/tmp/at-job.log 2>&1' | at now + 3 minutesBoth 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.
sudo journalctl -u atd --since "6 min ago"Sample 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 directoryJob 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:
cat /var/tmp/at-job.logSample 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:
echo '/usr/local/bin/maintenance.sh >> /var/tmp/maintenance.log 2>&1' | at 23:00The 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.
echo 'df -h /' | at -M now + 1 minuteThe job header records the choice as a number rather than a flag name:
at -c 25 | sed -n '3p'Sample output:
# mail root -1A 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.
cd /var/tmp && export GOLC_LAB=set-before-submitNow 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.
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 minutesWhen that job ran, it answered from inside the reconstruction:
cat /var/tmp/at-env.logSample output:
pwd=/var/tmp
GOLC_LAB=set-before-submit
TERM=dumb
shell=/bin/bashThree 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.
env -u TERM bash -c 'echo "bash sees TERM=${TERM:-unset}"'Sample output:
bash sees TERM=dumbSo 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_shortcutLD_LIBRARY_PATHandLD_PRELOAD, becauseatis 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.
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 minuteThe result is unambiguous:
cat /var/tmp/at-tty.logSample output:
tty=not a tty
term=dumb
sh=/bin/bashnot 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.
ls -l /etc/at.allow /etc/at.denySample output:
ls: cannot access '/etc/at.allow': No such file or directory
-rw-r--r--. 1 root root 1 Sep 19 2025 /etc/at.denyThat 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.
su - atlab -c "echo 'echo hi' | at now + 30 minutes"Sample output:
warning: commands will be executed using /bin/sh
job 18 at Sat Aug 8 18:24:00 2026To take that away, name the user in the deny file. One username per line, no whitespace, no comments.
echo atlab >> /etc/at.denyThe same submission is now refused outright:
su - atlab -c "echo 'echo hi' | at now + 30 minutes"Sample 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.
su - atlab -c "atq"Sample 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.
printf 'root\n' > /etc/at.allowatlab 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.
printf '\n' > /etc/at.denyWith atlab in neither file, the answer is still no:
su - atlab -c "echo 'echo hi' | at now + 30 minutes"Sample 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.
printf 'root\natlab\n' > /etc/at.allowThe 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.
su - atlab -c "echo 'echo hi' | at now + 30 minutes"Sample 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.allowexists: only the usernames in it may useat, andat.denyis ignored entirelyat.allowmissing andat.denypresent: everyone except the names inat.denymay useatat.allowmissing andat.denypresent but empty: everyone may useat, 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.
sudo journalctl -u atd --since "2 min ago"Sample 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.
echo 'echo batch-demo >> /var/tmp/at-batch.log' | batchSample output:
warning: commands will be executed using /bin/sh
job 23 at Sat Aug 8 17:58:00 2026The 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.
cat /proc/loadavgSample output:
2.04 2.58 2.90 2/576 60357At 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.
atq -q bSample output:
23 Sat Aug 8 17:58:00 2026 b rootTen minutes after its nominal time the load finally dropped under the threshold and atd released it on its own.
sudo journalctl -u atd --since "15 min ago" | grep "job 23"Sample 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:
grep -v '^#' /etc/sysconfig/atdThe 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
atjob deletes itself after running, while a cron entry for a single run has to be removed by hand afterwards - Missed runs: an overdue
atjob always catches up, a calendar timer only does so withPersistent=true, and ordinary cron simply skips the run - Environment:
atcopies your shell environment into the job, whereas cron gives you a deliberately minimalPATHthat trips up scripts working fine in your terminal - Visibility: a timer's output lands in the journal by default, while
atand 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.
echo 'wall "Maintenance reboot at 23:00. Save your work."' | at now + 1 minuteThe 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.
echo 'rm -rf /var/tmp/golc-scratch' | at now + 2 minutesWhen 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.
at -f /root/at-lab/disk-report.sh 23:00at 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.
echo 'systemctl restart httpd >> /var/tmp/restart.log 2>&1' | at 02:00 tomorrowQueue 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
- Manual page for at(1), batch, atq and atrm, covering time syntax, environment handling, and the -o, -M and -q options
- Manual page for at.allow(5) and at.deny, including the precedence between the two files
- Manual page for atd(8), documenting the batch load limit and the minimum interval between batch jobs
- Red Hat Enterprise Linux System Administrator's Guide: Automating System Tasks, with sections on at and batch
- CentOS Stream 10 package spec for at, whose changelog records the past date handling patch added in 3.2.5-13
- Source repository for the at package
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 pastrather than running immediately as the manual page describes. That came from a distribution patch inat-3.2.5-13, so check your own build rather than assuming. - Revoking a user's access through
/etc/at.denystops them submitting and even listing jobs, but does nothing about work they already queued. Those jobs keep running on schedule until root removes them withatrm.
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.

