trim() in Bash: Strip Leading and Trailing Whitespace
PHP has trim(), ltrim() and rtrim(). Bash has none of them, and the usual answer on forums is to pipe through sed or xargs. You do not need either: two parameter expansions strip whitespace from both ends of a string without starting a single process.
I need this constantly when reading config files, parsing command output and cleaning up what users type at a prompt. Here are the three functions I use now, how they work, and the shortcuts that look the same but are not.
The function
# trim() -- strip leading and trailing whitespace
# Usage: trim string
trim() {
[ $# -eq 1 ] || return 1
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s\n' "$s"
}
# ltrim() -- strip leading whitespace only
ltrim() {
[ $# -eq 1 ] || return 1
local s="$1"
printf '%s\n' "${s#"${s%%[![:space:]]*}"}"
}
# rtrim() -- strip trailing whitespace only
rtrim() {
[ $# -eq 1 ] || return 1
local s="$1"
printf '%s\n' "${s%"${s##*[![:space:]]}"}"
}All three return 1 if called with the wrong number of arguments. They print the result with printf '%s\n', so a string such as -n or one containing backslashes comes out unchanged.
How it works
The method is to first work out what the leading whitespace is, then remove exactly that. [![:space:]] is a bracket expression meaning "any character that is not whitespace". So ${s%%[![:space:]]*} removes the longest suffix that starts with a non-space character, which leaves only the leading run of whitespace.
That run is then fed back as the pattern for ${s#...}, which strips it from the front. The trailing side is the mirror image: ${s##*[![:space:]]} removes everything up to and including the last non-space character, leaving the trailing whitespace, and ${s%...} cuts it off the end. Here are the intermediate values:
s=" hello world "
lead="${s%%[![:space:]]*}"; printf 'lead=[%s]\n' "$lead"
s="${s#"$lead"}"; printf 'after=[%s]\n' "$s"
trail="${s##*[![:space:]]}"; printf 'trail=[%s]\n' "$trail"
s="${s%"$trail"}"; printf 'final=[%s]\n' "$s"lead=[ ]
after=[hello world ]
trail=[ ]
final=[hello world]The inner expansion is quoted ("${s%%...}") so it is treated as a literal string rather than a pattern. The captured text is only ever whitespace, so in practice it would match either way, but quoting a pattern you built from data is a habit worth keeping. It costs nothing and it is the difference between correct and subtly wrong in functions like str_replace().
[:space:] covers space, tab, newline, vertical tab, form feed and carriage return. This is close to PHP's default list (PHP also strips NUL bytes, which a Bash variable cannot hold anyway).
Usage
$ trim " hello world " | cat -A
hello world$
$ ltrim " hello world " | cat -A
hello world $
$ rtrim " hello world " | cat -A
hello world$cat -A marks the end of each line with $, so you can see where the spaces went. Whitespace inside the string is left alone, as with PHP. Tabs and newlines at the edges go too:
$ trim $'\t\n two words \n\t' | cat -A
two words$
$ trim $'value\r' | od -c
0000000 v a l u e \n
0000006The second example is the one that causes problems for people reading files saved on Windows: the stray \r is removed. In a script you will normally capture the result:
read -r -p "Hostname: " answer
host=$(trim "$answer")Without the subshell
$(trim ...) forks a subshell for every call. That is fine for a prompt, but in a loop over thousands of lines the cost grows quickly. This variant writes the result straight into a variable with printf -v (Bash 3.1 and later):
# trim_to() -- trim into a variable, no subshell
# Usage: trim_to varname string
trim_to() {
[ $# -eq 2 ] || return 1
local s="$2"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf -v "$1" '%s' "$s"
}$ trim_to name " Ada Lovelace "
$ printf '[%s]\n' "$name"
[Ada Lovelace]On my machine, 10,000 calls took 0.31s with trim_to, 3.1s with r=$(trim ...) and 12.7s with r=$(printf ... | sed ...). That is one measurement on one machine, but the ratio is what you would expect from counting forks. Pick a variable name that does not clash with the function's own local s.
The extglob version
With extglob turned on, +(pattern) means "one or more of pattern", so you can strip the whitespace directly with the longest-match operators:
shopt -s extglob
s=$' \t hello world \t '
s="${s##+([[:space:]])}"
s="${s%%+([[:space:]])}"
printf '[%s]\n' "$s"[hello world]It reads more naturally, but it depends on a shell option. Without shopt -s extglob the same lines silently do nothing and you get [ hello ] back. If you use it inside a function in a library, save and restore the caller's setting with restore=$(shopt -p extglob) and eval "$restore". The [![:space:]] version needs no options at all, which is why I prefer it for library code. Extended globbing on very long strings can also be slow, because the longest-match operators try many positions.
The read trick, and why it fails
You will see this a lot:
read t <<< "$s"read splits on IFS, and leading and trailing IFS whitespace is dropped, so it does trim. But without -r, read treats backslash as an escape character and removes it:
s=' C:\temp\new '
read t <<< "$s"; printf 'no -r: [%s]\n' "$t"
read -r t <<< "$s"; printf 'with -r: [%s]\n' "$t"no -r: [C:tempnew]
with -r: [C:\temp\new]A trailing backslash is worse: it is taken as a line continuation, so ' ends with \' comes back as ends with. Even with -r the trick has two more limits. It only reads the first line, so $' line one\n line two ' gives you line one and quietly drops the rest. And it only trims characters that are in IFS. If a script has set IFS=:, nothing is trimmed at all. IFS=$' \t\n' read -r t fixes that, but by then the parameter expansion is shorter and clearer.
Edge cases
- Empty or all-whitespace input returns an empty line, not an error.
trimwith no argument at all returns 1. - Glob characters are safe:
trim " a*b?c[d] "printsa*b?c[d]because the result is always quoted. - Unicode spaces: in the
en_GB.UTF-8locale on this machine (glibc), the no-break space U+00A0 is not in[:space:], so text pasted from a web page may keep it. Handle it explicitly with${s//$'\u00a0'/ }first if you need to (the$'\u...'escape needs Bash 4.2+). - Command substitution strips trailing newlines from whatever it captures, so
$(rtrim "$s")and"$s"captured from a command can already differ. That is the shell, not the function. - Bash 3.2 on macOS: the functions and the extglob version work, including
printf -v. Nothing in the functions needs Bash 4.
Alternatives
For a whole file, use an external tool once rather than a shell loop. sed trims every line:
sed 's/^[[:space:]]*//; s/[[:space:]]*$//' input.txtawk '{$1=$1; print}' also trims, but it squeezes internal runs of spaces into one, so " hello world " becomes hello world. Sometimes that is what you want. It is not what trim() does. Avoid xargs as a trimmer: it interprets quotes and backslashes, and fails with unmatched single quote on a string as ordinary as it's here.
For the full semantics of the expansions used here, see Shell Parameter Expansion in the Bash manual, and trim() on php.net for the PHP original.
Related functions
- str_replace(): replace text with
${s//search/replace} - str_pad(): the opposite job: add padding
- rm_str(): remove a substring
- Reading a file line by line: where
IFS=and-rmatter - PHP string functions in Bash: the full index