rm_str() in Bash: Remove a Substring From a String
Many people still use sed or tr out of habit to remove a substring. Typical jobs are to strip the dashes from a date, the quotes from a CSV field or a prefix from a hostname. PHP does it with str_replace($needle, '', $string). Bash does it with one parameter expansion, ${string//needle/}, and no extra process.
rm_str() is a small wrapper around that expansion. The wrapper exists mainly to get the quoting right, which the 2009 version did not.
The function
# rm_str() -- remove every occurrence of NEEDLE from STRING
# Usage: rm_str string needle
rm_str() {
(( $# == 2 )) || return 2
[[ -n $2 ]] || return 2
printf '%s\n' "${1//"$2"/}"
}It prints the result and returns 0. Calling it with the wrong number of arguments, or with an empty needle, returns 2 and prints nothing.
How it works
${var/pattern/replacement} replaces the first match of pattern in var. Doubling the first slash, ${var//pattern/replacement}, replaces every match. With an empty replacement, the matches are simply deleted. The trailing slash is optional (${var//pattern} works too). I keep it because it makes the "replace with nothing" intent obvious.
The important part is "$2" inside the expansion. The pattern position is a glob, just like the right-hand side of [[ == ]]. Quoting the variable there tells Bash to match the needle literally, so a * or ? in it is just a character. The outer double quotes then protect the result from word splitting and filename expansion before printf sees it.
Replacement goes left to right without overlaps, the same as PHP's str_replace(): "banana" minus "ana" is "bna", because after the first "ana" is removed, only "na" is left.
Usage
$ rm_str "hello cruel world" "cruel "
hello world
$ rm_str "2026-10-17" "-"
20261017
$ rm_str 'say "hello"' '"'
say hello
$ rm_str "banana" "ana"
bna
$ rm_str "naïve café" "é"
naïve caf
$ rm_str "" "x"; echo $?
0
$ rm_str "abc" ""; echo $?
2
$ rm_str "abc"; echo $?
2Capture the result with command substitution as usual: day=$(rm_str "$date" "-"). An empty input string is not an error. There is simply nothing to remove, so you get an empty line and status 0.
What was wrong with the 2009 version
The original body was:
rm_str_2009() {
local _str="$1";
local _chr="$2";
[ -z "$_chr" ] && _chr='"';
if [ -n "$_str" -a -n "$_chr" ]; then
echo ${_str//$_chr};
return 0;
fi;
return 1;
}It has three separate problems, and they are all visible in a short session. I ran this in a directory containing two files, v12-notes and v12.log, created half-way through:
$ rm_str_2009 "a b c" "b"
a c
$ rm_str "a b c" "b"
a c
$ rm_str_2009 "v1.2*" "."
v12*
$ touch v12-notes v12.log
$ rm_str_2009 "v1.2*" "."
v12.log v12-notes
$ rm_str "v1.2*" "."
v12*
$ rm_str_2009 "a*b*c" "*"
$ rm_str "a*b*c" "*"
abcThe unquoted echo. echo ${_str//$_chr} has no quotes around the expansion, so the result goes through word splitting and globbing. Runs of spaces collapse to one, and a result like v12* is expanded to whatever files happen to match in the current directory. The same call gave two different answers depending on what was in the directory. echo also mangles strings that start with -n or -e, which is why the new version uses printf '%s\n'.
The unquoted pattern. ${_str//$_chr} treats the needle as a glob. Removing * from "a*b*c" matched the whole string, so the old function printed an empty line instead of "abc".
The odd default. With no second argument, the original removed double quotes:
$ rm_str_2009 'say "hello"'
say hello
$ rm_str_2009 "" "x"; echo $?
1I remember why I did that: I was cleaning quoted values out of config files at the time. But a function called "remove string" that quietly picks a string for you is dangerous, and it hides a forgotten argument. The new version refuses to guess and returns 2. If you want quotes removed, say so: rm_str "$s" '"'.
Pitfalls
When you do want a pattern
Sometimes the glob behaviour is exactly what you need, such as deleting all whitespace or every non-digit. In that case, write the pattern directly in the expansion rather than passing it through a variable, so anyone reading the script can see it is a pattern:
$ s=" tab and spaces "
$ printf '[%s]\n' "${s//[[:space:]]/}"
[tabandspaces]
$ v="v1.2.3-rc1"
$ printf '%s\n' "${v//[![:digit:]]/}"
1231
$ x="one, two, three"
$ printf '%s\n' "${x/, /}"
onetwo, three
$ f="report.final.pdf"
$ printf '%s\n' "${f%.pdf}" "${f#report.}"
report.final
final.pdfThe single slash in ${x/, /} removes only the first match. ${f%.pdf} and ${f#report.} remove a suffix or prefix only, which is safer than a global removal when you are trimming file extensions: "pdf.pdf" loses one ".pdf", not both. I used [[:digit:]] rather than [0-9] on purpose. In some UTF-8 locales a range like [0-9] matches more than the ten ASCII digits (see is_num()).
Locale, multibyte text and versions
In a UTF-8 locale Bash matches characters, so removing "é" from "café" works as you would expect. The pattern expansion and the quoted needle both work in Bash 3.2, so the function runs unchanged on macOS. It is safe under set -u because the argument count is checked before $2 is used.
Bash 5.2 added the patsub_replacement option, where an unquoted & in the replacement stands for the matched text. With an empty replacement, as here, it makes no difference. It matters for the general replace function in str_replace().
Alternatives
For a stream or a file, external tools are the right choice because they do not need the data in a variable first:
$ printf '%s\n' 'say "hello"' | tr -d '"'
say hello
$ printf '%s\n' "banana" | tr -d 'an'
b
$ printf '%s\n' "banana" | sed 's/ana//g'
bnaNote the difference: tr -d deletes a set of characters, so tr -d 'an' removes every "a" and every "n", while sed and rm_str remove the string "ana". For a value already in a variable, the expansion is the fastest option because it does not start a process.
History of this snippet
The April 2009 version used an unquoted echo, an unquoted pattern, removed double quotes when no needle was given, and returned 1 for empty input. The rewrite quotes both the pattern and the result, uses printf, and returns 2 for a missing or empty needle.
Related functions
- str_replace(): replace a substring with another
- trim(): strip whitespace from both ends
- strstr(): check whether a string contains a substring
- strlen(): string length