Bash

ucwords() in Bash: Capitalise Every Word in a String

By Nawawi JamiliPublished Updated 6 min read

PHP's ucwords() capitalises the first character of every word: "hello world" becomes "Hello World". Bash has nothing built in for it. Bash 4 can capitalise a single word with ${word^}, so the real work is deciding where each word starts, and doing that without breaking the spacing in between.

My 2009 version walked the string one character at a time and only knew about spaces. This rewrite works word by word, treats tabs and newlines as separators too, and keeps every bit of whitespace exactly where it was.

The function

bash
# ucwords -- uppercase the first character of each word in a string
# Usage: ucwords string
# Words are separated by any whitespace; the separators are kept as-is.
# Requires Bash 4+ for ${word^}.
ucwords() {
    [ "$#" -eq 1 ] || { printf 'usage: ucwords string\n' >&2; return 2; }
    local rest=$1 out='' gap word
    while [ -n "$rest" ]; do
        gap=${rest%%[![:space:]]*}     # leading whitespace (may be empty)
        rest=${rest#"$gap"}
        word=${rest%%[[:space:]]*}     # next run of non-whitespace
        rest=${rest#"$word"}
        out+=$gap${word^}
    done
    printf '%s\n' "$out"
}

How it works

The loop removes text from the front of the string, one gap and one word per pass. Everything is done with pattern removal, so no external commands and no subshells are involved.

  1. ${rest%%[![:space:]]*} deletes everything from the first non-whitespace character to the end. What remains is the whitespace at the front, which might be nothing at all.
  2. ${rest#"$gap"} removes that whitespace from the front. The quotes make the shell treat the gap as literal text rather than a pattern.
  3. ${rest%%[[:space:]]*} is the mirror image: everything up to the next whitespace character, which is the word.
  4. ${rest#"$word"} removes the word, and out+=$gap${word^} appends the untouched gap and the capitalised word to the result.

The loop stops when rest is empty. On a string with trailing whitespace, the last pass picks up the trailing gap and an empty word, which ${word^} leaves empty, so the trailing spaces survive too.

[[:space:]] matches space, tab, newline, carriage return, vertical tab and form feed. That is the same set PHP's ucwords() uses by default, so the word boundaries agree.

Usage

terminal
$ ucwords "hello world"
Hello World
$ ucwords "  two  spaces  between  "
  Two  Spaces  Between
$ ucwords $'name\temail\tphone'
Name	Email	Phone
$ ucwords $'first line\nsecond line'
First Line
Second Line
$ ucwords "élan vital"
Élan Vital
$ ucwords "hELLO wORLD"
HELLO WORLD
$ ucwords ""; echo "exit status: $?"

exit status: 0
$ ucwords; echo "exit status: $?"
usage: ucwords string
exit status: 2

The double spaces and the leading and trailing spaces are preserved, the tab-separated fields keep their tabs, and each line of a multi-line string gets its first word capitalised. Like PHP, ucwords only raises first letters. It never lowers anything, which is why "hELLO wORLD" comes out as "HELLO WORLD".

Title case from any input

For a heading or a name typed in capitals, lowercase first and then capitalise the words:

terminal
$ title='THE OLD MAN AND THE SEA'
$ ucwords "${title,,}"
The Old Man And The Sea

Note that "And" and "The" are capitalised too. Proper English title case leaves short words such as "and", "of" and "the" in lowercase unless they come first. That needs a word list and is a different function. ucwords is purely mechanical.

What counts as a word

This is the question that decides whether ucwords looks right on your data. With whitespace as the only boundary, apostrophes and hyphens are part of the word:

terminal
$ ucwords "o'neil jean-luc 3rd street"
O'neil Jean-luc 3rd Street

"Jean-luc" is wrong for a name, and "3rd" is unchanged because its first character is a digit. If your data is hyphenated names, add the hyphen to both bracket expressions. Here is that variant, with only the two pattern lines changed:

bash
        gap=${rest%%[![:space:]-]*}
        word=${rest%%[[:space:]-]*}
terminal
$ ucwords_hyphen "jean-luc  picard"
Jean-Luc  Picard

Keep the - as the last character inside the brackets so it is read literally and not as a range. PHP's ucwords() offers the same idea through an optional second argument listing the delimiters.

The opposite mistake is just as common. Regex word boundaries, as in GNU sed's \b, treat any non-alphanumeric character as a boundary, so the apostrophe splits words too:

terminal
$ s="o'neil jean-luc   mcdonald"
$ printf '%s\n' "$s" | sed 's/\b\(.\)/\u\1/g'
O'Neil Jean-Luc   Mcdonald

Good for "O'Neil", bad for "Don'T" or "It'S", which is what you get on ordinary English contractions. No rule gets "McDonald" right. Names like that need an exception list whatever tool you use.

Pitfalls

Splitting into an array loses the spacing. The tempting short version is to let read -a split the words and use the array form of the expansion:

terminal
$ read -ra words <<< "$s"
$ printf '%s\n' "${words[*]^}"
O'neil Jean-luc Mcdonald

The words are right, but the three spaces before "Mcdonald" became one, and tabs would become spaces. Fine for display, not fine when the whitespace is data, such as a fixed-width or tab-separated line. The loop above exists to avoid exactly this.

Bash 3. ${word^} needs Bash 4. On the macOS /bin/bash 3.2, load the ucfirst() fallback and change the append line to out+=$gap$(ucfirst "$word"). I tested that variant with the fallback on Bash 4.4. The output matched the table above except "élan vital", which became "élan Vital" because the fallback only handles a to z. It costs one subshell per word, so it is slower, but it is correct.

Locale. Both the capitalisation and [[:space:]] follow the current locale. In a UTF-8 locale "é" is capitalised. Under LC_ALL=C it is not. The strtolower() post covers that in detail.

Alternatives

For a stream or a file rather than a variable, GNU sed with an explicit whitespace boundary reproduces the function's behaviour:

terminal
$ printf '%s\n' "$s" | sed -E 's/(^|[[:space:]])([[:alpha:]])/\1\u\2/g'
O'neil Jean-luc   Mcdonald

It only capitalises a letter that follows whitespace or the start of a line, and it leaves the spacing alone. \u is a GNU sed extension. BSD sed on macOS does not support it, and there an awk script or a newer Bash is the practical route.

History of this snippet

The 2009 ucwords looped over every character, tracked a "previous was a space" flag, and called strtoupper() in a subshell for each first letter. It had three bugs. Only the space character counted as a separator, so tabs and newlines did not start a new word. An empty string made it return 1. And because strtoupper printed with an unquoted echo, a lone * in the input was glob-expanded. Run in a directory holding two files, the original gave:

terminal
$ ucwords $'tab\tseparated'
Tab	separated
$ ucwords "rating: * stars"
Rating: notes.txt readme.md Stars
$ ucwords ""; echo "exit status: $?"
exit status: 1

The new version fixes all three and is quicker as well. I timed a 1000-character string on one machine (Bash 4.4), over three runs each. It took between 0.009 s and 0.014 s with the new function and between 0.14 s and 0.16 s with the original. The difference is mostly the 200 subshells the old version started, one per word.