Regular Expressions in Bash

Chapter 4 — Character Classes and POSIX Classes

What Is a Character Class?

A character class is a set of characters enclosed in square brackets [...]. The class matches any single character from that set. Where the dot . matches any character, a character class matches one specific character chosen from a defined set — giving you precise control over what is acceptable at that position.

Anatomy of a character class
[aeiou] → matches any one vowel: a, e, i, o, or u
[a-z] → matches any one lowercase letter (a range)
[0-9] → matches any one digit
[a-zA-Z0-9] → matches any letter or digit (multiple ranges)
[^aeiou] → negated class — matches any one character that is NOT a vowel
[abc-z] → matches a, b, c, or any letter from c to z

A character class always matches exactly one character. To match multiple characters you combine it with a quantifier (covered in Chapter 5): [0-9]+ matches one or more digits.

Simple Character Classes

# Match any vowel
echo "hello world" | grep -oE '[aeiou]'
e
o
o

# Match "grey" or "gray"
echo -e "grey\ngray\ngruy" | grep 'gr[ae]y'
grey
gray

# Match any single hex digit
grep -oE '[0-9A-Fa-f]' <<< "colour #ff3a2b"
f
f
3
a
2
b

# Match a bracket character (open or close)
grep '[()]' source.c     # any line with a parenthesis

# Match "colour" or "color" (British vs American spelling)
grep 'colo[u]r\|colour' file.txt
# simpler: u is optional so [u] works but see Chapter 5 for ? quantifier

Ranges Inside Character Classes

A hyphen - between two characters inside a class defines a range of characters based on their ASCII (or locale) order. Common ranges:

RangeMatchesASCII positions
[0-9]Any digit48–57
[a-z]Lowercase letters97–122
[A-Z]Uppercase letters65–90
[a-zA-Z]Any letter65–90, 97–122
[a-zA-Z0-9]Letter or digitCombined
[a-f]Hex lowercase letters a–f97–102
[!-~]All printable ASCII (excl. space)33–126
# Match a word made of only lowercase letters
grep -E '^[a-z]+$' words.txt

# Match a 3-letter uppercase acronym
grep -E '\b[A-Z]{3}\b' text.txt

# Match a hexadecimal colour code like #3fa2c0
grep -E '#[0-9A-Fa-f]{6}' styles.css

# Match a line containing only digits and hyphens (like a date 2026-06-11)
grep -E '^[0-9-]+$' data.txt
Range behaviour depends on the locale. In a UTF-8 locale, [a-z] may include accented characters between a and z in the collation order, giving unexpected results. For reliable ASCII-only matching use LC_ALL=C or switch to POSIX named classes like [[:lower:]] which always behave predictably regardless of locale.

Negated Classes — [^...]

When ^ is the first character inside a class, it negates the class — matching any single character that is not in the set. This is one of the most useful patterns in regex because it lets you match "everything up to a delimiter".

# Match any character that is NOT a digit
grep -oE '[^0-9]' <<< "abc123def"
a
b
c
d
e
f

# Extract text before the first colon on each line
grep -oE '^[^:]+' /etc/passwd   # matches from start up to (not including) first :

# The [^delimiter]* trick — match up to the first occurrence of a char
# (the lazy-quantifier workaround from Chapter 10 of the SED course)
echo "<b>bold</b> and <i>italic</i>" | grep -oE '<[^>]*>'
<b>
</b>
<i>
</i>

# Strip all non-alphanumeric characters
sed 's/[^a-zA-Z0-9]//g' <<< "hello, world! 2026"
helloworld2026

# Match a quoted string (no embedded quotes)
grep -oE '"[^"]+"' data.json   # " then one or more non-quote chars then "
Pattern [^,]+ on "Alice,30,London" Alice ✓ (chars before first comma) 30 ✓ (chars between commas) London ✓ (chars after last comma) Each field is "one or more characters that are not a comma"

Special Characters Inside Classes

Most regex metacharacters lose their special meaning inside [...] and become literals. But a few characters need care:

