Bash

strstr() in Bash: Check if a String Contains a Substring

By Nawawi JamiliPublished Updated 5 min read

Checking whether one string contains another is probably the test I write most often in shell scripts. Is this line an error, does this hostname end in the right domain, did the command print "OK"? PHP has strstr() for this, and Bash can do the same job with a single [[ ]] test, no grep and no subshell.

The name can mislead you, though. PHP's strstr() does not return true or false. It returns the part of the haystack starting at the first match. My 2009 version only answered the yes/no question. This rewrite gives you both: strstr as a "contains" test, and php_strstr that prints the remainder the way PHP does.

The function

bash
# strstr() -- succeed if HAYSTACK contains NEEDLE (literal match)
# Usage: strstr haystack needle
strstr() {
    (( $# == 2 )) || return 2
    [[ $1 == *"$2"* ]]
}
# php_strstr() -- print HAYSTACK from the first NEEDLE onwards,
# or the part before NEEDLE when a third argument is given
# Usage: php_strstr haystack needle [before]
php_strstr() {
    (( $# == 2 || $# == 3 )) || return 2
    [[ $1 == *"$2"* ]] || return 1
    local before=${1%%"$2"*}
    if (( $# == 3 )); then
        printf '%s\n' "$before"
    else
        printf '%s\n' "${1:${#before}}"
    fi
}

Both functions return 0 on a match, 1 when the needle is not there, and 2 when they are called with the wrong number of arguments. Keeping "not found" and "bad usage" apart means a typo in your script does not quietly look like a normal negative answer.

How it works

Inside [[ ]], the right-hand side of == is a glob pattern, not a plain string. *"$2"* means "anything, then the needle, then anything". The stars are unquoted so they act as wildcards. "$2" is quoted so whatever is in the needle is matched literally. That quoting is the key part, and it is the bug the 2009 version had (more on that below).

The function body is just the test. The exit status of the last command becomes the function's return value, so there is no need for && return 0; return 1.

php_strstr uses two parameter expansions. ${1%%"$2"*} deletes the longest suffix that starts with the needle, which leaves everything before the first occurrence. Its length, ${#before}, is the offset of the match, and ${1:offset} slices from there to the end. That reproduces PHP's default behaviour (needle included), and the third argument mirrors PHP's $before_needle flag. Any non-empty third argument switches it on.

Usage

terminal
$ line="The quick brown fox jumps over the lazy dog"
$ strstr "$line" "brown fox" && echo found
found
$ strstr "$line" "Brown"; echo $?
1
$ strstr "$line"; echo $?
2

$ php_strstr "user@example.com" "@"
@example.com
$ php_strstr "user@example.com" "@" before
user
$ php_strstr "user@example.com" "#"; echo $?
1
$ php_strstr "key=value=more" "="
=value=more

The match is case-sensitive, so "Brown" is not found in a line that contains "brown". For a case-insensitive version see stristr(). The last example shows that only the first occurrence counts, exactly as in PHP.

In a script, the contains test reads naturally in an if:

bash
kernel=$(uname -r)
if strstr "$kernel" ".el8"; then
    echo "RHEL 8 family kernel"
fi
output
RHEL 8 family kernel

Be careful what you search in. A substring test knows nothing about word boundaries: strstr "inactive" "active" succeeds, so testing the output of systemctl is-active this way would report a stopped service as running. Compare the whole value with [[ $out == active ]] instead.

The same goes for process checks. The 2009 post used strstr "$(ps ax)" "httpd" to see whether Apache was running. That also matches vim /etc/httpd/conf/httpd.conf or a tail -f on an httpd log. For process checks, a proper process match is safer. See __pgrepf().

Pitfalls

The unquoted needle in the original

The 2009 code tested [[ "$1" = *$2* ]]. With $2 unquoted, any *, ? or [...] in the needle became a wildcard. Here is the old function, renamed to strstr_2009, against the new one:

terminal
$ strstr_2009 "backup.tar.gz" "b*z" && echo found
found
$ strstr_2009 "error.log" "err?r" && echo found
found
$ strstr_2009 "id: 42" "[0-9]" && echo found
found
$ strstr "backup.tar.gz" "b*z" || echo "not found"
not found
$ strstr "price: 5*3" "5*3" && echo found
found

None of the first three strings contains its needle literally, yet the old function said yes to all of them. If the needle ever comes from user input or a file, that is a real bug. If you actually want pattern matching, write the pattern out yourself ([[ $s == *[0-9]* ]]) so the intent is visible.

Empty needle and multibyte text

terminal
$ strstr "abc" ""; echo $?
0
$ php_strstr "abc" ""
abc
$ strstr "café crème" "é c" && echo found
found
$ php_strstr "café crème" "crè"
crème

An empty needle matches every string. PHP 8 behaves the same way (older PHP versions warned and returned false), so I left it alone. If an empty needle is a mistake in your script, check [[ -n $needle ]] before calling.

Accented characters are fine, because the comparison is byte-for-byte. The slice in php_strstr uses character offsets for both the length and the substring. So the two always agree, in a UTF-8 locale and in the C locale.

Portability

[[ ]], (( )), ${var:offset} and quoted parts inside patterns all work in Bash 3.2, so the stock macOS /bin/bash runs both functions. They are not POSIX sh. For dash or BusyBox use the case form below. Under set -u they are safe as well, because the argument count is checked before $2 is read.

Alternatives

Often you do not need a function at all. Parameter expansion and case cover most of what strstr() is used for in PHP:

terminal
$ email="user@example.com"
$ domain=${email#*@}; echo "$domain"
example.com
$ user=${email%%@*}; echo "$user"
user
$ case $email in *@*) echo "looks like an address" ;; esac
looks like an address
$ printf '%s\n' "$email" | grep -qF '@' && echo found
found

${email#*@} is the PHP idiom substr(strstr($email, '@'), 1) in one expansion. The case statement works in any POSIX shell. grep -qF (fixed string, quiet) is the right tool when the haystack is a file or a large stream. For a value already in a variable, piping it into grep just starts a process that is not needed.

History of this snippet

The original from April 2009 returned true or false, had the unquoted needle described above, and added a redundant [ "$1" = "$2" ] check before the pattern test (equal strings already match *"$2"*). The rewrite quotes the needle, drops the extra check, separates usage errors from "not found" with exit status 2, and adds php_strstr for the real PHP behaviour. The PHP reference is the strstr() manual page.