← back to latest

Do math on zero-padded numbers without the shell reading them as octal

echo $(( 10#$(date +%m) ))

anatomy

echo
Arithmetic expansion is a substitution, not a command. It produces a string where it sits and needs something to receive it. If you only want the side effect of an assignment, use the `(( ))` compound command instead, which evaluates and returns an exit status rather than printing.
$(( ))
Arithmetic expansion. Everything inside is parsed as a C-style integer expression, so bare words are read as variable names and you can drop the `$` on them. Evaluation is fixed-width signed integers with no overflow check and no fractions: `$(( 7 / 2 ))` is 3, and there is no flag that changes that.
10#
The base prefix, and the entire point of the command. Without it the shell applies C's constant rules: a leading `0x` means hex, a bare leading `0` means octal, and everything else means decimal. Writing `10#` states the base out loud, so `09` is nine rather than an invalid octal digit. The general form is `base#number` with a base from 2 to 64.
$(date +%m)
Command substitution producing the current month as two characters. `%m` is specified to be zero padded, which is why the value is `09` and not `9`. Every other padded date field behaves the same way: `%d`, `%H`, `%M`, `%S`, and the three-character `%j`.

Sample output

$ date +%m
09

$ month=$(date +%m); echo $(( month + 1 ))
bash: 09: value too great for base (error token is "09")

$ echo $(( 10#$(date +%m) ))
9

The error is the shell telling you something true in a confusing way. It read 09 as an octal constant because of the leading zero, and 9 is not a digit that exists in base 8, so the value really is too great for the base it was handed. The 10# prefix removes the guesswork by naming the base, and the same fix works on anything padded: a day, an hour, a build number sliced out of a filename.

When you would reach for it

Any time a number arrives as text with a zero on the front and you need to add, compare, or index with it. That covers most of date, which pads %m, %d, %H, %M, %S, and %j by specification, along with sequence numbers in filenames, ISO week numbers, and octets pulled out of an IP address with cut. The failure has a season. Padded values from 01 to 07 are valid octal and happen to equal their decimal reading, so a script can run correctly for seven months and then break on the eighth day of the month, or in August, depending on which field it touches.

Gotchas

  • The error is the good outcome. 08 and 09 fail loudly, but 010 and 031 are valid octal and evaluate silently to 8 and 25. A script that compares $(date +%j) against a threshold is quietly six off on the 31st of January and never says a word about it. If you only defend against the error message, you have fixed the half of the problem that was already telling you it was broken.
  • The prefix is picky about what follows it. $(( 10#$m )) works, but $(( 10#m )) fails with the same “value too great for base” message, because the prefix expects literal digits and m is not one, even though bare $(( m )) is the normal way to reference a variable. An empty value is a different error: $(( 10#$m )) with m unset becomes 10# on its own and reports an invalid integer constant, so guard it with $(( 10#${m:-0} )).
  • Your interactive shell may not reproduce the bug. zsh leaves its OCTAL_ZEROES option off by default, specifically because the rule breaks date and time strings, so $(( 09 + 1 )) returns 10 in a macOS login shell and then fails the moment the same line runs under #!/bin/bash. Test under the shebang you ship, not the shell you type in.
  • 10# is not portable to POSIX sh. dash rejects 10#08 as a syntax error, and it rejects a bare 08 as well, so a #!/bin/sh script gets no fix and no free pass. See the variants for the portable form.
  • printf carries the same C rules in its numeric conversions. printf '%d' 08 refuses with “invalid octal number”, and printf '%d' 010 prints 8 without comment. Passing a padded value to %d is the same trap wearing different clothes.

Variants

$ echo $(( 16#1f4 )) $(( 2#11010110 )) $(( 8#755 ))

Any base from 2 to 64 in, decimal out. Digits run 0-9, then a-z, then A-Z, then @ and _ for bases above 36, and case only matters once the base passes 36

$ printf '%#x %#o\n' 500 500

The trip back out. $(( )) only ever returns decimal, so conversion in the other direction belongs to printf, and %# adds the 0x and 0 prefixes that the shell will read back correctly

$ m=09; echo $(( ${m#0} + 1 ))

The portable fix for #!/bin/sh, using parameter expansion to strip one leading zero before the arithmetic sees it. It handles two-character fields; for %j and its three characters you need the strip twice or expr "$m" + 1, which treats its arguments as decimal and has no opinion about zeros

lineage

Octal is a fossil of machine architecture. The PDP-8 had 12-bit words and the PDP-11 packed its instructions into three-bit fields, which made base 8 the natural way to read a word off a console: three bits per digit, no letters required, and a clean split at the field boundaries. Unix inherited the habit from the hardware it grew up on, which is also why file permissions are octal, with one digit per read-write-execute triad. B and then C wrote the convention into the language itself as a lexer rule, where a bare leading zero marks an octal constant, and that rule was copied more or less verbatim by Perl, by JavaScript, by Python until version 3 replaced it with an explicit `0o`, and by shell arithmetic. So a notation chosen for reading PDP-11 core dumps is still deciding what your date command means in August. The escape hatch came later: the `base#number` form arrived with the Korn shell, bash inherited it, and it remains the only part of the system where you get to say which base you meant instead of having it inferred from punctuation.