A small Bash loop takes zero-padded camera clip names, pulls out the number, and works out the number of the clip that comes next. It handles clip_07.mp4. At clip_08.mp4 it prints one error line, skips clip_08, clip_09 and clip_10 without another word, carries on with the rest of the script, and exits 0.
Here is the loop:
#!/bin/bash
# for each clip, work out the number of the clip that follows it
for f in clip_07.mp4 clip_08.mp4 clip_09.mp4 clip_10.mp4; do
n=${f#clip_}; n=${n%.mp4}
echo "start $f"
next=$((n + 1))
echo " next is $next"
done
echo "after the loop"
Run with bash next_clip.sh, then print $?:
start clip_07.mp4
next is 8
start clip_08.mp4
next_clip.sh: line 6: 08: value too great for base (error token is "08")
after the loop
exit status: 0
Two things went wrong, and the second is the one that costs you clips. The first is that Bash read 08 as an octal number and rejected it. The second is that the error did not stop the script. It abandoned the loop, and the script went on to its last line as if the loop had finished.
Disclosure: I make an Android camera app. Nothing below uses its code or describes how it names, numbers or stores clips. The clip names are made up, and every output is pasted from the scripts shown. Versions:
GNU bash, version 5.1.16(1)-release (aarch64-unknown-linux-gnu)
dash 0.5.11+git20210903+057cd650a4ed-3build1
date (GNU coreutils) 8.32
expr (GNU coreutils) 8.32
GNU Awk 5.1.0, API: 3.0 (GNU MPFR 4.1.0, GNU MP 6.2.1)
Python 3.10.12
A leading zero means octal
The Bash Reference Manual's section on shell arithmetic is short about this: "Constants with a leading 0 are interpreted as octal numbers." That sentence has been in the manual since at least the 2.05a edition, which is the copy I read it in.
Octal digits run from 0 to 7. So 07 is a fine octal number, 08 and 09 are not numbers at all, and 010 is a number, but it is eight, not ten:
#!/bin/bash
for n in 07 08 09 010 012; do
echo "n=$n \$((n + 1)) -> $( (echo $((n + 1))) 2>&1 )"
done
n=07 $((n + 1)) -> 8
n=08 $((n + 1)) -> constants.sh: line 3: 08: value too great for base (error token is "08")
n=09 $((n + 1)) -> constants.sh: line 3: 09: value too great for base (error token is "09")
n=010 $((n + 1)) -> 9
n=012 $((n + 1)) -> 11
The errors are the lucky case. 08 and 09 fail loudly. 010 and 012 do not fail. They come back as 9 and 11, the right answers for octal eight and ten, plus one. A loop that walks clip_010.mp4, clip_011.mp4 and so on with three-digit padding will compute wrong numbers with no error at all, until it reaches a name with an 8 or a 9 in it.
This is why the bug looks random. With two-digit padding, clips 01 to 07 work, clips 08 and 09 break, and from clip 10 onward everything works again, because 10 has no leading zero.
The error abandons the loop, not the script
The part I did not expect was what happened after the error. clip_08.mp4 printed start, then the error, and then nothing else from the loop. clip_09 and clip_10 did not print start at all. The line after the loop ran, and the exit status was 0.
That 0 is the status of the last command, the final echo. The failure left one line on stderr and nothing in the exit status. In a cron job whose stderr goes nowhere, that is a silent skip.
Bash has a mode that treats this differently. In the list of things that change in POSIX mode, the same manual says: "Non-interactive shells exit if a syntax error in an arithmetic expansion results in an invalid expression." I ran the same script three ways:
#!/bin/bash
for sh in "bash" "bash --posix" "dash"; do
echo "== $sh next_clip.sh"
$sh next_clip.sh 2>&1
echo "exit status: $?"
done
== bash next_clip.sh
start clip_07.mp4
next is 8
start clip_08.mp4
next_clip.sh: line 6: 08: value too great for base (error token is "08")
after the loop
exit status: 0
== bash --posix next_clip.sh
start clip_07.mp4
next is 8
start clip_08.mp4
next_clip.sh: line 6: 08: value too great for base (error token is "08")
exit status: 1
== dash next_clip.sh
start clip_07.mp4
next is 8
start clip_08.mp4
next_clip.sh: 6: Illegal number: 08
exit status: 2
Default Bash abandoned the loop and kept going. bash --posix stopped the script with status 1. dash, which is /bin/sh on this system, stopped with status 2. So the same file behaves differently depending on which shell runs it, and the one that hid the failure here was plain bash.
I did not test set -e here. I am not making a claim about whether it would have stopped this script.
Not every comparison does arithmetic
The next surprise is that the three common ways to compare numbers in Bash do not agree about 08 or about 010:
#!/bin/bash
for n in 08 010; do
printf 'n=%-4s' "$n"
printf '[ -lt 9 ]: '; [ "$n" -lt 9 ] && printf 'yes ' || printf 'no '
printf '[[ -lt 9 ]]: '; ( [[ $n -lt 9 ]] && printf 'yes ' || printf 'no ' ) 2>&1 | tr '\n' ' '
printf '(( n < 9 )): '; ( (( n < 9 )) && printf 'yes' || printf 'no' ) 2>&1 | tr '\n' ' '
echo
done
n=08 [ -lt 9 ]: yes [[ -lt 9 ]]: compare.sh: line 5: [[: 08: value too great for base (error token is "08") no (( n < 9 )): compare.sh: line 6: ((: 08: value too great for base (error token is "08") no
n=010 [ -lt 9 ]: no [[ -lt 9 ]]: yes (( n < 9 )): yes
- The single-bracket
testread08as eight and010as ten. It compared them as decimal integers here. - The double-bracket test and
(( ))both read them as arithmetic.08was an error, and010was eight, so it came out less than 9.
So for 010 the single-bracket test said no and the double-bracket test said yes, on the same shell and the same value. Moving a test from single to double brackets changes the answer for zero-padded input.
The hour field does this every morning
The most common place to meet this is date +%H, which pads the hour to two digits. An hourly job that does arithmetic on the hour works all day except at 8 and 9 in the morning:
#!/bin/bash
# what an hourly job sees at half past each hour
for h in 07 08 09 10; do
hh=$(date -u -d "2026-10-29 $h:30" +%H)
bare=$(date -u -d "2026-10-29 $h:30" +%-H)
echo "%H=$hh \$((hh + 1)) -> $( (echo $((hh + 1))) 2>&1 )"
echo " %-H=$bare \$((bare + 1)) -> $((bare + 1))"
done
%H=07 $((hh + 1)) -> 8
%-H=7 $((bare + 1)) -> 8
%H=08 $((hh + 1)) -> hours.sh: line 6: 08: value too great for base (error token is "08")
%-H=8 $((bare + 1)) -> 9
%H=09 $((hh + 1)) -> hours.sh: line 6: 09: value too great for base (error token is "09")
%-H=9 $((bare + 1)) -> 10
%H=10 $((hh + 1)) -> 11
%-H=10 $((bare + 1)) -> 11
The times are pinned with -d and -u so the output does not depend on when you run it. %H gives 08 and 09, and both fail. GNU date also accepts %-H, where the hyphen asks for no padding, and that version works for every hour. The same hyphen worked on other fields I tried, %-M for minutes and %-d for the day of the month.
Other tools read the same string as decimal
Bash's own printf builtin agrees with Bash arithmetic. Most other tools do not:
#!/bin/bash
for n in 08 010; do
echo "n=$n"
echo " printf %d: $( (printf '%d' "$n") 2>&1 | tr '\n' ' ')"
echo " expr: $(expr "$n" + 1)"
echo " awk: $(awk -v n="$n" 'BEGIN { print n + 1 }')"
echo " python int: $(python3 -c "print(int('$n') + 1)")"
done
n=08
printf %d: other_tools.sh: line 4: printf: 08: invalid octal number 0
expr: 9
awk: 9
python int: 9
n=010
printf %d: 8
expr: 11
awk: 11
python int: 11
printf '%d' 08 printed an error and then a 0, so a script using its output would carry on with zero. printf '%d' 010 printed 8. expr, GNU awk and Python's int() all read both values as decimal.
That makes handing the number to another tool a real fix in some scripts, but it also means you cannot assume two parts of one pipeline read a number the same way. If one step uses $(( )) and another uses awk, they can disagree on the same clip number.
Forcing base ten
Bash lets you name the base of a number with base#value, and the manual describes that form in the sentences right after the octal rule. 10#08 is eight read in base ten, and with it the loop works for every name:
#!/bin/bash
for f in clip_07.mp4 clip_08.mp4 clip_09.mp4 clip_10.mp4; do
n=${f#clip_}; n=${n%.mp4}
next=$((10#$n + 1))
printf '%s -> clip_%02d.mp4\n' "$f" "$next"
done
clip_07.mp4 -> clip_08.mp4
clip_08.mp4 -> clip_09.mp4
clip_09.mp4 -> clip_10.mp4
clip_10.mp4 -> clip_11.mp4
printf '%02d' puts the padding back for the next name. Note that this is printing a number that is already decimal, so the octal problem does not apply to it.
10# has edges of its own. On this version it rejects an empty value and a value with a sign in front, and it fails on anything that is not digits:
#!/bin/bash
for n in "08" "" "-08" "x8"; do
echo "n='$n' \$((10#\$n + 1)) -> $( (echo $((10#$n + 1))) 2>&1 )"
done
n='08' $((10#$n + 1)) -> 9
n='' $((10#$n + 1)) -> edges.sh: line 3: 10#: invalid integer constant (error token is "10#")
n='-08' $((10#$n + 1)) -> edges.sh: line 3: 10#: invalid integer constant (error token is "10#")
n='x8' $((10#$n + 1)) -> edges.sh: line 3: 10#x8: value too great for base (error token is "10#x8")
An empty value is what you get when the name does not match the pattern you stripped, so in a real loop it is the likely case, not an exotic one. A minus sign could come from a name like clip_-08.mp4 or from a subtraction earlier in the script. Newer Bash releases may handle the signed case differently. I did not test any other version, and I make no claim about them.
The safest shape I found is to check that the value is digits before any arithmetic, and to make the check fail in a way the caller can see:
#!/bin/bash
next_number() {
local n=$1
[[ $n =~ ^[0-9]+$ ]] || { echo "not a clip number: '$n'" >&2; return 1; }
echo $((10#$n + 1))
}
for n in 08 09 "" x8; do
out=$(next_number "$n") && echo "'$n' -> $out" || echo "'$n' -> skipped, status $?"
done
'08' -> 9
'09' -> 10
not a clip number: ''
'' -> skipped, status 1
not a clip number: 'x8'
'x8' -> skipped, status 1
Bad input now produces a message and a non-zero status for that one call, and the loop moves on to the next name on purpose rather than by accident.
What to change in a script like this
- Assume a padded value is a string, not a number, until it is converted. Clip numbers, hours from
date +%H, minutes, days of the month and parts of a file name are all likely to be padded. - Convert with
$((10#$n)), and check that$nis only digits before you do. - When you only need the number for display or comparison, ask for it unpadded, as in
date +%-H. - Do not switch a numeric test from single to double brackets on padded input without checking, because the two can give different answers.
- Count the items your loop processed and compare that with the number you expected, and log both. A loop that is abandoned partway leaves no other sign in the exit status.
- If a script must stop on an arithmetic error, test it under the shell that actually runs it. Default Bash,
bash --posixanddashdid three different things here.
Things I did not test
-
Other Bash versions. I ran Bash 5.1. The octal sentence is old and I expect it to hold, but I did not run another release, and the behavior of
10#with a sign or an empty value is the part most likely to differ. -
set -eanderrexittraps. I did not check whether either would have stopped the default-mode script. - zsh, ksh and BusyBox. I ran Bash and dash only. Other shells have their own rules for leading zeros, and I make no claim about them.
-
Non-GNU
date,exprandawk. The%-Hhyphen is a GNUdatefeature here, and I did not test any otherdate.
Sources. Both quotes are from the GNU Bash Reference Manual, read in the archived 2.05a edition, chapter 6: Bash Features (sections 6.5 Shell Arithmetic and 6.11 Bash POSIX Mode). The current manual's section is Shell Arithmetic; that page did not load for me while checking, so the quotes are from the archived copy. Outputs are pasted unedited from the scripts above.
If you want an old Android phone recording in the background so there are numbered clips for a loop like this to walk, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.
Until 10# turns it into a decimal, a padded clip number is octal to Bash, and the clip after clip_07 is where the loop quietly stops.
Top comments (0)