CharacterInside [...]How to include it literally
]Closes the classPut it first: []abc]
-Range operator between two charsPut it first or last: [-abc] or [abc-]
^Negation if it is the first charPut it anywhere except first: [a^b]
\Escape character (GNU tools)\\
.Literal dot — not any-char hereJust write . normally
*Literal asterisk — not quantifier hereJust write * normally
# Include a literal ] by placing it first
grep '[]abc]' file.txt       # matches ], a, b, or c

# Include a literal - by placing it last
grep '[a-z0-9-]' file.txt    # matches lowercase, digit, or hyphen
# NOT [a-z0-9] followed by a range — - at the end is always literal

# Include a literal . and * (they are NOT special inside [])
grep '[.*]' file.txt          # matches . or * — both literal inside []

# Match a URL slug: lowercase, digits, hyphens only
grep -E '^[a-z0-9-]+$' slugs.txt

# Match a line containing [ or ] (the bracket chars themselves)
grep '[][]]' file.txt         # ] first (to be literal), then [ (normal literal)

POSIX Named Character Classes

POSIX defines a set of named classes written as [:name:] inside a character class bracket: [[:name:]]. They are more portable than raw ranges like [a-z] because they correctly respect the current locale and character encoding.

[[:alpha:]] ≈ [a-zA-Z] Any alphabetic letter (locale-aware)
[[:digit:]] = [0-9] Any decimal digit 0–9
[[:alnum:]] ≈ [a-zA-Z0-9] Any letter or digit
[[:upper:]] ≈ [A-Z] Uppercase letters
[[:lower:]] ≈ [a-z] Lowercase letters
[[:space:]] [ \t\n\r\f\v] Any whitespace character
[[:blank:]] [ \t] Space or tab only (not newline)
[[:punct:]] (all punctuation) Any punctuation character
[[:print:]] (printable + space) Any printable character incl. space
[[:graph:]] (printable - space) Printable, excluding space
[[:cntrl:]] ASCII 0–31, 127 Control characters
[[:xdigit:]] [0-9A-Fa-f] Hexadecimal digits
# Trim leading whitespace (any whitespace, not just spaces)
sed 's/^[[:space:]]*//' file.txt

# Remove all punctuation from a line
sed 's/[[:punct:]]//g' text.txt

# Keep only alphanumeric characters and spaces
sed 's/[^[:alnum:][:space:]]//g' text.txt

# Match lines that start with an uppercase letter
grep '^[[:upper:]]' sentences.txt

# Find lines containing control characters (e.g. stray \r from Windows)
grep -P '[[:cntrl:]]' file.txt

# Remove Windows carriage returns (\r) at end of lines
sed 's/[[:cntrl:]]//g' file.txt

# Match a hex colour: # followed by exactly 6 hex digits
grep -E '#[[:xdigit:]]{6}' styles.css

# Combining POSIX classes: letter OR digit OR underscore (like \w)
grep -E '[[:alnum:]_]+' identifiers.txt

POSIX classes can be combined and negated

# Match anything that is NOT a letter or digit
grep -oE '[^[:alnum:]]' <<< "hello, world!"
,
 
!

# Combine multiple POSIX classes in one set
# Match letter, digit, underscore, or hyphen (valid identifier + hyphen)
grep -E '^[[:alnum:]_-]+$' slugs.txt

# Match a line with at least one uppercase AND contains a digit
# (two separate greps piped — one class can't express AND logic)
grep '[[:upper:]]' file.txt | grep '[[:digit:]]'

GNU Shorthand Classes

GNU tools (grep, sed, awk on Linux) support shorthand escape sequences as convenient aliases for common POSIX classes. These are not available in POSIX BRE/ERE or on BSD/macOS tools.

\w \W Word char
[[:alnum:]_]
\d \D Digit
[[:digit:]]
GNU sed 4.9+
\s \S Whitespace
[[:space:]]
\h \H Horiz. space
[[:blank:]]
PCRE/grep -P
# \w — word character [a-zA-Z0-9_]  (GNU grep, GNU sed)
grep -oE '\w+' <<< "hello_world 2026"
hello_world
2026

