Every shell programmer knows while. Almost nobody uses its sibling: until.
It's the same loop with the condition inverted — and in the right spot, it reads
like plain English where while ! reads like a puzzle.
Syntax
until CONDITION; do
BODY
done
The loop runs the body repeatedly until CONDITION succeeds (i.e. exits
with status 0). Put differently: it keeps looping as long as CONDITION
fails.
Compare:
# while: loop as long as the condition is true
while ! pgrep -x redis-server >/dev/null; do sleep 1; done
# until: loop until the condition becomes true
until pgrep -x redis-server >/dev/null; do sleep 1; done
Same behavior. The second one reads the way you'd describe it out loud: wait
until Redis is running. That's the whole case for until — pick the form where
the reader doesn't have to flip a boolean in their head.
Five practical examples
1. Wait for a service to come up
Starting a container, then waiting for its HTTP endpoint:
docker compose up -d api
until curl -sf http://localhost:8080/health >/dev/null; do
sleep 1
done
echo "API is ready"
curl -sf exits non-zero on connection refused, timeouts, and non-2xx responses
— all the states where "ready" is still false.
2. Wait for a file to appear
Useful when another process is supposed to drop a marker:
until [ -f /tmp/import.done ]; do
sleep 0.5
done
process_the_import3. Retry a flaky command, with a cap
Infinite loops are a footgun. Always pair until with a maximum:
attempt=0
max=10
until curl -sf https://example.com/api/data -o data.json; do
attempt=$((attempt + 1))
if [ "$attempt" -ge "$max" ]; then
echo "giving up after $attempt attempts" >&2
exit 1
fi
sleep $((attempt * 2)) # linear backoff
done4. Wait for a TCP port
Before running migrations against a freshly-booted Postgres:
until nc -z localhost 5432; do
sleep 1
done
psql -h localhost -U app -d app -f migrations.sql
nc -z (or nc -zv if you want the noise) probes the port without sending
data.
5. Block until a background job finishes
kill -0 "$pid" doesn't actually kill anything — it only asks "is this pid
still alive?" (exits 0 if yes).
long_running_job &
pid=$!
# Do other work here if needed, then block:
until ! kill -0 "$pid" 2>/dev/null; do
sleep 1
done
wait "$pid" # reap the exit status
wait "$pid" alone is usually what you want, but the pattern above is handy
when you need to poll some other condition alongside liveness (e.g. a spinner,
or a deadline).
Gotchas
Always have an escape hatch. An until with no timeout and no attempt
counter is a script that hangs forever when its dependency never comes up. Use
timeout, a counter, or both:
timeout 60 bash -c 'until curl -sf http://localhost:8080/health; do sleep 1; done'
set -e does not break the loop on body failures. Commands inside the
do/done block that fail won't abort the loop — only the condition influences
iteration. If you need to fail fast mid-body, check explicitly and exit or
break.
Portability. until is POSIX. It works in bash, zsh, dash, ksh, and
ash (BusyBox). If your script starts with #!/bin/sh, you're fine.
Don't invert for style alone. If the natural way to describe the loop is
"while X is still happening," use while. until earns its keep only when the
success condition is the obvious thing to name.
Why it's worth the muscle memory
until doesn't do anything while can't. It just occasionally produces code
that's obvious at a glance instead of mildly obvious after parsing a negation.
For a tiny keyword, that's a fair trade. The next time you find yourself typing
while !, pause for half a second and ask whether the other spelling reads
better. Often it does.