Regular expressions - Python re + bash grep/sed
Each section shows the concept in Python first, then the bash equivalent.
Literal matching & metacharacters
Most characters match themselves. A few are metacharacters and need
escaping with \ to match literally: . ^ $ \ * + ? [ ] ( ) { } |.
import re
re.search(r'core', 'fedora-coreos-config') # <Match 'core'> (anywhere)
re.match(r'core', 'fedora-coreos-config') # None (must match at start)
re.fullmatch(r'core', 'core') # <Match 'core'> (whole string)
Rule of thumb: use re.search() unless you need start-of-string or
full-string matching. Always use raw strings (r"...") for patterns so
backslash escapes like \b aren’t processed by Python first. re.escape(s)
escapes every metacharacter in a string.
In bash, grep has flavors:
Flag |
Flavor |
Use when |
|---|---|---|
(none) |
BRE |
simple literal matching |
|
ERE |
|
|
PCRE |
need |
|
fixed |
literal strings containing metacharacters |
Character classes
[...] matches one character from the set.
re.findall(r'[aeiou]', 'coreos') # ['o', 'e', 'o']
re.findall(r'[0-9]', 'rpm-4.19') # ['4', '1', '9']
# ranges & negation: [a-z] [A-Z] [0-9] [a-zA-Z0-9] [^0-9]
Inside [...]: - is a range (literal if first/last), ^ negates only
at the start, ] is literal if first.
Shorthand |
Equivalent |
Meaning |
|---|---|---|
|
|
digit / not digit |
|
|
word char / not |
|
|
whitespace / not |
ERE (grep -E) has no \d/\w - use POSIX classes ([[:digit:]],
[[:alpha:]], [[:alnum:]], [[:space:]]) or switch to grep -P.
Quantifiers
Quantifier |
Meaning |
|---|---|
|
0+ / 1+ / 0 or 1 |
|
exactly n / n or more / between n and m |
By default quantifiers are greedy (match as much as possible); add ? to
make them lazy:
text = '<b>bold</b> and <i>italic</i>'
re.findall(r'<.*>', text) # ['<b>bold</b> and <i>italic</i>'] (greedy)
re.findall(r'<.*?>', text) # ['<b>', '</b>', '<i>', '</i>'] (lazy)
re.findall(r'"([^"]*)"', 'a "x" b "y"') # ['x', 'y'] (often cleaner & faster)
In bash, -o prints only the match; ERE has no lazy quantifiers (use -P):
echo "rpm-4.19-3.el9" | grep -oE '[0-9]+\.[0-9]+' # 4.19
echo '<b>x</b>' | grep -oP '<.*?>' # <b> </b>
Groups and capturing
Parentheses group elements and capture the matched text.
m = re.search(r'(?P<major>\d+)\.(?P<minor>\d+)', 'rhel-9.8')
m.group(0) # '9.8' (entire match)
m.group('major') # '9'
m.groupdict() # {'major': '9', 'minor': '8'}
# findall returns group contents when groups exist:
re.findall(r'(\d+)\.(\d+)', 'rhel-9.8 rhel-10.2') # [('9','8'), ('10','2')]
re.findall(r'(?:rhel|fedora)-\d+', 'rhel-9 fedora-42') # full match (non-capturing)
re.search(r'<(\w+)>.*?</\1>', '<b>bold</b>') # backreference \1
In bash, sed -E captures with () and references with \1, \2:
echo "ignition-2.20.0-3.el9" | sed -E 's/.*-([0-9.]+)-.*/\1/' # 2.20.0
echo "Mabe, Dusty" | sed -E 's/([^,]+), (.*)/\2 \1/' # Dusty Mabe
Alternation and anchors
| means “or”. Anchors match a position, not a character: ^ (start),
$ (end), \b (word boundary), \B (not a boundary).
re.search(r'^rhel', 'rhel-9.8') # match
re.search(r'\.conf$', 'build-9.8.conf') # match
re.findall(r'\bcore\b', 'coreos core util') # ['core'] (whole word only)
For repeated use, compile once with re.compile(r'...') - it returns a
pattern object with the same methods and is faster in loops. In bash, grep -w
matches whole words.
Substitution
re.sub(r'\d+', 'X', 'rhel-9.8') # 'rhel-X.X'
re.sub(r'(\d+)\.(\d+)', r'\2.\1', 'rhel-9.8') # 'rhel-8.9' (swap groups)
re.sub(r'\d+', 'X', '1.2.3', count=1) # 'X.2.3'
re.sub(r'\d+', lambda m: str(int(m.group())*2), 'v4') # function replacement
re.split(r'[-.]', 'ignition-2.20.0') # ['ignition','2','20','0']
echo "rhel-9.8" | sed -E 's/[0-9]+/X/g' # rhel-X.X (all)
echo "rhel-9.8" | sed -E 's/([0-9]+)\.([0-9]+)/\2.\1/' # rhel-8.9
sed -i -E 's/old/new/g' file.txt # edit in place
Lookahead and lookbehind
Zero-width assertions - they check around a position without consuming it.
Syntax |
Meaning |
|---|---|
|
positive / negative lookahead (what follows) |
|
positive / negative lookbehind (what precedes) |
re.findall(r'\d+\.\d+(?=-el)', 'a-4.19-el9 b-3.2-fc42') # ['4.19']
re.findall(r'(?<=el)\d+', 'rhel-9.8 el10') # ['9', '10']
re.search(r'(?<=STREAM=)\w+', 'STREAM=stable').group() # 'stable'
In Python, lookbehind must be fixed-width (no */+/{n,m} inside).
ERE has no lookaround; use grep -P.
Multiline and flags
re.findall(r'rhel', 'RHEL rhel', re.IGNORECASE) # ['RHEL', 'rhel']
re.findall(r'^VERSION_ID=(.+)$', text, re.MULTILINE) # ^/$ at line bounds
re.search(r'<tag>(.+)</tag>', text, re.DOTALL) # . also matches \n
re.findall(r'^name=(.+)$', text, re.I | re.M) # combine with |
re.findall(r'(?i)rhel', 'RHEL') # inline flag
bash: grep -i is case-insensitive, and grep is line-oriented so ^/$
work per line already.
Common patterns
# RPM NVR (name can contain hyphens; version starts with a digit)
NVR = r'^(.+)-(\d[\d.]*\d)-(\d+\.\w+)$'
re.match(NVR, 'ignition-2.20.0-3.el9').groups() # ('ignition','2.20.0','3.el9')
# version string (optional patch)
re.search(r'(\d+)\.(\d+)(?:\.(\d+))?', 'OCP 4.22.0').groups() # ('4','22','0')
# IP address (doesn't validate 0-255)
re.findall(r'\b\d{1,3}(?:\.\d{1,3}){3}\b', 'host 192.168.1.1') # ['192.168.1.1']
# /etc/os-release line (handles quoted & unquoted values)
re.findall(r'^(\w+)=("?)(.+?)\2$', text, re.M)
Quick reference
|
Use |
|---|---|
|
find first / at start / whole string |
|
all matches (list / iterator of Match) |
|
replace / split on pattern |
|
compile for reuse / escape metacharacters |
Match object: m.group(n), m.groups(), m.groupdict(),
m.start()/m.end(), m.span().
grep flag |
Meaning |
|---|---|
|
ERE / PCRE / fixed string |
|
ignore case / only match / invert / whole word |
|
count / list files / line numbers / recursive / quiet |
sed: -E (extended), -i (in place), g (all occurrences in s///g),
I (case-insensitive).