How to Cache Command Output in Scripts and Handle Edge Cases
Cache command output in scripts: a file with a TTL, keys, atomic writes, locking, stale-while-error behaviour and the edge cases that bite.

A script calls kubectl get pods, aws ec2 describe-instances or a slow API on every run, and nothing changes between runs. Caching the answer for a minute or an hour saves seconds each time and avoids rate limits. A cache is also a way to serve wrong data, so the design details (key, freshness, atomic writes, failure) matter. It continues avoiding repeated API calls. The examples assume Bash 5.2 and GNU coreutils 9.x.
A small cache function
cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/mytool
mkdir -p "$cache_dir"
cached() { # cached TTL_SECONDS COMMAND [ARGS...]
local ttl=$1; shift
local key file tmp
key=$(printf '%s\0' "$@" | sha256sum | cut -d' ' -f1)
file="$cache_dir/$key"
if [[ -f $file ]] && (( $(date +%s) - $(stat -c %Y "$file") < ttl )); then
cat "$file"
return 0
fi
tmp=$(mktemp "$file.XXXXXX")
if "$@" > "$tmp"; then
mv -f "$tmp" "$file"
cat "$file"
else
rm -f "$tmp"
if [[ -f $file ]]; then
echo "warning: refresh failed, using stale data" >&2
cat "$file"
else
return 1
fi
fi
}
cached 300 kubectl get pods -n shop -o nameThe key is a hash of the whole command line, so different arguments get different entries. Freshness compares the file's modification time with the TTL. The new output goes to a temporary file in the same directory and is renamed, which is atomic: a reader never sees a half-written cache, as in idempotent scripts. If the command fails, a stale copy is better than nothing for many uses, with a warning. stat -c %Y is GNU; on macOS use stat -f %m, a portability point from date on macOS and Linux.
Simpler: find with -mmin
file=/tmp/pods.cache
if [[ -z $(find "$file" -mmin -5 2>/dev/null) ]]; then
kubectl get pods -o name > "$file.tmp" && mv "$file.tmp" "$file"
fi
cat "$file"When the file is missing or older than five minutes, find prints nothing and the script refreshes. It is portable between GNU and BSD and needs no date arithmetic. A fixed file name under /tmp invites collisions between users and symlink attacks; use a per-user cache directory with mode 700.
Edge cases that bite
Key too small cached output reused for a different argument or user
Empty result caching an empty answer after a transient error hides data for the whole TTL
Errors cached a failure written to the cache is served as success
Concurrency two runs refresh at once and race
Secrets tokens or personal data written to a world-readable file
Clock changes a clock set backwards makes entries look fresh for too long
Key on everything that changes the output: the arguments, the user, the environment variables the command reads (the active Kubernetes context, the cloud profile). Do not write the cache when the command fails or returns nothing unexpectedly. Use flock around the refresh to avoid a stampede when many processes find the cache expired at once. Set the cache directory to mode 700 and files to 600 if they hold anything sensitive. See safe temp files.
Cache invalidation
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/mytool"
mytool --refresh
find "$cache_dir" -type f -mtime +7 -deleteGive users a way to force a refresh (a flag or an environment variable) and clean old entries so the directory does not grow without bound. A TTL is the simplest invalidation; if the data has a version, an ETag or a last-modified marker, use it instead of guessing, as HTTP does with If-None-Match. For API results, see how rate limits and retries interact with a cache.
Deciding what to cache
Caching pays off when three conditions hold together: the call is slow or limited, the answer changes less often than the script runs, and a slightly old answer does no harm. Listing cloud instances for a shell prompt fits all three. The same list used to decide which servers to stop does not, because a stale entry means acting on a machine that no longer exists. The test is to ask what the worst consequence of acting on data that is a TTL old would be, and to set the TTL below the point where that consequence becomes unacceptable.
Look also at what dominates the cost. A command that spends 800 ms starting a runtime and 20 ms asking a question is not helped by a cache that still starts a shell and hashes the arguments. Measure the uncached time and the cached time with the same benchmark method you would use for any optimisation; if the cached path is not several times faster, remove it and keep the code simpler.
Stale on error, and how to say so
The most valuable behaviour of a cache in a script is what it does when the source fails. Serving a copy that is hours old with a warning on the error stream is usually better than failing a prompt or a completion function, and for a status display it is the right choice. For a decision, the same behaviour is dangerous. Make this a parameter of the function rather than a global habit: a flag that allows stale data when the refresh fails, off by default, and a warning line that includes the age of the data so the reader can judge it.
Include the age in the output where a person looks. A prompt segment that shows pod counts with a small mark when the data is more than five minutes old tells the user how far to trust it. Silent staleness is the failure mode that makes people distrust a cache after one bad experience, and the cost of showing the age is a few characters.
Races, locks and partial writes
Several scripts started at the same moment, for example one per terminal tab or one per CI shard, find the cache expired together and each calls the slow command. This is a stampede, and it can trip the very rate limit the cache was meant to avoid. A lock around the refresh, taken with flock on a file in the cache directory, makes the first process refresh and the others wait and then read the new file. Give the waiters a timeout, so a stuck refresh does not block every other process for good.
Atomic replacement protects readers, and a lock protects the source. They solve different problems and both are cheap. Writing to a temporary file in the same directory and renaming it matters because a rename within one file system is atomic, while a rename across file systems is a copy followed by a delete and is not. Keeping the temporary file next to the target is what guarantees the first case.
Cache keys and privacy
Output from a command can contain information that is not meant to persist: tokens in a configuration dump, customer names in a listing, internal host names. A cache turns a transient value into a file that outlives the process, gets copied by backup tools and may be readable by other users if the mode is loose. Create the directory with mode 700, set umask 077 before writing, and keep secrets out of cached commands altogether by caching only the parsed, non-sensitive fields.
The hash used for the key should include everything that changes the answer but nothing that varies without changing it. Including the process ID or a timestamp makes every run a miss, and omitting the active account or region makes one user's data visible to another. Print the key components when debugging with a verbose flag, since the usual cache bug is two inputs that should differ producing the same key.
Where this does not apply
Do not cache anything where staleness is dangerous: authorization decisions, balances, deploy state. If a command takes ten milliseconds, the cache costs more than it saves (file reads and a hash run subprocesses). A memoisation within one run, with an associative array, is cheaper than a file cache when the same call repeats inside a single script. The XDG location is defined in the XDG Base Directory specification; see also flock(1).


