You already know how Bash processes a line before launching it: it expands variables, substitutes commands, splits on spaces. This lesson adds the most powerful piece of that processing — globbing — and then something that looks very much like it and has nothing to do with it: regular expressions.
Confusing the two is the most widespread mistake among people who have been using the terminal for years. They look alike because both use * and ?, and those mean different things in each. The confusion is not academic: it produces commands that appear to work, return something plausible and are wrong. By the end of this lesson you will have the distinction fixed in your mind, you will be able to describe any set of files with a pattern and to build regular expressions that stand up to real cases over access.log and bookings.csv.
Contents
- Who expands what: the shell first, the program afterwards
- Globbing: the shell's wildcards
- POSIX classes and brace expansion
- Globbing options: globstar, dotglob, nullglob, failglob
- Regular expressions: BRE, ERE and PCRE
- The metacharacters, one by one
- Grouping, alternation and back-references
- Greediness: when the regex takes too much
- A method for building a regex step by step
- Practical regexes from the running example
- Who expands what: the shell first, the program afterwards
The rule, in one sentence: globbing is done by Bash over file names before the command runs; regular expressions are interpreted by the program over the text it receives.
The command never gets to see the wildcard. Check it with a command that does nothing but print its arguments:
operator@srv-tramontana:~$ cd /var/log/tramontana && echo *.log
access.log errors.log
operator@srv-tramontana:~$ echo '*.log'
*.logIn the first case echo received two already-resolved arguments. In the second, the quotes prevented globbing and it received the literal. This is why ls *.log and grep '.*\.log' file bear no resemblance to each other: the first asks the shell for files, the second asks the program to look for a pattern inside the text.
Here the two things live side by side: *.log was expanded by Bash (which is why grep knows there are two files and prefixes the name), while GET is the pattern grep interprets. When a pattern is for the program, it goes in single quotes. Always. If you write grep *.log file without quotes and there is an access.log in the directory, Bash substitutes it and grep ends up looking for the string "access.log" inside errors.log. A plausible and wrong result.
| Globbing | Regular expression | |
|---|---|---|
| Processed by | Bash | the program (grep, sed, awk…) |
| Acts on | existing file names | any text |
* means |
any sequence of characters | zero or more of the previous element |
? means |
exactly one character | zero or one occurrence of the previous |
| Must match | the whole name | by default, any part of the line |
| If nothing matches | the pattern is passed literally | the program returns no results |
The last two rows are subtle and worth their weight in gold. A glob has to match the complete name, whereas grep 'GET' matches any line containing that string in any position.
- Globbing: the shell's wildcards
| Wildcard | Matches | Example at Tramontana |
|---|---|---|
* |
any sequence, including the empty one | *.log → access.log errors.log |
? |
exactly one character | 3.2.? → 3.2.0 3.2.1 |
[abc] |
one of the listed characters | report-[12].txt |
[a-z] |
one from the range | [0-9]*.csv |
[!abc] or [^abc] |
one that is not in the list | [!.]* |
operator@srv-tramontana:~$ ls /opt/tramontana/releases/
3.1.0 3.2.0 3.2.1 3.3.0
operator@srv-tramontana:~$ ls -d /opt/tramontana/releases/3.2.*
/opt/tramontana/releases/3.2.0 /opt/tramontana/releases/3.2.1
operator@srv-tramontana:~$ ls -d /opt/tramontana/releases/3.[13].0
/opt/tramontana/releases/3.1.0 /opt/tramontana/releases/3.3.0Three properties of globbing to keep in mind:
*does not cross slashes./opt/*/appfinds nothing nested any deeper; that is what**is for, and you will see it in section 4.- Hidden files do not match
*.echo *in your$HOMEdoes not list.bashrc. It is deliberate: it stops anrm *from taking your configuration with it. - The result comes out sorted according to the locale, with the consequences you already know from 03-01.
The course convention — looking with ls before an rm — is exactly a globbing check: you run the pattern with a harmless command and see what the dangerous one would have received.
operator@srv-tramontana:~$ ls /srv/tramontana/backups/temp/*.tmp
/srv/tramontana/backups/temp/export-1.tmp
/srv/tramontana/backups/temp/export-2.tmp
operator@srv-tramontana:~$ rm /srv/tramontana/backups/temp/*.tmp
- POSIX classes and brace expansion
Inside square brackets you can use named classes, which are more readable and more correct with accented characters than a hand-written range:
| Class | Equivalent to |
|---|---|
[[:digit:]] |
0-9 |
[[:alpha:]] |
letters, including the accented ones from the locale |
[[:alnum:]] |
letters and digits |
[[:space:]] |
space, tab, newline |
[[:upper:]] / [[:lower:]] |
upper case / lower case |
[[:punct:]] |
punctuation marks |
The double brackets are confusing: the outer ones are the set's, the inner ones are part of the class name. They combine with other characters inside the same set: [[:digit:]-] matches a digit or a hyphen.
Brace expansion is not globbing
{a,b} and {1..10} look like wildcards and are something else: they generate text, whether the files exist or not. It happens before globbing.
operator@srv-tramontana:~$ echo report-{january,february,march}.txt
report-january.txt report-february.txt report-march.txt
operator@srv-tramontana:~$ echo /home/operator/work/2026/{07,08,09}/{reports,data}
/home/operator/work/2026/07/reports /home/operator/work/2026/07/data
/home/operator/work/2026/08/reports /home/operator/work/2026/08/data
/home/operator/work/2026/09/reports /home/operator/work/2026/09/data(The real output comes out on a single line; here it is split so that it can be read.) That directory structure you already have was created in exactly this way, with mkdir -p and a brace expansion. The ranges accept a step: {1..10}, {a..e}, {0..30..5}.
The most profitable everyday use is avoiding the repetition of a long path:
operator@srv-tramontana:~$ cp /etc/tramontana/app.conf{,.bak-$(date +%F)}
operator@srv-tramontana:~$ ls /etc/tramontana/
app.conf app.conf.bak-2026-08-18The brace {,.bak-...} expands into two arguments: the original and the original with a suffix. It is the shorthand form of the course's backup convention.
- Globbing options: globstar, dotglob, nullglob, failglob
They are enabled with shopt -s and disabled with shopt -u.
| Option | Effect |
|---|---|
globstar |
enables **, which does cross slashes and walks subdirectories |
dotglob |
makes * include hidden files |
nullglob |
if the pattern matches nothing, it expands to nothing instead of staying literal |
failglob |
if the pattern matches nothing, the command does not run and gives an error |
nocaseglob |
case-insensitive globbing |
extglob |
extended patterns: !(pattern), +(pattern), `@(a |
operator@srv-tramontana:~$ shopt -s globstar
operator@srv-tramontana:~$ ls /opt/tramontana/releases/**/*.html
/opt/tramontana/releases/3.2.1/templates/confirmation.html
/opt/tramontana/releases/3.2.1/templates/invoice.htmlWhat happens by default when a pattern matches nothing
This behaviour is surprising and it is worth understanding before you suffer from it. By default, if a glob finds nothing, Bash leaves it as it is and passes it to the command as literal text:
operator@srv-tramontana:~$ ls /var/log/tramontana/*.gz
ls: cannot access '/var/log/tramontana/*.gz': No such file or directoryNotice that the message contains the asterisk: ls received the unexpanded pattern and tried to open a file literally called *.gz. With ls it is harmless; with a destructive command, less so. Imagine rm /srv/tramontana/backups/temp/*.tmp when there are no .tmp files left: at best it gives an error, but a command that interpreted the argument differently could do something unexpected.
nullglobis what you want when the pattern can legitimately match nothing.failglobis the safest for interactive work: the command aborts before it runs.
operator@srv-tramontana:~$ shopt -s failglob
operator@srv-tramontana:~$ ls /var/log/tramontana/*.gz
bash: no match: /var/log/tramontana/*.gzNow it is Bash that refuses, and ls never ran.
- Regular expressions: BRE, ERE and PCRE
A regular expression describes a set of strings. The historical problem is that there are three dialects and the Unix tools do not agree on which one they use.
| BRE (basic) | ERE (extended) | PCRE (Perl) | |
|---|---|---|---|
| Used in | grep, sed |
grep -E, sed -E, awk |
grep -P |
+ ? {} () ` |
` | must be escaped: \+ \? |
work directly |
\d \w \s |
no | no | yes |
\b (word boundary) |
yes | yes | yes |
Lazy *? |
no | no | yes |
In other words: in BRE the parentheses are literal and \\( is the metacharacter; in ERE it is the other way round. Hence the same expression gives different results with and without -E:
operator@srv-tramontana:~$ grep -c 'GET\|POST' access.log
412
operator@srv-tramontana:~$ grep -Ec 'GET|POST' access.log
412
operator@srv-tramontana:~$ grep -c 'GET|POST' access.log
0The third one looks for the literal string GET|POST, which never appears. A firm recommendation: always use grep -E. You write fewer backslashes, it reads better and it is the syntax shared by awk, egrep and almost every modern language. Reserve -P for what only PCRE offers (\d, lazy quantifiers, lookahead), knowing that it is not available on every machine.
- The metacharacters, one by one
We work on /var/log/tramontana/access.log, which has this format:
operator@srv-tramontana:~$ head -3 /var/log/tramontana/access.log
2026-08-18 09:14:02 GET /bookings/1012 200 ip=10.0.2.31 ms=48
2026-08-18 09:14:07 POST /bookings 201 ip=10.0.2.31 ms=134
2026-08-18 09:14:19 GET /houses/mas-figueres 200 ip=10.0.2.44 ms=22| Metacharacter | Means | Example | Matches |
|---|---|---|---|
. |
any single character | 2.6 |
2026, 216, 2x6 |
^ |
start of line | ^2026-08-18 |
lines from that day |
$ |
end of line | ms=[0-9]+$ |
the final field |
[] |
one from the set | [45]0[0-9] |
404, 500, 503 |
[^] |
one outside the set | [^0-9] |
any non-digit |
* |
zero or more of the previous | ms=[0-9]* |
ms= and ms=134 |
+ |
one or more of the previous | ms=[0-9]+ |
only ms=134 |
? |
zero or one | https? |
http, https |
{n,m} |
between n and m repetitions | [0-9]{3} |
exactly three digits |
| |
alternation | GET|POST |
either of the two |
() |
grouping | (GET|POST) /bookings |
groups for the alternation |
\. |
a literal dot | 10\.0\.2\.15 |
that IP and only that one |
The difference between * and + is the one that generates the most false positives: * accepts total absence, so grep 'ms=[0-9]*' matches even a line with an empty ms=.
The escapes only exist in PCRE:
| Escape | Equivalent to |
|---|---|
\d |
[0-9] |
\w |
[A-Za-z0-9_] |
\s |
whitespace |
\D \W \S |
their negations |
\b |
word boundary (the border between \w and non-\w) |
\b deserves attention because it solves a constant problem. Searching for 200 in the log also matches ms=200 or /bookings/1200:
operator@srv-tramontana:~$ grep -c ' 200 ' access.log
331
operator@srv-tramontana:~$ grep -cE '\b200\b' access.log
338The spaces are more restrictive than \b (which would also accept =200). In this case the first is the correct one, because the status code is always surrounded by spaces. grep's -w option does the same as wrapping the whole pattern in \b.
- Grouping, alternation and back-references
Parentheses do two things: they delimit the scope of an operator and they capture what they match so that it can be reused.
Without parentheses, the alternation would extend to the end of the pattern and 1[4-9]|2[0-9]: would mean "09:1[4-9]" or "2[0-9]:", which is something else.
A back-reference \1 matches exactly the same text that the first group captured. It is used to detect repetitions, which is something no pattern without capture can express:
No output: there are no duplicated words. A real use is finding repeated octets or, in bookings.csv, detecting a doubled separator:
Also no results, which is exactly what we want: no empty field caused by a double semicolon. A search with no output is a result, not a failure; it is worth saying out loud because it takes some getting used to.
- Greediness: when the regex takes too much
The quantifiers *, + and {n,} are greedy: they match as much as possible. That is the cause of the classic error. Let us try to extract just the path from a log line with sed (which you will see in depth in 03-05; here it is only a demonstration):
operator@srv-tramontana:~$ echo '2026-08-18 09:14:02 GET /bookings/1012 200 ip=10.0.2.31 ms=48' \
| sed -E 's/.*(\/.*) .*/\1/'
/bookings/1012 200 ip=10.0.2.31We expected /bookings/1012 and we have got three fields. The reason: the .* inside the group ate everything it could while the pattern still matched globally. The solution in ERE is to forbid the separating character, instead of accepting any character:
operator@srv-tramontana:~$ echo '2026-08-18 09:14:02 GET /bookings/1012 200 ip=10.0.2.31 ms=48' \
| sed -E 's/.*(\/[^ ]*) .*/\1/'
/bookings/1012[^ ]* cannot cross a space, so the group stops where it should. [^X]* instead of .* is the technique that solves 90% of greediness problems and it works in every dialect.
PCRE also offers lazy quantifiers, which match the minimum: .*?. With grep -oP '\/.*?\s' you would get the same result. It is convenient, but it depends on -P, so in portable scripts prefer the negated class.
- A method for building a regex step by step
Nobody writes a complex regular expression in one go. The method is incremental and always the same:
- Look at the real data.
head -3of the file, not from memory. - Start with the most distinctive part and check that it matches something:
grep -E 'ip=' access.log | head -3. - Add one piece at a time, verifying the count with
-cafter each addition. If the number changes unexpectedly, the problem is in the last thing you added. - Use
-oto see exactly what is matching, not the whole line. It is the most useful debugging toolgrephas. - Check the false negatives too:
grep -vE 'pattern' file | headshows you what is escaping you. It is usually more revealing than what does match. - Only when the pattern is validated should you use it on something that modifies data.
operator@srv-tramontana:~$ grep -oE 'ip=[0-9.]+' access.log | head -3
ip=10.0.2.31
ip=10.0.2.31
ip=10.0.2.44-o prints only the part that matches, one per line. With that you can see immediately whether your pattern is going too far or falling short.
- Practical regexes from the running example
Extracting IP addresses. A "properly done" IP would require validating that each octet is 0-255, which produces an unreadable pattern. For your own logs, with a known format, this is enough and it reads well:
operator@srv-tramontana:~$ grep -oE 'ip=([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | head -3
ip=10.0.2.31
ip=10.0.2.31
ip=10.0.2.44([0-9]{1,3}\.){3} is "a group of one to three digits followed by a dot, repeated three times", plus a final group with no dot. The dots are escaped: without the backslash they would match any character.
Error status codes. The 4xx and 5xx ones, which are the interesting ones:
operator@srv-tramontana:~$ grep -cE ' [45][0-9]{2} ' access.log
23
operator@srv-tramontana:~$ grep -oE ' [45][0-9]{2} ' access.log | sort | uniq -c
4 404
14 500
5 503The spaces around the pattern avoid matching the first three digits of a booking identifier.
ISO dates. [0-9]{4}-[0-9]{2}-[0-9]{2} is enough for a controlled format. If you want to restrict it to valid months: [0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01]). That level of rigour makes sense when validating input, not when searching in your own logs.
Validating the format of bookings.csv. The file has the header id;date;house;guest;nights;amount and 25 records. A valid line is: a four-digit id, an ISO date, a house name in lower case with hyphens, a guest name, a number of nights and a decimal amount.
operator@srv-tramontana:~$ grep -cvE '^[0-9]{4};[0-9]{4}-[0-9]{2}-[0-9]{2};[a-z-]+;[^;]+;[0-9]+;[0-9]+\.[0-9]{2}$' \
/home/operator/data/bookings.csv
1Only one line fails validation: the header, which indeed does not comply with the format of a record. That confirms that the 25 records are correct. Note the three design decisions in the pattern: ^ and $ force the whole line to be validated (without anchors, grep would match any fragment and the validation would be worthless); [^;]+ in the guest name accepts spaces and accents but not an extra semicolon; and [0-9]+\.[0-9]{2} requires exactly two decimal places in the amount.
To see which one fails, grep -nvE '...' with -n gives you the line number. This pattern, saved, is an integrity check you can run before every import.
Common Mistakes and Tips
- Not quoting the
greppattern. If it contains*,?or[, Bash expands it first and you search for something else. Single quotes always. - Believing that
*in a regex means "anything". In a regex,*applies to the previous element. "Anything" is.*. - Forgetting to escape the dot in an IP or in an extension.
10.0.2.15also matches10x0y2z15. Write10\.0\.2\.15. - Using
.*where a negated class belongs. If the result takes too much, change.*for[^delimiter]*. - Mixing dialects. If you write
\dwithout-P,greplooks for the literal letterdand gives no warning. It is a silent failure. - Validating without anchors. A validation pattern without
^and$validates nothing. - Tip: when a pattern does not work, do not rewrite it from scratch. Take pieces off it until it matches something and then add them back one at a time.
- Tip:
grep --color=always -oE 'pattern'is your debugger. Andecho 'test line' | grep -E 'pattern'lets you test against a made-up case without touching the real file.
Exercises
Exercise 1. Without using find or grep, list with a single glob all the release directories of the 3.2 series in /opt/tramontana/releases/, and explain why 3.2* and 3.2.* are not equivalent. Then create with a single command the structure /home/operator/work/2026/{10,11,12}/{reports,data}.
Exercise 2. Extract from access.log all the POST requests that returned an error code (4xx or 5xx), showing only the method, the path and the code. Build the pattern step by step and show the verification of each step.
Exercise 3. Marta asks you to make sure that bookings.csv has no badly formatted amounts before the monthly import. Write a check that detects amounts without exactly two decimal places, verify that the current file is clean, and prove that your pattern works by testing it against a made-up line that is genuinely wrong.
Solutions
Solution 1.
operator@srv-tramontana:~$ ls -d /opt/tramontana/releases/3.2.*
/opt/tramontana/releases/3.2.0 /opt/tramontana/releases/3.2.13.2* would also match a hypothetical 3.20 or 3.25, because * includes the empty string and does not require the dot. 3.2.* forces there to be a dot after the 2, which in a semantic versioning scheme is exactly the difference between "the 3.2 series" and "anything starting with 3.2". With four releases the mistake is invisible; with forty, it is not.
operator@srv-tramontana:~$ mkdir -p /home/operator/work/2026/{10,11,12}/{reports,data}
operator@srv-tramontana:~$ ls /home/operator/work/2026/
07 08 09 10 11 12The brace expansion generates the six paths and mkdir -p creates the intermediate levels. Note that this is not globbing: it works precisely because the directories do not exist yet.
Solution 2. Step by step, verifying with -c after each addition:
operator@srv-tramontana:~$ grep -cE 'POST' access.log
114
operator@srv-tramontana:~$ grep -cE 'POST /[^ ]+' access.log
114
operator@srv-tramontana:~$ grep -cE 'POST /[^ ]+ [45][0-9]{2}' access.log
18
operator@srv-tramontana:~$ grep -oE 'POST /[^ ]+ [45][0-9]{2}' access.log | sort | uniq -c | sort -rn
9 POST /bookings 500
5 POST /bookings/payment 503
4 POST /bookings/payment 500The first step confirms that there are POSTs. The second does not change the count, which shows that all the POST lines have a path in the expected format: if it had dropped, we would have malformed lines. The third filters by error code: 18 of the log's 23 error responses are POST requests. [^ ]+ instead of .* stops the path from eating the rest of the line, and [45][0-9]{2} matches any 4xx or 5xx without listing them. -o trims the output down to the essentials and the final pipeline groups it; that sort | uniq -c | sort -rn is the pattern you will study in depth in 03-05.
Solution 3.
operator@srv-tramontana:~$ grep -nvE ';[0-9]+\.[0-9]{2}$' /home/operator/data/bookings.csv
1:id;date;house;guest;nights;amountOnly the header. The 25 records have a well-formed amount. To exclude the header from the report and keep only real errors:
operator@srv-tramontana:~$ tail -n +2 /home/operator/data/bookings.csv \
| grep -nvE ';[0-9]+\.[0-9]{2}$'
operator@srv-tramontana:~$ echo "exit code: $?"
exit code: 1No output and exit code 1: grep found no line that breaks the rule, which is the desired result. tail -n +2 starts at line 2, skipping the header.
The important part of the exercise is the last one: a validator you have never seen fail is not validated. You test it against a known bad case:
operator@srv-tramontana:~$ echo '1026;2026-08-19;can-ventos;Ana Puig;3;340.5' \
| grep -nvE ';[0-9]+\.[0-9]{2}$'
1:1026;2026-08-19;can-ventos;Ana Puig;3;340.5It detects the amount with a single decimal place. Now you know that the pattern really does discriminate and is not returning "all fine" because of a syntax error. Test a good case too (340.50) and confirm that it does not flag it: a validator must fail when it should and only when it should.
Conclusion
This lesson has given you two pattern languages and, above all, the boundary between them.
- Globbing is done by Bash over file names, before anything runs. The command receives the already-resolved list and never sees the wildcard. You check it with
echo. - Regular expressions are interpreted by the program over the text it receives, and that is why they go in single quotes.
- You have mastered the wildcards
*,?,[abc],[a-z],[!abc]and the POSIX classes, and you know that*neither crosses slashes nor matches hidden files. - You distinguish brace expansion — which generates text whether the files exist or not — from globbing, and you use
{,.bak-$(date +%F)}for backups. - You control
globstar,dotglob,nullglobandfailglob, and you know what Bash does by default when a pattern matches nothing: pass it literally. - You know the difference between BRE, ERE and PCRE, and why the recommendation is
grep -E. - You handle the metacharacters one by one, grouping, alternation and the back-references
\1. - You understand greediness and you know that the portable solution is to replace
.*with a negated class[^X]*. - And you have a method for building patterns: real data, one piece at a time,
-cto count,-oto see what matches,-vto see what is escaping, and testing the validator against a bad case before trusting it.
In the next lesson, Searching Files and Content: find, locate and grep, these patterns stop being an exercise and become the interface to three tools. find walks the directory tree applying criteria that include globs; grep applies regular expressions to millions of lines in seconds; and locate answers instantly by consulting a database. You will know which one to use in each case, how to combine them with xargs without a name containing spaces ruining everything, and how to locate on srv-tramontana the releases that have to be purged, the .bak-* files scattered around the system and the configuration file where somebody left a credential written down.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
