Bash

in_array() in Bash: Check if an Array Contains a Value

By Nawawi JamiliPublished 5 min read

PHP's in_array($needle, $haystack) tells you whether an array contains a value. Bash has no such test built in. People often share a one-liner that matches against "${arr[*]}", but it gives wrong answers as soon as an element contains a space. A ten-line loop does the job correctly. For large lists, an associative array does it much faster.

I use this mostly for validating input: is this action one of start stop restart, is this hostname in the list of servers I am allowed to change.

The function

bash
# in_array() -- check whether a value is in a list
# Usage: in_array needle item...
#        in_array "$value" "${array[@]}"
in_array() {
    [ $# -ge 1 ] || return 2
    local needle="$1" item
    shift
    for item in "$@"; do
        [[ $item == "$needle" ]] && return 0
    done
    return 1
}

It returns 0 if the value is found, 1 if not, and 2 if it was called with no arguments at all. This way, a caller can tell "not found" from "used wrongly". It prints nothing. You use it directly in an if.

How it works

Bash cannot pass an array to a function as a single argument, so the array is expanded into separate arguments with "${array[@]}". The quotes are essential: they keep each element as exactly one word, including any spaces. Inside the function, the first argument is saved as the needle, shift drops it, and for item in "$@" goes through the rest.

The comparison is [[ $item == "$needle" ]]. On the right-hand side of ==, an unquoted word is a glob pattern. Quoting "$needle" makes it a literal string. Without those quotes, in_array "a*" ... would match apple. The left side does not need quotes inside [[ ]], because no word splitting happens there.

Usage

bash
fruits=("apple" "banana split" "cherry")
if in_array "cherry" "${fruits[@]}"; then
    echo "we have cherries"
fi
actions=(start stop restart status)
in_array "reload" "${actions[@]}" || echo "Unknown action: reload"
output
we have cherries
Unknown action: reload

And the cases that matter, run with set -u on Bash 4.4:

terminal
$ in_array "banana split" "${fruits[@]}" && echo yes || echo no
yes
$ in_array "banana" "${fruits[@]}" && echo yes || echo no
no
$ in_array "a*" "${fruits[@]}" && echo yes || echo no
no
$ in_array "" "${fruits[@]}" && echo yes || echo no
no
$ in_array "" "a" "" && echo yes || echo no
yes
$ in_array; echo "rc=$?"
rc=2

A partial word is not a match, a glob is not a match, and an empty string is only found when the array really contains an empty element. That is the PHP behaviour with strict comparison.

Why the "${arr[*]}" trick gives false positives

This is the version you will find in many scripts:

bash
[[ " ${arr[*]} " =~ " $value " ]] && echo found

It joins the array into one string separated by spaces, pads it with a space at each end, and looks for the value surrounded by spaces. It works for arrays of single words. It fails as soon as an element contains the separator:

bash
arr=("apple" "banana split" "cherry")
[[ " ${arr[*]} " =~ " banana " ]] && echo "found banana (wrong)"
[[ " ${arr[*]} " =~ " split cherry " ]] && echo "found 'split cherry' (wrong)"
[[ " ${arr[*]} " == *" banana "* ]] && echo "glob version also wrong"
output
found banana (wrong)
found 'split cherry' (wrong)
glob version also wrong

The joined string is " apple banana split cherry ", and the information about where one element ends and the next begins is gone. banana is found although no element is banana, and split cherry is found by matching across two elements. Changing IFS to a different separator just moves the problem to values that contain that character.

There is a second problem if the regex is kept in a variable, which is the usual way to avoid quoting problems with =~. Then it really is a regular expression, and a dot matches any character:

terminal
$ arr=("xzy"); re=" x.y "
$ [[ " ${arr[*]} " =~ $re ]] && echo "x.y matched xzy"
x.y matched xzy

For hostnames, IP addresses and version strings, that is a real bug, not a theoretical one. Use the loop.

Many lookups: an associative array as a set

The loop is linear: every call goes through the whole list. If you check thousands of values against a long list, build an associative array once and look keys up directly. This needs Bash 4.0 or later, so it does not work with the default Bash 3.2 on macOS:

bash
declare -A allowed=()
for u in alice bob "carol smith"; do
    allowed[$u]=1
done
for u in bob carol "carol smith"; do
    if [[ ${allowed[$u]+set} ]]; then
        echo "[$u] allowed"
    else
        echo "[$u] denied"
    fi
done
output
[bob] allowed
[carol] denied
[carol smith] allowed

${allowed[$u]+set} expands to set if the key exists and to nothing if it does not. So the test works whatever value you stored, and it is safe under set -u. You will also see [[ -v allowed[$u] ]] (Bash 4.2+). It is easy to read, but in my tests on 4.4 it gave the wrong answer for a key containing ], while the +set form handled it. Bash 4.4 also rejects an empty key completely, with bad array subscript. So check for an empty value before using it as a key.

To see the difference, I timed 1,000 lookups in a 2,000-element list on this machine. The in_array loop took 6.3 seconds. The associative array took 0.004 seconds. With ten elements you will not notice, so use whichever reads better.

PHP's array_search() returns the key instead of true. With a nameref (Bash 4.3+) the function can walk the real indexes, which matters for sparse arrays:

bash
# array_search() -- print the index of the first match
# Usage: array_search needle arrayname   (Bash 4.3+)
array_search() {
    [ $# -eq 2 ] || return 2
    local -n _as_arr="$2"
    local i
    for i in "${!_as_arr[@]}"; do
        if [[ ${_as_arr[$i]} == "$1" ]]; then
            printf '%s\n' "$i"
            return 0
        fi
    done
    return 1
}
terminal
$ array_search "cherry" fruits
2
$ array_search "kiwi" fruits; echo "rc=$?"
rc=1
$ sparse=([3]=a [10]=b); array_search b sparse
10

Note that this one takes the array name, not its expanded values.

Pitfalls

  • Forgetting the quotes in "${array[@]}" splits elements on spaces and expands globs. This gives you exactly the false positives the function is meant to avoid.
  • Empty arrays and set -u. On Bash 4.4 and later, "${empty[@]}" under set -u is fine. Older versions (including 3.2 and 4.0-4.3) stop with an "unbound variable" error. ${empty[@]+"${empty[@]}"} works on all of them.
  • Case. The comparison is case-sensitive. Lower-case both sides first with strtolower() if you need otherwise.

See Arrays in the Bash manual and in_array() on php.net.