grep, sed, awk, Python, bash and PCRE: dialects, patterns, traps and tested recipes
▶Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Regular Expressions Cheat Sheet: grep, sed, awk, Python, bash and PCRE
grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' file
A regular expression (regex) is a short pattern that describes a set of strings: "a number, then a dot, then letters". You use it to find lines, pick pieces out of text, and rewrite text. The same idea is built into grep, sed, awk, Python, JavaScript, vim, nginx, find and almost every language, but each tool speaks a slightly different DIALECT, and that is where most confusion starts.
Every example below was run (grep 3.11, sed 4.9, mawk 1.3.4, bash 5.2 and Python 3.12 on Ubuntu 24.04) and the output shown is real. The lines marked with "$ " are what you type.
Which dialect am I using?
Tool or command Dialect Notes
grep 'pattern' BRE basic: + ? | ( ) { } are LITERAL unless you put a backslash in front
grep -E / egrep ERE extended: + ? | ( ) { } are special (this is what most people expect)
grep -P PCRE Perl-style: \d \w \s, lazy .*?, lookahead/lookbehind, \K (GNU grep only; not on macOS/BSD grep)
sed 's/x/y/' BRE sed -E (or -r) switches to ERE
awk ERE mawk (Ubuntu default) and gawk differ in small ways, see the awk notes below
find -regex emacs-style by default; -regextype posix-extended for ERE; the pattern must match the WHOLE path
bash [[ $x =~ re ]] ERE the pattern goes unquoted on the right
Python re, JavaScript Perl-like \d \w \s, lazy quantifiers, lookarounds, named groups
vim its own \v at the start of a pattern means "very magic" (close to ERE)
The same pattern can mean different things (or fail) between dialects. A quick demonstration, with the pattern a+b meaning "one or more a, then b":
$ echo 'aaab' | grep -o 'a+b' || echo "BRE: no match, because + is a literal plus sign"
BRE: no match, because + is a literal plus sign
$ echo 'aaab' | grep -oE 'a+b'
aaab
$ echo 'a+b' | grep -o 'a+b'
a+b
$ echo 'aaab' | grep -o 'a\+b'
aaab
$ echo 'aaab' | grep -oP 'a+b'
aaab
In BRE, \+ \? \| \( \) \{ \} (with a backslash) are the special forms (a GNU extension). When in doubt, use grep -E, or grep -P when you need \d, \w, lazy matching or lookarounds.
Always put the pattern in SINGLE quotes so the shell does not touch it: grep -E '\d+' (not "\d+").
The building blocks
The basic pieces (ERE flavor, used with grep -E, sed -E, awk, bash =~)
. any single character (except a newline)
[abc] one of a, b or c [a-z] a range [^abc] anything EXCEPT a, b, c
^ $ start of the line / end of the line
* + ? zero or more / one or more / zero or one of the previous item
{3} {2,5} exactly 3 / between 2 and 5 of the previous item ({2,} means 2 or more)
( ) group (and capture) a|b either a or b
\ turn off a special meaning: \. is a literal dot, \$ a literal dollar
$ grep -E '^ca.t$' sample.txt
cart
caat
$ grep -E '^ca+t$' sample.txt
cat
caat
$ grep -E '^ca?t$' sample.txt
cat
ct
$ grep -E '^ca*t$' sample.txt
cat
caat
ct
$ grep -E '^c(a|ar)t$' sample.txt
cat
cart
$ grep -E '^[a-c]{2,3}t$' sample.txt
cat
caat
$ grep -E '[0-9]{4}-[0-9]{2}-[0-9]{2}' sample.txt
2026-03-08 ERROR disk full
$ grep -oE '[A-Z]+' sample.txt
T
ERROR
GET
-o prints only the matching part (one per line), which makes grep a great extraction tool; -n adds line numbers; -c counts matching lines; -v inverts; -i ignores case; -w matches whole words; -x requires the whole line to match.
$ grep -c . sample.txt; grep -c '^$' sample.txt || true
7
0
$ grep -owE 'cat|mat' sample.txt
cat
cat
mat
Character classes for readable patterns (POSIX, work in every dialect): put them INSIDE brackets
$ echo 'Port 8080 open, user=alice_7!' | grep -oE '[[:digit:]]+'
8080
7
$ echo 'Port 8080 open, user=alice_7!' | grep -oE '[[:alpha:]]+'
Port
open
user
alice
$ echo 'Port 8080 open, user=alice_7!' | grep -oE '[[:punct:]]'
,
=
_
!
$ printf 'a b\tc\n' | grep -cE '[[:space:]]'
1
[[:alpha:]] letters, [[:digit:]] digits, [[:alnum:]] both, [[:upper:]] [[:lower:]], [[:space:]] spaces tabs newlines, [[:punct:]] punctuation, [[:xdigit:]] hex digits. In PCRE and Python the short forms \d \w \s (and \D \W \S for the opposites) do the same.
To put special characters INSIDE a bracket expression: ] first, - last, ^ not first: [^]a-] means "anything except ], a or -". Most metacharacters (. * + ?) lose their special meaning inside [ ].
Greedy and lazy: the surprise that bites everyone
Quantifiers are GREEDY: they take as much as they can, then give back only if the rest of the pattern needs it.
$ echo '<b>bold</b> and <i>italic</i>' | grep -oE '<.*>'
<b>bold</b> and <i>italic</i>
$ echo '<b>bold</b> and <i>italic</i>' | grep -oP '<.*?>'
<b>
</b>
<i>
</i>
$ echo '<b>bold</b> and <i>italic</i>' | grep -oE '<[^>]*>'
<b>
</b>
<i>
</i>
$ echo 'name="alice" role="admin"' | grep -oP '="\K[^"]+'
alice
admin
The first command is greedy: it swallows from the first < to the LAST >. The second uses the lazy form .*? (PCRE, Python, JavaScript only). The third gets the same result in plain ERE by saying "anything but >" ([^>]*): this is the portable trick and it is usually faster too. The fourth uses \K ("forget what was matched so far") to print only the text after =", without the =" itself.
Anchors, word boundaries and whole-line matches
$ printf 'cat\nconcatenate\nthe cat sat\ncat5\n' | grep 'cat'
cat
concatenate
the cat sat
cat5
$ printf 'cat\nconcatenate\nthe cat sat\ncat5\n' | grep -w 'cat'
cat
the cat sat
$ printf 'cat\nconcatenate\nthe cat sat\ncat5\n' | grep -E '\bcat\b'
cat
the cat sat
$ printf 'cat\nconcatenate\nthe cat sat\ncat5\n' | grep -E '^cat$'
cat
$ printf 'cat\nconcatenate\nthe cat sat\ncat5\n' | grep -x 'cat'
cat
$ printf 'one\n\ntwo\n \n' | grep -n '^[[:space:]]*$'
2:
4:
$ echo 'foo.bar' | sed -E 's/\<./\U&/g'
Foo.Bar
\b is a word boundary (between a word character and a non-word character); GNU also offers \< (start of a word) and \> (end of a word). -w and -x are the quick versions. An unanchored pattern matches ANYWHERE inside a line, which is why "cat" also found "concatenate".
Match a whole line, or nothing: anchor BOTH ends. A pattern for a port number that is not anchored will happily accept 80a:
$ printf '80\n80a\n443\n99999\n' | grep -E '^[0-9]{1,5}$'
80
443
99999
Groups, alternation and back-references
$ echo 'the the quick brown fox fox jumps' | grep -oE '\b([a-z]+) \1\b'
the the
fox fox
$ echo 'John Smith' | sed -E 's/(\w+) (\w+)/\2, \1/'
Smith, John
$ echo '2026-03-08' | sed -E 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\3.\2.\1/'
08.03.2026
$ echo 'error: disk full' | sed -E 's/(error|warn): (.*)/[\U\1\E] \2/'
[ERROR] disk full
$ echo 'abcabc' | grep -E '^(abc){2}$'
abcabc
$ echo 'color colour' | grep -oE 'colou?r'
color
colour
A parenthesised group captures what it matched; \1 \2 ... refer back to it, in the pattern itself (repeated words) or in the replacement of sed. (?:...) is a NON-capturing group in PCRE/Python. & in a sed replacement means "the whole match". \U ... \E upper-cases the text in between (GNU sed).
Named groups (Python, PCRE): more readable than \1 and \2
$ python3 - <<'PY'
> import re
> m = re.search(r'(?P<ip>\d+\.\d+\.\d+\.\d+) (?P<method>[A-Z]+) (?P<path>\S+)', '192.168.1.20 GET /index.html')
> print(m.group('ip'), m.group('method'), m.group('path'))
> print(m.groupdict())
> PY
192.168.1.20 GET /index.html
{'ip': '192.168.1.20', 'method': 'GET', 'path': '/index.html'}
Lookahead and lookbehind (PCRE and Python)
These match a position, not text: "followed by", "not followed by", "preceded by". The text inside the lookaround is NOT part of the match.
$ echo 'price: $42, cost: $7' | grep -oP '(?<=\$)\d+'
42
7
$ echo 'foo.txt foo.log foo.txt.bak' | grep -oP '\S+\.txt(?!\.)'
foo.txt
$ echo 'secret123 password456 token789' | grep -oP '\w+(?=\d{3})' | head -3
secret
password
token
$ echo '10.0.0.1 and 999.1.1.1' | grep -oP '\b(?!999)\d+\.\d+\.\d+\.\d+\b'
10.0.0.1
(?=x) followed by x, (?!x) NOT followed by x, (?<=x) preceded by x, (?<!x) NOT preceded by x. Lookbehind needs a fixed length in most engines. \K is the shortcut for "throw away everything matched so far": grep -oP 'user=\K\w+'.
Patterns you will actually use (tested)
Do not trust a pattern you copied from the internet, including these, until it has passed a few strings it should accept AND a few it must reject. This is a tiny test harness: each row is a pattern and strings that should or should not match.
$ python3 - <<'PY'
> import re
> tests = {
> "IPv4 (loose)": (r'^\d{1,3}(\.\d{1,3}){3}$', ["10.0.0.1", "192.168.1.20", "255.255.255.255"], ["10.0.0", "1.2.3.4.5", "a.b.c.d"]),
> "IPv4 (strict)": (r'^((25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(25[0-5]|2[0-4]\d|1?\d?\d)$', ["10.0.0.1", "255.255.255.255", "0.0.0.0"], ["256.1.1.1", "1.2.3", "01.2.3.999"]),
> "MAC address": (r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', ["00:1A:2b:3C:4d:5E"], ["00:1A:2b:3C:4d", "GG:1A:2b:3C:4d:5E"]),
> "ISO date": (r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$', ["2026-03-08", "1999-12-31"], ["2026-13-01", "2026-3-8", "2026-00-10"]),
> "Time HH:MM:SS": (r'^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$', ["00:00:00", "23:59:59"], ["24:00:00", "12:60:00"]),
> "Port 1-65535": (r'^([1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$', ["1", "80", "65535"], ["0", "65536", "08080"]),
> "UUID": (r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', ["123e4567-e89b-12d3-a456-426614174000"], ["123e4567-e89b-12d3-a456-42661417400"]),
> "Semantic version": (r'^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$', ["1.2.3", "10.0.1-rc.1"], ["1.2", "v1.2.3"]),
> "Hex colour": (r'^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$', ["#fff", "#1a2B3c"], ["#ffff", "fff", "#ggg"]),
> "Simple email": (r'^[^@\s]+@[^@\s]+\.[^@\s]+$', ["a@b.co", "first.last+tag@example.org"], ["a@b", "a b@c.com", "@x.com"]),
> "Hostname": (r'^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$', ["web01", "a.example.com"], ["-bad.com", "bad-.com", "a..b"]),
> }
> for name, (pat, good, bad) in tests.items():
> ok = all(re.search(pat, s) for s in good) and not any(re.search(pat, s) for s in bad)
> print("%-17s %s accepts %d, rejects %d" % (name, "PASS" if ok else "FAIL", len(good), len(bad)))
> PY
IPv4 (loose) PASS accepts 3, rejects 3
IPv4 (strict) PASS accepts 3, rejects 3
MAC address PASS accepts 1, rejects 2
ISO date PASS accepts 2, rejects 3
Time HH:MM:SS PASS accepts 2, rejects 2
Port 1-65535 PASS accepts 3, rejects 3
UUID PASS accepts 1, rejects 1
Semantic version PASS accepts 2, rejects 2
Hex colour PASS accepts 2, rejects 3
Simple email PASS accepts 2, rejects 3
Hostname PASS accepts 2, rejects 3
These patterns use \d, which is PCRE/Python syntax. For grep -E replace \d with [0-9] and use [[:alnum:]] for \w. The "simple email" pattern deliberately accepts almost anything with an @ and a dot: real email addresses are far too complicated for a regex, so validate by sending a message. The same is true for URLs and HTML.
The same ones in plain ERE for grep and sed
$ printf '10.0.0.1\n999.1.1.1\nabc\n' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$'
10.0.0.1
999.1.1.1
Look closely: the loose IPv4 pattern let 999.1.1.1 through. "Looks like an IP address" and "is a valid IP address" are different questions: use the strict pattern from the test table (or a real parser such as python3 -c 'import ipaddress...') when it matters.
$ printf '2026-03-08 ERROR x\nbad 2026-3-8\n' | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
2026-03-08 ERROR x
$ echo 'see https://example.com/a/b?x=1 and http://test.org' | grep -oE 'https?://[^ ]+'
https://example.com/a/b?x=1
http://test.org
$ echo 'contact: ann@example.com, bob@test.org' | grep -oE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'
ann@example.com
bob@test.org
$ echo 'ID=0042 id=17' | grep -oiE 'id=[0-9]+'
ID=0042
id=17
Search and replace with sed
$ echo 'hello world' | sed 's/world/there/'
hello there
$ echo 'a-b-c' | sed 's/-/+/'
a+b-c
$ echo 'a-b-c' | sed 's/-/+/g'
a+b+c
$ echo 'a-b-c' | sed 's/-/+/2'
a-b+c
$ echo ' padded ' | sed -E 's/^[[:space:]]+|[[:space:]]+$//g' | cat -A
padded$
$ echo 'too many spaces' | sed -E 's/ +/ /g'
too many spaces
$ echo '1234567' | sed -E ':a; s/([0-9])([0-9]{3})($|,)/\1,\2\3/; ta'
1,234,567
$ echo 'path/to/file' | sed 's|/|\\|g'
path\to\file
$ echo 'key = value # comment' | sed -E 's/[[:space:]]*#.*$//'
key = value
$ printf 'one\ntwo\nthree\n' | sed -n '/two/,$p'
two
three
$ echo 'version 1.2.3' | sed -E 's/[0-9]+\.[0-9]+\.[0-9]+/X.Y.Z/'
version X.Y.Z
Use a different delimiter (| or #) when the text contains slashes. Add g to replace every match on the line, a number to replace only the nth. sed -i edits files in place: always keep a backup with sed -i.bak until you trust the pattern. The case-conversion forms \U \L \u \l and the & shortcut are GNU sed features.
Rename with a pattern (preview with echo first)
$ for f in report_2026-03-08.txt report_2026-03-09.txt; do echo "mv $f ${f/report_/archive-}"; done
mv report_2026-03-08.txt archive-2026-03-08.txt
mv report_2026-03-09.txt archive-2026-03-09.txt
$ echo 'getUserNameById' | sed -E 's/([A-Z])/_\L\1/g'
get_user_name_by_id
awk and bash: matching inside programs
$ printf 'alice 200\nbob 404\ncarol 200\n' | awk '$2 ~ /^2/ {print $1}'
alice
carol
$ printf 'alice 200\nbob 404\ncarol 200\n' | awk '$1 !~ /^a/ {print $1}'
bob
carol
$ echo 'user=alice id=42' | awk '{ if (match($0, /id=[0-9]+/)) print substr($0, RSTART + 3, RLENGTH - 3) }'
42
$ echo 'a1b22c333' | awk '{ n = gsub(/[0-9]+/, "#"); print n " replacements: " $0 }'
3 replacements: a#b#c#
$ awk 'BEGIN { print ("abc123" ~ /[0-9]{3}$/) }'
1
awk matches a regular expression against a field with ~ and !~ (the pattern between slashes). match() sets RSTART and RLENGTH so substr() can cut the match out; gsub() replaces. Differences between awk versions: gawk adds \y (word boundary) and capture arrays; mawk (the Ubuntu default) does not. If a pattern behaves oddly, check awk --version.
bash: the =~ operator and BASH_REMATCH
$ s='backup-2026-03-08.tar.gz'; if [[ $s =~ ^backup-([0-9]{4})-([0-9]{2})-([0-9]{2})\.tar\.gz$ ]]; then echo "year=${BASH_REMATCH[1]} month=${BASH_REMATCH[2]} day=${BASH_REMATCH[3]}"; fi
year=2026 month=03 day=08
$ re='^[0-9]+$'; for v in 42 4x2 ''; do [[ $v =~ $re ]] && echo "'$v' is a number" || echo "'$v' is NOT a number"; done
'42' is a number
'4x2' is NOT a number
'' is NOT a number
Keep the pattern in a variable (re=...) and use it UNQUOTED on the right of =~: quoting it turns it into a plain text comparison. The whole match is ${BASH_REMATCH[0]}, groups follow.
find matches the WHOLE path
$ find logs -regextype posix-extended -regex '.*/app-[0-9]{2}\.log' | sort
logs/2026/app-01.log
logs/2026/app-02.log
$ find logs -name 'app-*.log' | sort
logs/2026/app-01.log
logs/2026/app-02.log
For simple cases a glob (find -name) is easier. -regex is anchored to the full path, which is why it starts with .*/
Python (and JavaScript) quick reference
$ python3 - <<'PY'
> import re
> text = "2026-03-08 ERROR disk full; 2026-03-09 WARN slow query; 2026-03-10 ERROR timeout"
> print(re.findall(r'\d{4}-\d{2}-\d{2}', text))
> print(re.findall(r'(\d{4}-\d{2}-\d{2}) (ERROR|WARN)', text))
> print(re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', "2026-03-08"))
> print(re.split(r'[;,]\s*', "a; b,c; d"))
> pat = re.compile(r"""
> (?P<year>\d{4}) - (?P<month>\d{2}) - (?P<day>\d{2}) # date
> \s+ (?P<level>[A-Z]+) # level
> """, re.VERBOSE)
> print(pat.match("2026-03-08 ERROR x").groupdict())
> print(re.fullmatch(r'\d+', '123x'), bool(re.fullmatch(r'\d+', '123')))
> print(re.findall(r'^\w+', "first line\nsecond line", re.MULTILINE))
> print(re.search(r'hello', 'Say HELLO', re.IGNORECASE).group())
> PY
['2026-03-08', '2026-03-09', '2026-03-10']
[('2026-03-08', 'ERROR'), ('2026-03-09', 'WARN'), ('2026-03-10', 'ERROR')]
08/03/2026
['a', 'b', 'c', 'd']
{'year': '2026', 'month': '03', 'day': '08', 'level': 'ERROR'}
None True
['first', 'second']
HELLO
Useful functions: re.search (first match anywhere), re.match (match at the START), re.fullmatch (the WHOLE string), re.findall (all matches), re.finditer (match objects), re.sub (replace), re.split. Flags: re.IGNORECASE, re.MULTILINE (^ and $ match at every line), re.DOTALL (. matches newlines too), re.VERBOSE (spaces and comments allowed: the way to keep a long pattern readable).
Always write patterns as raw strings, r'...', so Python does not process the backslashes first.
Escaping: what needs a backslash where
Characters that are special in a regex and must be escaped to match literally: . ^ $ * + ? ( ) [ ] { } | \
$ echo 'cost is $5.00 (approx) [est]' | grep -oF '$5.00 (approx) [est]'
$5.00 (approx) [est]
$ echo 'cost is $5.00 (approx)' | grep -oE '\$[0-9]+\.[0-9]{2}'
$5.00
$ echo 'a.b' | grep -c 'a.b'; echo 'axb' | grep -c 'a.b'; echo 'axb' | grep -c 'a\.b' || true
1
1
0
$ python3 -c 'import re; print(re.escape("a.b*c(d)"))'
a\.b\*c\(d\)
When the text you search for is a plain string, not a pattern, use grep -F (fixed string): it needs no escaping at all and is faster. In Python use re.escape() to turn user input into a safe literal. The backslash needs doubling in some contexts (JSON, Java, C strings: "\\d+"), which is why raw strings and single quotes help.
Special characters between the shell and the tool: single quotes protect everything. To include a single quote INSIDE a single-quoted pattern, close, add an escaped quote, and re-open: 'it'\''s'
Multi-line text
By default . does not match a newline and ^ $ work per line. To match across lines you need a tool and a flag that treat the whole input as one string.
$ printf 'start\nmiddle\nend\nother\n' | grep -Pzo 'start\n(.|\n)*?end' | tr '\0' '\n'
start
middle
end
$ printf 'a\nb\nc\n' | sed -z 's/\n/,/g'; echo
a,b,c,
$ printf 'a\nb\nc\n' | sed -n '/a/,/b/p'
a
b
$ printf 'x=1\ny=2\n' | python3 -c 'import re,sys; print(re.sub(r"=(\d)\n", r" is \1; ", sys.stdin.read()))'
x is 1; y is 2;
grep -z treats the whole input as one "line" (NUL-separated); sed -z does the same; a range like /start/,/end/ prints from one matching line to the next; in Python use re.DOTALL or re.MULTILINE. For anything structured (HTML, XML, JSON, YAML, CSV with quotes) use a real parser: xmllint, jq, python csv.
Performance and the traps
Backtracking engines (Python, PCRE, JavaScript, Java) can take forever on a pattern with nested quantifiers: this is "catastrophic backtracking" and it is a classic denial-of-service when the input comes from users. grep -E and awk use a different algorithm and are not affected.
$ timeout 2 python3 -c 'import re; re.match(r"(a+)+$", "a"*40 + "b")'; echo "exit status $? (124 means it was killed after 2 seconds)"
exit status 124 (124 means it was killed after 2 seconds)
$ echo "$(printf 'a%.0s' {1..40})b" | grep -cE '(a+)+$' || echo "grep -E answered at once: 0 matches (no catastrophic backtracking)"
0
grep -E answered at once: 0 matches (no catastrophic backtracking)
Rules of thumb: avoid a quantifier on a group that itself contains a quantifier ((a+)+, (.*)*), prefer [^x]* to .*? when you can name the stop character, anchor the pattern, and put a length limit on input that comes from outside. In Python 3.11+ use possessive quantifiers or atomic groups where they apply.
Other classic traps
$ printf 'aXb\nAxB\n' | grep -c 'x'
1
$ printf 'aXb\nAxB\n' | grep -ci 'x'
2
$ echo 'line' | grep -cE 'li|'
1
1. An empty alternative ("li|") matches everything. 2. Character ranges such as [a-z] can depend on the locale on older systems (they may include upper-case letters), so prefer [[:lower:]] in portable scripts, or set LC_ALL=C. 3. . does not match a newline, and in UTF-8 text it matches one CHARACTER, not one byte. 4. Greedy .* in the middle of a line matches to the LAST occurrence. 5. Forgetting the anchors: "^[0-9]+$" validates, "[0-9]+" only finds. 6. Unquoted patterns: the shell expands * and ? before the tool sees them (grep a*b in a folder with files). 7. grep without -E treating + ? | as plain characters.
How to build a regex without going mad
1. Write down 3 strings that SHOULD match and 3 that should NOT. 2. Start with the smallest pattern that matches something, and grow it one piece at a time. 3. Use grep -o (or Python findall) to SEE exactly what each version matches. 4. Anchor it. 5. Add the "should not match" strings to the test and keep them. 6. When it is longer than one line, use re.VERBOSE in Python and write comments. 7. Do not paste real logs, tokens or personal data into online regex testers.
$ for s in 10.0.0.1 10.0.0 a.b.c.d 1.2.3.4.5; do printf '%-10s ' "$s"; echo "$s" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$' && echo match || echo no; done
10.0.0.1 match
10.0.0 no
a.b.c.d no
1.2.3.4.5 no
Visual debugging: grep --color=always highlights the match in a pager: grep --color=always -E 'pattern' file | less -R
Quick reference
. any char [abc] one of [^abc] none of [a-z] range ^ start $ end
* 0 or more + 1 or more ? 0 or 1 {n,m} n to m a|b either (x) group
\d \w \s (PCRE/Python) digit, word char, space \D \W \S the opposites \b word boundary
[[:digit:]] [[:alpha:]] [[:alnum:]] [[:space:]] [[:upper:]] [[:lower:]] [[:punct:]] (everywhere)
.*? lazy (PCRE/Python) (?=x) (?!x) (?<=x) (?<!x) lookarounds \K forget so far (?:x) no capture (?P<n>x) named group
grep -E ERE grep -P PCRE grep -F plain string grep -o only the match grep -w whole word sed -E ERE sed 's/a/b/g'
Tips
Use grep -E by default; use -P only when you need \d \w, lazy quantifiers or lookarounds (and remember it is not on macOS).
Anchor patterns and test them with strings that must fail. Most regex bugs are false positives you never tried.
Prefer [^"]* ("anything but the closing quote") over .*? when you can: it is clearer, portable and faster.
A regex is the wrong tool for HTML, JSON, YAML, CSV with quoted fields, email address validity and balanced parentheses. Use a parser.
"Some people, when confronted with a problem, think: I know, I'll use regular expressions. Now they have two problems." Test both.