Bash

is_num() in Bash: Check if a Value Is a Number

By Nawawi JamiliPublished Updated 6 min read

Before a script does arithmetic with something a user typed, read from a file or got from another command, it should check that the value really is a number. PHP has is_numeric() and the ctype_digit() family. Bash has no such test, and its arithmetic accepts too much to be used as one. It treats an empty string as zero and a variable name as a reference.

is_num() was one of my first snippets in 2009. The code was fine, the comment was wrong, and there are more problems around numbers in Bash than a one-line regex suggests. This post covers them.

The function

bash
# is_num() -- succeed if INPUT is a non-negative integer (0, 7, 200)
# Usage: is_num input
is_num() {
    (( $# == 1 )) || return 2
    [[ $1 =~ ^[[:digit:]]+$ ]]
}
# is_int() -- succeed if INPUT is an integer with an optional sign
is_int() {
    (( $# == 1 )) || return 2
    [[ $1 =~ ^[-+]?[[:digit:]]+$ ]]
}
# is_decimal() -- integer or decimal with optional sign: -1.5, 3., .25
is_decimal() {
    (( $# == 1 )) || return 2
    [[ $1 =~ ^[-+]?([[:digit:]]+\.?[[:digit:]]*|\.[[:digit:]]+)$ ]]
}

Each returns 0 for a match, 1 for anything else, and 2 when called with no argument or more than one.

How it works

[[ string =~ regex ]] matches against a POSIX extended regular expression. ^ and $ anchor it to the whole value, so "12 monkeys" does not pass just because it starts with digits. [[:digit:]]+ means one or more digits. The 2009 version wrapped the digits in a capturing group, ^([0-9]+)$, which did nothing because nothing read BASH_REMATCH afterwards.

Leave the regex unquoted. Since Bash 3.2, quoting the right-hand side of =~ makes it a literal string, so [[ $1 =~ "^[0-9]+$" ]] would look for those exact characters. If a regex gets complicated, put it in a variable and use [[ $1 =~ $re ]].

The original comment said "Check if input is number between 0-9". The code never did that: it accepted any run of digits, of any length. The new comment says what the function actually does.

Usage

terminal
$ is_num 200 && echo "number"
number
$ is_num "12 monkeys" || echo "not a number"
not a number
$ is_num; echo $?
2
$ is_num 1 2; echo $?
2

Here is how the three functions treat a range of inputs:

output
input    is_num  is_int  is_decimal
"0"      yes     yes     yes
"200"    yes     yes     yes
"007"    yes     yes     yes
"-5"     no      yes     yes
"+5"     no      yes     yes
"3.14"   no      no      yes
".5"     no      no      yes
"5."     no      no      yes
""       no      no      no
" 42"    no      no      no
"1e3"    no      no      no
"0x1f"   no      no      no

Choose by what the next line of your script can handle. A count, port number, or array index needs is_num. An offset that may be negative needs is_int. is_decimal is for values you will pass to awk, bc or printf '%f'. This is because Bash's own arithmetic is integer-only and rejects "3.14" with a syntax error. Exponents, hex and surrounding spaces are all rejected. Strip spaces with trim() first if your input has them.

Pitfalls

Leading zeros are octal in arithmetic

is_num accepts "007" and "08", which is right for validation: they are digits. But Bash arithmetic, like C, reads a leading zero as octal:

terminal
$ n=010; echo $(( n + 1 ))
9
$ n=08; echo $(( n + 1 ))
bash: 08: value too great for base (error token is "08")
$ n=08; echo $(( 10#$n + 1 ))
9

This causes problems with dates and times more than anywhere else: date +%m in August gives "08", and $(( month + 1 )) then fails. The 10# prefix forces base 10. Validate first, then always write $(( 10#$n )) when the value could have leading zeros.

Note that 10# in Bash 4.4 does not accept a sign, so for is_int values strip the sign first:

terminal
$ n=-08; echo $(( 10#$n ))
bash: 10#-08: value too great for base (error token is "08")
$ d=${n#[-+]}; v=$(( 10#$d )); [[ $n == -* ]] && v=$(( -v )); echo "$v"
-8

The old test command is different again. [ "08" -gt 5 ] compares in decimal and succeeds. But [[ 08 -gt 5 ]] evaluates its operands as arithmetic and fails with the same "value too great for base" error. Mixing the two in one script is a good way to get inconsistent results.

Why not just test with (( ))?

It is tempting to skip validation and let arithmetic complain. That does not work. An empty variable evaluates to 0, so n=""; (( n == 0 )) is true. A string that happens to be a variable name evaluates to that variable's value. Worse, arithmetic expands array subscripts, and a subscript can contain a command substitution:

terminal
$ x="a[\$(echo injected >&2)]"
$ (( x > 0 )) || echo "x is not positive"
injected
x is not positive

The command ran. If x came from a web form or a filename, that could have been anything. Run untrusted input through is_num or is_int before you use it in (( )), $(( )), let, or an array index.

[0-9] is not always ten characters

I used [[:digit:]] rather than the original [0-9] because of something I only found while testing this rewrite. On this machine (glibc 2.28, en_GB.UTF-8 locale), the range [0-9] in a Bash regex also matches non-ASCII digits such as the Arabic-Indic three:

terminal
$ [[ "٣" =~ ^[0-9]+$ ]] && echo "[0-9] matched"
[0-9] matched
$ [[ "٣" =~ ^[[:digit:]]+$ ]] || echo "[[:digit:]] did not match"
[[:digit:]] did not match
$ echo $(( 99999999999999999999 ))
7766279631452241919

Ranges follow the locale's collation order, and results vary between C libraries and versions. [[:digit:]] is defined as the ten digits 0-9, and so is an explicit list [0123456789]. With LC_ALL=C the range is safe as well.

Very long numbers overflow

The last line above shows the other limit. Bash integers are 64-bit signed, so anything above 9223372036854775807 wraps around silently. If the value is a size or ID that could be huge, check its length ((( ${#n} <= 18 ))) or pass it to bc.

The one-liner without a function

In a POSIX sh script, where [[ =~ ]] is not available, a case pattern does the same job as is_num:

terminal
$ case 42 in ''|*[![:digit:]]*) echo "no" ;; *) echo "number" ;; esac
number
$ [ "08" -gt 5 ] && echo "test reads 08 as decimal"
test reads 08 as decimal

The empty alternative '' is essential. Without it an empty string would count as a number. The regex functions themselves work in Bash 3.2 and later, so the default macOS shell runs them.

History of this snippet

The April 2009 version had the right regex and the wrong comment. It used [0-9], returned 1 for bad usage, and only handled non-negative integers. The rewrite keeps the function and fixes the comment. It switches to [[:digit:]], returns 2 for usage errors, and adds is_int and is_decimal. If you are coming from PHP, note that is_numeric() is much looser than any of these (it accepts exponents and surrounding whitespace). See the is_numeric() manual page.

  • str_repeat(): uses the same validation for its count
  • yesno(): validate a yes/no answer
  • trim(): strip whitespace before validating
  • getopts: parse options, then validate their values