strtolower() in Bash: Convert a String to Lowercase
You have a string in mixed case, say an email address or a "YES" typed at a prompt, and you want it in lowercase before comparing or storing it. PHP does this with strtolower(). Bash 4 and later do it with a parameter expansion, ${var,,}, and older Bash needs a fallback. This post covers both, plus the question that decides whether the result is what you expect: which locale is the conversion using?
The function
# strtolower -- print a string in lowercase
# Usage: strtolower string
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
strtolower() {
[ "$#" -eq 1 ] || { printf 'usage: strtolower string\n' >&2; return 2; }
printf '%s\n' "${1,,}"
}
else
# Bash 3.x (stock macOS): ASCII A-Z only
strtolower() {
[ "$#" -eq 1 ] || { printf 'usage: strtolower string\n' >&2; return 2; }
local str=$1 upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ
local lower=abcdefghijklmnopqrstuvwxyz i
for ((i = 0; i < 26; i++)); do
str=${str//${upper:i:1}/${lower:i:1}}
done
printf '%s\n' "$str"
}
fiThe version test runs once, when the file is sourced, and defines whichever function suits the shell. After that, callers just use strtolower and never think about it.
How it works
The Bash 4 path. ${1,,} is the case-modification expansion added in Bash 4.0. Two commas lower every character. One comma lowers only the first (that is lcfirst()). The conversion works character by character, using the C library's case tables for the current locale. So it handles "É" and "Ö" as well as "A" to "Z" when you run under a UTF-8 locale.
The Bash 3 path. The stock /bin/bash on macOS is still 3.2, which has no ${var,,}. The fallback walks the 26 letters and uses pattern substitution, ${str//X/x}, to replace every occurrence of each capital with its lowercase partner. ${upper:i:1} picks the i-th letter out of a plain string, so there are no arrays to maintain. It makes 26 passes over the string, which is fine for the short values you normally lowercase, and it never starts an external process.
It is deliberately ASCII-only. A pure-Bash table covering all of Unicode would be far too large. If you need accented letters on an old shell, an external tool is the better answer (see Alternatives).
Output and errors. The function prints with printf '%s\n' and quotes the expansion. It returns 2 with a usage message unless it gets exactly one argument.
Usage
$ strtolower "HELLO WORLD"
hello world
$ strtolower "MiXeD CaSe 123"
mixed case 123
$ email=$(strtolower "Admin@Example.COM")
$ printf '%s\n' "$email"
admin@example.com
$ strtolower "ÉCOLE ÖSTERREICH"
école österreich
$ strtolower; echo "exit status: $?"
usage: strtolower string
exit status: 2That was Bash 4.4 with LANG=en_GB.UTF-8. Digits and punctuation pass through untouched. I also tested the Bash 3 fallback by loading just the else branch into Bash 4.4, since I have no 3.2 build on this machine. The first three lines are identical, but the accented line comes back as École Österreich. The plain ASCII letters change, "É" and "Ö" do not.
The locale decides
${var,,} is only as good as the locale it runs in. Switch the same shell to the C locale and the accented capitals stop converting:
$ LC_ALL=C
$ strtolower "ÉCOLE ÖSTERREICH"
École ÖsterreichThis matters most for scripts started by cron, systemd units or env -i, which often have no LANG at all. If you rely on accented letters being lowered, set a UTF-8 locale explicitly in the script. If you would rather the result never depend on the environment, set LC_ALL=C on purpose and accept ASCII-only conversion.
PHP went the other way. Since PHP 8.2, strtolower() is locale-insensitive and only converts ASCII letters. mb_strtolower() is the Unicode-aware one. So when porting code, Bash in a UTF-8 locale behaves more like mb_strtolower(), and Bash in the C locale behaves like modern PHP's strtolower().
Pitfalls
One character in, one character out. Bash maps each character to exactly one other character and looks at nothing around it. A few real-world rules do not fit that model:
$ strtolower "STRASSE ẞ"
strasse ß
$ strtolower "ΣΑΣ"
σασ
$ strtolower "İSTANBUL"
istanbulGreek capital sigma becomes σ even at the end of a word, where correct spelling uses the final form ς. The Turkish dotted capital İ becomes a plain "i" here. The Turkish rules proper (I to dotless ı, İ to i) come from the tr_TR locale's own tables in glibc. That locale is not installed on the machine I tested on, so I have not shown its output. The practical point holds either way: lowercasing is not language-neutral. If you lowercase identifiers such as hostnames, usernames or config keys, set LC_ALL=C around the conversion so a user's locale cannot change the result.
The unquoted echo. This is the bug in my 2009 version, and it is common in snippets online. echo ${1,,} without quotes squeezes runs of spaces, expands globs against the current directory, and drops strings that look like echo options. Here is the original next to the new one, run in a directory containing notes.txt and readme.md. First the 2009 function:
$ strtolower "HELLO WORLD"
hello world
$ strtolower "FILES: *"
files: notes.txt readme.md
$ strtolower "-N"And the function from this post:
$ strtolower "HELLO WORLD"
hello world
$ strtolower "FILES: *"
files: *
$ strtolower "-N"
-nThe old -N case printed nothing at all, because -n is an option to echo. The quoted printf fixes all three.
Alternatives
Inline expansion. On Bash 4+, skip the function and write "${answer,,}" directly. It avoids the subshell that $(strtolower ...) costs.
declare -l. Bash 4.0 also lets you give a variable the lowercase attribute. Every value assigned to it, including appends and read, is lowered automatically:
$ declare -l user
$ user='ROOT'
$ user+='@LOCALHOST'
$ printf '%s\n' "$user"
root@localhostHandy for a variable that must always be normalised, such as a hostname read from user input. Inside a function, declare makes the variable local, just as local -l would.
External tools. For a pipe or a whole file, use a tool. But note which ones understand UTF-8:
$ printf '%s\n' 'ÉCOLE Österreich' | tr '[:upper:]' '[:lower:]'
École Österreich
$ printf '%s\n' 'ÉCOLE Österreich' | awk '{ print tolower($0) }'
école österreich
$ printf '%s\n' 'ÉCOLE Österreich' | sed 's/.*/\L&/'
école österreichGNU tr (coreutils 8.30 here) works on bytes, so it only lowers ASCII even in a UTF-8 locale. GNU awk and GNU sed handled the accented capitals. \L is a GNU sed extension, so it will not work with the BSD sed on macOS. On a Bash 3 system that needs Unicode-aware lowercasing, piping through awk is the simplest choice.
Case-insensitive comparison. If you only lowercase in order to compare, you may not need to convert at all. shopt -s nocasematch makes [[ == ]] and case ignore case:
$ answer='YES'
$ shopt -s nocasematch
$ [[ $answer == yes ]] && echo 'matched'
matched
$ shopt -u nocasematchTurn it off again afterwards. It is a global setting and will change the result of any later comparison in the script.
History of this snippet
The first version, from April 2009, had only the 26-letter loop, built from two arrays. In August 2009 I added the Bash 4 branch when ${var,,} arrived. Both branches printed with an unquoted echo, which caused the whitespace, glob and -n problems shown above. This rewrite quotes everything, uses printf, simplifies the fallback to two strings, and returns 2 on bad usage.
Related functions
- strtoupper(): the opposite direction, with pattern-limited uppercasing
- lcfirst(): lowercase only the first character
- ucwords(): title-case each word, often after lowercasing
- stristr(): case-insensitive substring search
- strlen(): string length, and another place where the locale matters