The first cron job I wrote felt like a small promotion.
I had a script.
I had a schedule.
The computer would now perform the task without me.
0 2 * * * /home/miri/bin/generate-report
Every day at 02:00, the report would appear.
Automation achieved.
Except that scheduling a command and automating an operation are not quite the same thing.
Cron knew when to start the script. It knew nothing about:
whether yesterday's run was still active
whether today's report already existed
whether the previous run stopped halfway
whether running the command twice was safe
whether the environment matched my terminal
whether anybody would notice a failure
Cron had done exactly what I asked.
The problem was that I had not asked enough questions.
These examples assume Bash on Linux, traditional cron behaviour, GNU command-line tools and util-linux
flockunless noted otherwise.
Cron starts attempts, not outcomes
A crontab entry broadly says:
run this command
at these matching times
For example:
15 3 * * * /home/miri/bin/cleanup-reports
The five scheduling fields represent:
minute
hour
day of month
month
day of week
followed by the command.
Conceptually:
SCHEDULE MATCHES
│
▼
START COMMAND
│
▼
CRON'S PART IS MOSTLY DONE
Cron does not understand the business meaning of cleanup reports. It does not know whether two copies may run safely, whether the result already exists or how to recover from partial work.
Those are responsibilities of the operation being scheduled.
Imagine this first attempt at a report script:
#!/usr/bin/env bash
mkdir /var/lib/reports
generate-summary >> /var/lib/reports/daily.txt
The first run may work.
The second fails to create the existing directory and appends another copy of the report:
orders: 42
orders: 42
orders: 42
Cron did not create those duplicates.
My operation did.
The schedule merely gave the mistake a reliable appointment.
Repetition changes the question
When I run a command manually, I often ask:
Does this work?
Once it is scheduled, I need to ask:
What happens the second time?
What happens if it runs twice at once?
What happens if it fails after changing half the state?
What happens if I rerun it manually?
Can it distinguish completed work from partial work?
Can it safely continue after interruption?
Cron creates repeated scheduled invocations. More attempts may come from manual reruns, monitoring systems, deployment scripts or external retry wrappers.
Cron itself does not normally retry a failed command. The wider operating environment creates several ways for the same logical work to be attempted again.
This is where I encountered the word idempotency.
Idempotency is about repeated effect
An idempotent operation can be applied repeatedly without repeatedly changing its intended result once that result has been reached.
A simple filesystem example is:
mkdir -p /var/lib/reports
The desired state is:
/var/lib/reports exists as a directory
Running the command again continues to satisfy the same condition.
Compare that with:
printf '%s\n' 'report complete' >> status.log
Every execution adds another line.
mkdir -p directory
│
└── converges on a state
append to file
│
└── creates another effect each time
Idempotency is always defined against an effect.
Suppose the desired outcome is:
There should be exactly one completed report for 23 May 2021.
A rerun may still write another log entry or increment a metric. It can be idempotent with respect to the published report without being idempotent with respect to every observable side effect.
The important promise is that a retry should not accidentally create:
two reports
two invoices
two payments
two emails
Idempotency is therefore tied to the operation’s semantics. It cannot be determined from shell syntax alone.
Identify one logical unit of work
A daily report may be attempted several times:
logical report:
2021-05-23
attempts:
02:00 scheduled invocation
02:10 manual rerun
02:15 recovery attempt
Those should usually be several attempts to achieve one logical outcome, not three separate reports.
The operation needs a stable identity for that work:
report date
order ID
source object ID
backup date
event ID
deployment version
Without a stable identity, it cannot reliably distinguish new work from another attempt at the same work.
The current clock is not automatically that identity.
A report started at 02:00 on 24 May may cover 23 May. A delayed retry after midnight could otherwise select a different date from the original attempt.
A script can accept the reporting period explicitly:
report_date=${1:?usage: generate-report YYYY-MM-DD}
Or it can define a visible default policy:
report_date=${1:-$(date -u --date='yesterday' +%F)}
The second form depends on GNU date, but it makes the contract clear:
scheduled invocation
│
└── process previous UTC day
manual recovery
│
└── pass the exact missed date
Timezone belongs to the operation’s input contract. The timezone used by cron and the timezone defining a business day are not automatically the same.
Recognise the desired state
Automation often becomes safer when it describes the state it requires instead of assuming every attempt begins from nothing.
This:
mkdir -p /var/lib/reports
expresses:
Ensure this directory hierarchy exists.
But existence alone may not be enough. The path could have the wrong type, ownership or permissions.
A script might check:
report_dir=/var/lib/reports
mkdir -p -- "$report_dir" ||
exit 1
if [[ ! -d $report_dir || ! -w $report_dir ]]; then
printf 'report directory is not writable: %s\n' \
"$report_dir" >&2
exit 1
fi
These checks improve diagnostics, but they are snapshots. A successful -w test does not reserve the directory or guarantee that a later write succeeds.
The real operation still needs error handling.
The final result also needs a completion test:
final_report="/var/lib/reports/orders-$report_date.tsv"
if [[ -s $final_report ]]; then
printf 'report already exists: %s\n' "$final_report" >&2
exit 0
fi
A successful previous attempt becomes a successful no-op.
Whether -s is enough depends on what “complete” means. A stronger test might verify:
expected header
checksum
record count
completion marker
remote acknowledgement
Idempotency requires a reliable way to recognise the desired state.
Build first, publish second
Appending a complete report is dangerous:
generate-summary >> daily-report.txt
Each run adds another result.
Replacing the output is closer to the intended state:
generate-summary > daily-report.txt
But the shell truncates the destination before generate-summary runs. If generation fails halfway, the previous good report may already be gone.
A safer pattern is:
generate temporary result
│
▼
validate result
│
▼
publish completed result
For example:
report_dir=/var/lib/reports
final_report=$report_dir/daily-report.txt
temporary_report=$(mktemp "$report_dir/.daily-report.XXXXXX") ||
exit 1
cleanup()
{
rm -f -- "$temporary_report"
}
trap cleanup EXIT
if ! generate-summary > "$temporary_report"; then
printf 'report generation failed\n' >&2
exit 1
fi
if [[ ! -s $temporary_report ]]; then
printf 'generated report is empty\n' >&2
exit 1
fi
mv -- "$temporary_report" "$final_report" ||
exit 1
temporary_report=''
mktemp creates the file instead of asking the script to invent a predictable name such as /tmp/report.$$.
Placing the temporary file in the final directory keeps it on the same filesystem. Publication can then use a rename, so readers normally see the old object or the completed new one rather than a half-written final file.
That guarantee has limits.
A rename does not by itself guarantee durability after a machine or storage crash. The temporary file’s default permissions may also need to be changed before publication. An EXIT trap cannot clean up after SIGKILL or a system crash, so abandoned temporary files may need a retention policy.
The publication contract should also be explicit:
replace the existing result
is different from:
publish only when no result exists
The example above chooses replacement.
Remote effects need remote idempotency
Local files can often be recognised by path and content.
Remote effects are harder:
send email
create order
charge card
publish message
upload object
Suppose a job uploads a report and then records completion locally:
remote upload succeeds
│
▼
process crashes before local marker
│
▼
rerun cannot tell whether upload happened
The worst state is often:
I do not know whether the effect happened.
That is exactly when an operator wants to rerun the job.
A local marker written after a remote request cannot always resolve that uncertainty. The safest design is often for the remote system to understand a stable idempotency key:
report identity:
orders-2021-05-23
remote operation:
create or replace that object
Or:
payment identity:
invoice-4821
remote operation:
process this key once
Some operations are naturally repeatable.
Others need an explicit idempotency key or transactional design.
A scheduler cannot invent those semantics.
Concurrency needs its own policy
Idempotency describes repeated effect.
Concurrency asks what happens when attempts overlap.
Imagine two copies starting together:
PROCESS A PROCESS B
check report absent
check report absent
start generation
start generation
publish report
publish report
Both observed the same initial state.
This is a check-then-act race.
Cron does not automatically prevent overlap. A job scheduled every five minutes can overlap if one invocation takes eight minutes:
12:00 run ────────────────► 12:08
12:05 run ────────────────► 12:13
On Linux, flock provides a convenient advisory lock:
state_dir=/var/lib/report-generator
mkdir -p -- "$state_dir" ||
exit 1
exec 9> "$state_dir/report.lock" ||
exit 1
if ! flock -n 9; then
printf 'another report run is already active\n' >&2
exit 0
fi
File descriptor 9 remains open while the script runs, keeping the lock associated with that attempt.
flock is advisory. It coordinates programs that follow the same locking convention; it does not stop unrelated processes from modifying the files.
Lock contention also needs a policy:
exit 0
another attempt is already pursuing the same outcome
exit non-zero
this invocation failed to perform required work
wait for lock
every invocation should eventually run
Skipping may be acceptable for one daily report. But a hung process could make later invocations skip successfully, so independent monitoring should verify that the expected report appears.
A lock is not idempotency.
LOCK
controls overlap
IDEMPOTENCY
controls repeated effect
Reliable automation may need both.
Environment is input too
A script that works in my terminal may fail under cron.
My interactive environment can provide:
a large PATH
shell configuration
aliases
environment variables
current working directory
authentication agents
terminal input
A cron job runs in a more limited, non-interactive context.
A crontab can make important assumptions explicit:
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
17 2 * * * /usr/local/sbin/generate-daily-report
If the script begins with:
#!/usr/bin/env bash
its shebang selects Bash when the script is invoked directly. The shell configured by cron still interprets the crontab command line.
Crontab command fields have their own parsing rules, and some cron formats give % special meaning. Keeping the entry simple avoids embedding a second program inside the scheduler configuration.
A scheduled script should also use explicit paths and a deliberate working directory:
readonly report_dir=/var/lib/reports
cd -- "$report_dir" || {
printf 'cannot enter report directory: %s\n' \
"$report_dir" >&2
exit 1
}
The environment is part of the program interface.
If the operation requires something, it should say so.
Output and status are operational interfaces
A scheduled command has no human sitting in front of its terminal.
Traditional cron installations may mail unredirected stdout and stderr to the job owner. A chatty successful job can therefore create a great deal of mail.
Silencing everything is not necessarily better:
0 2 * * * /path/to/job >/dev/null 2>&1
Now genuine diagnostics disappear too.
A deliberate policy might be:
stdout
meaningful result, usually quiet on success
stderr
diagnostics requiring attention
exit status
whether this attempt achieved its contract
external check
whether the expected business result appeared
Cron can successfully launch a command while the operation later fails.
crontab parsed
│
▼
scheduler launched command
│
▼
script validated environment
│
▼
operation completed
│
▼
result verified
“Cron ran” is not the same statement as:
The scheduled business outcome was achieved.
A deliberate daily report script
The following is an illustrative local-file workflow, not a universal production template.
It checks important failures explicitly rather than relying on set -e, whose behaviour depends on command context.
#!/usr/bin/env bash
set -u
set -o pipefail
umask 027
readonly program_name=${0##*/}
readonly state_dir=/var/lib/report-generator
readonly report_dir=/var/lib/reports
readonly exporter=/usr/local/bin/export-orders
temporary_report=''
log()
{
printf '%s: %s\n' "$program_name" "$*" >&2
}
die()
{
log "$*"
exit 1
}
cleanup()
{
if [[ -n $temporary_report ]]; then
rm -f -- "$temporary_report"
fi
}
trap cleanup EXIT
main()
{
local report_date
local final_report
report_date=${1:-$(date -u --date='yesterday' +%F)} ||
die 'could not determine report date'
[[ $report_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] ||
die "invalid report date: $report_date"
mkdir -p -- "$state_dir" "$report_dir" ||
die 'could not create required directories'
[[ -d $state_dir && -w $state_dir ]] ||
die "state directory is not writable: $state_dir"
[[ -d $report_dir && -w $report_dir ]] ||
die "report directory is not writable: $report_dir"
[[ -x $exporter ]] ||
die "exporter is not executable: $exporter"
exec 9> "$state_dir/daily-report.lock" ||
die 'could not open lock file'
if ! flock -n 9; then
log 'another report run is already active'
return 0
fi
final_report=$report_dir/orders-$report_date.tsv
if [[ -s $final_report ]]; then
log "report already complete: $final_report"
return 0
fi
temporary_report=$(
mktemp "$report_dir/.orders-$report_date.XXXXXX"
) || die 'could not create temporary report'
log "generating report for $report_date"
if ! "$exporter" \
--date "$report_date" \
> "$temporary_report"
then
die "report generation failed for $report_date"
fi
if [[ ! -s $temporary_report ]]; then
die "generated report is empty for $report_date"
fi
chmod 0640 -- "$temporary_report" ||
die 'could not set report permissions'
mv -- "$temporary_report" "$final_report" ||
die "could not publish report: $final_report"
temporary_report=''
log "report published: $final_report"
}
main "$@"
The crontab entry remains simple:
PATH=/usr/local/bin:/usr/bin:/bin
17 2 * * * /usr/local/sbin/generate-daily-report
A recovery attempt can pass the work identity explicitly:
/usr/local/sbin/generate-daily-report 2021-05-23
The script now has visible policies for:
logical work identity
dependencies and directories
overlapping execution
already-completed work
temporary output
validation and publication
file permissions
diagnostics and cleanup
Cron still decides when to start another attempt.
The script decides what that attempt means.
The limits are part of the design
Even this example assumes:
one non-empty final file means the report is complete
the exporter is read-only
the previous UTC day is the normal reporting period
skipping an overlap is acceptable
cooperating writers all use the same lock
replacement is the publication policy
A production implementation might require checksums, record counts, ownership checks, durable storage guarantees, monitoring, retention and cleanup of abandoned temporary files.
Not every scheduled run should collapse into one result either.
A metric collected every minute represents a new time interval. Hourly snapshots may intentionally produce:
snapshot-12
snapshot-13
snapshot-14
The idempotency boundary might be:
Produce no more than one snapshot for each hour.
The design question remains:
What is the identity of one intended unit of work?
Once that identity is known, retries, duplicates and overlap can have explicit meanings.
The scheduler is not the workflow
Cron supplies time-based triggering.
It does not automatically provide:
dependency management
distributed locking
retry policy
backoff
durable workflow state
transaction coordination
alerting
execution history
idempotency keys
For small local jobs, Bash, files and locks may be sufficient.
As the workflow grows, a service manager, timer system, queue or workflow engine may become more appropriate.
Changing schedulers does not remove the central requirement.
Anything that can be retried, redelivered or restarted still needs a repeated-execution model.
Idempotency is not a cron feature.
It is an operation-design feature.
The mental model I’m keeping
My original model was:
SCRIPT
│
▼
CRON
│
▼
AUTOMATION
The new model is:
CRON
│
│ schedule matches
▼
START ATTEMPT
│
▼
SCRIPT LOGIC
│
┌───────────────┼────────────────┐
│ │ │
▼ ▼ ▼
identify work acquire lock validate state
│ │ │
└───────────────┼────────────────┘
▼
desired result exists?
│ │
yes no
│ │
▼ ▼
succeed prepare result
│
▼
verify result
│
▼
publish result
Cron answers:
When should another attempt begin?
The operation must answer:
What is this attempt trying to achieve?
How do I recognise that it has already succeeded?
Can another copy run at the same time?
What state might a failed attempt leave behind?
Can I safely try again?
That is the difference between a scheduled command and reliable automation.
Cron did not make me care about idempotency because cron was unreliable.
It made me care because cron was extremely reliable at repeating whatever mistake I had scheduled.
A tiny line in a crontab can therefore be a useful architecture review.
It asks, every few minutes:
Are you quite sure this is safe to do again?
References and further reading
Cron and crontab
POSIX.1-2017: crontab
Defines crontab as the standard interface for scheduling periodic background work, including the default execution environment and handling of unredirected output.
Linux crontab(5) manual page
Documents the traditional crontab file format, scheduling fields, environment assignments, command execution and output-mail behaviour.
Linux cron(8) manual page
Describes the cron daemon’s role in examining installed schedules and starting commands whose time expressions match.
Preventing overlapping runs
Linux flock(1) manual page
Documents the util-linux flock command used to manage advisory locks from shell scripts or wrap a lock around another command.
Linux flock(2) manual page
Documents the underlying Linux advisory file-locking system call and the lifecycle of locks associated with open files.
Repeated filesystem operations
GNU Coreutils: mkdir
Documents directory creation and the -p option, which does not report an error when an existing destination is already a directory.
Temporary result creation
GNU Coreutils: mktemp
Explains safe creation of temporary files and directories and the problems with predictable temporary filenames assembled from process IDs.
Publishing generated files
GNU Coreutils: mv
Documents moving and renaming files, including the distinction between an ordinary rename and cross-filesystem behaviour.
Linux rename(2) manual page
Documents filesystem rename behaviour and the atomic replacement of an existing destination name.
Bash execution and failure reporting
GNU Bash Reference Manual: Exit Status
Documents Bash’s zero-success and non-zero-failure convention and the $? parameter used to inspect a command’s result.
GNU Bash Reference Manual: Redirections Explains how Bash directs standard output and standard error to files or other destinations before executing a command.
GNU Bash Reference Manual: Bourne Shell Builtins
Includes the reference for trap, which the example uses to clean up temporary state when the script exits.