# \W — non-word character
grep -oE '\W+' <<< "hello, world!"
, 
!

# \s — whitespace (GNU grep -E)
grep -E '\s{2,}' file.txt     # lines with 2+ consecutive whitespace chars

# Collapse multiple spaces to one (GNU sed)
sed 's/\s\+/ /g' file.txt

# In [[ =~ ]] — \w works in Bash ERE
[[ "hello_123" =~ ^\w+$ ]] && echo "valid identifier"

Locale and Portability

Pattern GNU/Linux (UTF-8) C/POSIX locale macOS (BSD) Portable?
[a-z] May include accented letters a–z ASCII only Locale-dependent No
[[:lower:]] Lowercase for locale a–z ASCII Correct Yes
\w GNU tools GNU tools Not in BSD grep/sed No
[[:alnum:]_] All tools All tools All tools Yes
[0-9] Always digits only Always digits Always digits Yes (digits are safe)
# Force C locale for predictable ASCII range behaviour
LC_ALL=C grep '[a-z]' file.txt

# Portable alternative: POSIX class (no LC_ALL needed)
grep '[[:lower:]]' file.txt

Practical Recipes

Validate common input formats

# Is this a valid username? (letters, digits, underscore, 3-16 chars)
[[ "$username" =~ ^[[:alnum:]_]{3,16}$ ]] && echo "valid"

# Is this a valid hex colour? (#rgb or #rrggbb)
[[ "$col" =~ ^#([[:xdigit:]]{3}|[[:xdigit:]]{6})$ ]] && echo "valid colour"

# Is this a MAC address? (xx:xx:xx:xx:xx:xx)
[[ "$mac" =~ ^([[:xdigit:]]{2}:){5}[[:xdigit:]]{2}$ ]] && echo "valid MAC"

# Does the password contain at least one uppercase, one digit?
[[ "$pw" =~ [[:upper:]] ]] && [[ "$pw" =~ [[:digit:]] ]] && echo "strong enough"

Text cleaning pipelines

# Remove all non-printable characters from a file
sed 's/[^[:print:]]//g' dirty.txt

# Strip all digits from a string
sed 's/[[:digit:]]//g' <<< "order123 total456"
order total

# Extract only the alphabetic words from a line
grep -oE '[[:alpha:]]+' <<< "error: code 42 at line 7"
error
code
at
line

# Convert to lowercase using tr (not regex, but pairs well)
echo "HELLO WORLD" | tr '[[:upper:]]' '[[:lower:]]'
hello world

# Find lines containing tabs (useful to spot mixed indentation)
grep -P '\t' source.py          # PCRE tab
grep '[[:blank:]]' source.py    # space OR tab — broader

Quick Reference — Chapter 4

Character Class Syntax

[abc] Match one character: a, b, or c
[a-z] Match one character in the range a to z
[^abc] Match any one character that is NOT a, b, or c
[a-zA-Z0-9] Multiple ranges combined in one class
[]abc] Literal ] — put it first inside the class
[abc-] Literal - — put it first or last

Essential POSIX Classes

[[:alpha:]] [[:digit:]] Letters / digits — locale-aware and portable
[[:alnum:]] [[:space:]] Letters+digits / any whitespace character
[[:upper:]] [[:lower:]] Upper / lowercase letters
[[:blank:]] [[:punct:]] Space+tab only / punctuation characters
[[:xdigit:]] [[:print:]] Hex digits / all printable characters
[^[:alpha:]] Negated POSIX class — not a letter

GNU Shorthand (Linux only)

\w / \W Word char [[:alnum:]_] / non-word char
\s / \S Whitespace [[:space:]] / non-whitespace
\d / \D Digit [[:digit:]] / non-digit (GNU sed 4.9+, grep -P)
What is coming next: Chapter 5 covers quantifiers — how to specify how many times a character or class must appear: * (zero or more), + (one or more), ? (optional), and {n,m} (exact counts). Combining quantifiers with character classes is where regex becomes truly powerful.