Matheion

MA-CLI1 · Module 1.9

Finding files

Topics: Globs · find · Wildcards

Overview

Module 1.9 — Finding files

A project folder can hold dozens of files spread across several levels, and "I know it's in there somewhere" is not a plan. This module gives you two ways to name files by pattern instead of one at a time: shell globs, which the shell expands for you, and find, which walks a whole tree and reports what it meets.

Sam's tidewatch project has files for several gauges, an archive, and a scripts folder. Time to find things on purpose.

By the end you should be able to:

  • use *, ?, and [ ] in globs, and know when to quote them;
  • run find with a starting path, -name, -type f or -type d, and a light -mtime test;
  • explain find as "walk the tree, test each entry, print the ones that pass";
  • say how find differs from ls -R, and why you should list before you ever think of -delete.

Scroll Core ideas → worked examples → Quiz. Plan on roughly 60 minutes, with a terminal open in ~/cli-practice/tidewatch/.

Concepts

Core ideas — naming files by pattern, and searching a tree

So far Sam has named every file explicitly: notes/field_log.txt, data/raw/gauge_a.csv. That works when you know exactly what you want. But real questions sound different: "all the CSV files," "every script," "anything not touched since last season." To ask those, you need to describe files by a pattern or by a property rather than by a full name.

The shell gives you two complementary tools for this. Globs are short wildcard patterns that the shell expands into file names before the command runs. find is a separate command that walks through a folder tree, looks at each entry, and prints the ones that pass your tests. They overlap a little, but they answer different kinds of questions, and knowing which to reach for is most of the skill.


Globs: wildcards the shell expands

A glob is a pattern containing special characters. When you type one on a command line, the shell (not the command) replaces it with the list of matching file names, and then runs the command with that list. The command itself never sees the star; it sees the names.

* — any number of characters

ls data/raw/*.csv

The * stands for "anything, including nothing." So *.csv matches every name ending in .csv. If data/raw/ holds gauge_a.csv, gauge_b.csv, and gauge_c_old.csv, the shell turns your command into:

ls data/raw/gauge_a.csv data/raw/gauge_b.csv data/raw/gauge_c_old.csv

and then runs it. You can see this expansion for yourself with echo data/raw/*.csv, which just prints the matched names. That is a wonderful safety habit: echo a glob first to preview what it will touch.

? — exactly one character

ls data/raw/gauge_?.csv

Each ? matches precisely one character. So gauge_?.csv matches gauge_a.csv and gauge_b.csv but not gauge_c_old.csv, because c_old is several characters where the pattern allows one.

[ ] — one character from a set

ls data/raw/gauge_[ab].csv

The brackets match exactly one character, chosen from the ones inside. [ab] means "a or b." You can also give a range, such as [a-c] for a, b, or c, or [0-9] for any digit.

What globs do not do

  • A glob matches names in one folder level at a time. *.csv does not look inside subfolders. (data/*/*.csv reaches one level down, because you spelled out the middle folder.)
  • By convention, * does not match names that begin with a dot, so hidden files are skipped unless you write the dot yourself.
  • Globs match names, not contents. They cannot tell you which files mention a gauge, only which names look a certain way (for contents, use grep from the last module).

When a glob matches nothing

Different shells behave differently when a pattern matches no files. In bash, the pattern is usually passed along unchanged, so ls zzz*.csv reports that a file literally named zzz*.csv does not exist. In zsh (the default on many modern Macs), the shell stops first and says "no matches found." Either way, the lesson is the same: if you see the star come back in an error message, nothing matched. Check your spelling and your folder with pwd and ls.


Quoting globs

Quotes switch the shell's expansion off. Inside single or double quotes, * is just a star character handed to the command untouched.

echo *.csv        # the shell expands the glob
echo '*.csv'      # prints the literal text *.csv

Usually you want the shell to expand globs, so you leave them unquoted. But there is one important case where you must quote: when the pattern is meant for a command that does its own matching. The prime example is find -name, which you will meet in a moment. If you write find . -name *.csv without quotes and your current folder happens to contain CSV files, the shell expands the star first and find receives a jumble of names instead of the pattern, which typically produces a confusing error. Writing find . -name '*.csv' hands find the pattern itself.

Quote also when a name contains a space, such as 'Field Notes.txt'; otherwise the shell treats it as two separate words.

A simple rule of thumb: let the shell expand globs when it should choose the files; quote them when a command should interpret the pattern.


find: walk a tree and test each entry

find <where to start> <tests> <action>

find begins at the path you give it, visits every file and folder underneath, at every depth, and applies your tests to each one. Entries that pass are handed to the action. If you give no action, the default is to print the path.

The simplest possible use is to start somewhere and give no tests at all:

find .

This prints the starting folder and everything inside it, one path per line. Each path begins with the starting point you typed, so find . produces paths like ./notes/field_log.txt, while find notes produces paths like notes/field_log.txt. The paths show you where each thing lives.

Test by name — -name

find . -name '*.csv'

Read it as: "start here, and print every entry whose name matches *.csv." The pattern uses the same glob characters you just learned, but now find interprets them (that is why they are quoted). The match is against the file name only, not the folders above it, and it is case-sensitive; -iname ignores case.

Test by kind — -type f and -type d

  • -type f selects regular files.
  • -type d selects directories.
find . -type d
find . -type f -name '*.txt'

The first lists only folders, which gives a clean picture of the tree's shape. The second says "files only, ending in .txt." Tests written one after another are combined with AND, so an entry must pass all of them.

Test by age — -mtime (a light touch)

-mtime looks at when a file was last modified, counted in whole days:

  • -mtime +30 — modified more than 30 days ago (older).
  • -mtime -2 — modified less than 2 days ago (recent).
  • -mtime 0 — modified within the last day.
find . -name '*.csv' -mtime +30

This asks for CSV files untouched for over a month: a handy way to spot stale data. The rules about rounding days can feel fussy, so treat -mtime as a rough filter and double-check with ls -l when the exact day matters.


The mental model: walk, test, print

Picture a very patient helper who walks through your folders. At every entry, the helper asks the questions you wrote, in order: "Is it a file? Does its name end in .csv? Is it older than thirty days?" If every answer is yes, the helper prints the path (-print) and moves on. If any answer is no, the helper just continues walking.

In that picture, -print is the action. It is the default, so you rarely type it, but writing it makes the model explicit:

find data -name 'gauge_?.csv' -print

Thinking this way also helps you predict results. If you get nothing, ask which question failed for every entry (wrong name pattern? wrong type? starting in the wrong place?). If you get too much, add a test.

Two practical notes. First, the order of results is not guaranteed; it follows how the folders are stored. If you need a stable order, add | sort. Second, find produces plain text, one path per line, which means it combines naturally with the pipes from the last module, for example find . -name '*.csv' | wc -l counts how many CSV files exist anywhere in the tree.

If the starting path does not exist, find writes an error to stderr and finds nothing, which is exactly the behaviour you studied in the previous two modules.


Contrast with ls -R

In module 1.6 you used ls -R (and ls -lR) to see a whole tree. Both ls -R and find can cover the full depth, but they are built for different jobs.

| | ls -R | find | |---|---------|--------| | Purpose | display a tree, folder by folder | search a tree for entries that pass tests | | Output shape | groups with folder headings | one full path per line | | Filter by name pattern | awkward | built in (-name) | | Filter by type or age | not really | built in (-type, -mtime) | | Easy to pipe into wc, grep, sort | clumsy because of headings | natural, one path per line |

If you want to read the layout, ls -R is friendly. If you want to ask a question about the tree, find is the right tool. Because every line of find output is a self-contained path, it is also much easier to hand to the next command.


Safety: list first, act later

find can do more than print. It has actions such as -delete, which removes every entry that passes the tests. That power deserves respect, and here is why.

  • Deletion is permanent. As with rm, there is no trash can.
  • Tests that look right can be wrong. A pattern slightly too broad, or a start folder one level too high, and you may remove things you never meant to.
  • Order of arguments can change the meaning. With -delete, putting it before your tests can cause it to apply to everything.

So build the habit of two passes. First, run the find with plain printing and read the list carefully, perhaps counting it with | wc -l. Only if every line is something you are happy to lose, consider an action. For a beginner, the best default is simply to never use -delete and to remove files in a separate, deliberate rm command after reviewing a list. The listing itself is already the useful result.


Common pitfalls

  • Forgetting the quotes in find -name. The shell may expand the star before find sees it.
  • Expecting *.csv to search subfolders. A glob covers one folder level; find covers the whole tree.
  • Expecting hidden files to match *. Names starting with a dot are skipped.
  • Searching from the wrong folder. Check with pwd; start find as close to your target as you can.
  • Trusting result order. Add | sort if order matters.
  • Reaching for -delete early. List first, and then decide.
  • Quoting too much. If you quote a glob meant for the shell, it will not expand.

Self-study checklist

  • Explain the difference between *, ?, and [ab] with one example each.
  • Use echo to preview a glob before applying it to a command.
  • Say why find . -name '*.csv' needs quotes.
  • Write a find command for "all regular files ending in .txt under notes."
  • Explain -mtime +30 in your own words.
  • Describe find as "walk the tree, test each entry, print the ones that pass."
  • Name two differences between ls -R and find.
  • Describe the two-pass habit that keeps find safe.

Examples

Worked examples — locating things in the tidewatch tree

The tidewatch project now has data for several gauges, an archive, and a handful of scripts. Sam needs to find files by pattern and by age. Each scene gives a situation, the commands, an interpretation, and a takeaway. The order in which find lists things can differ on your machine, so the examples add | sort where order matters.

First, a small setup so you can follow along:

cd ~/cli-practice
mkdir -p tidewatch/notes tidewatch/data/raw tidewatch/data/processed tidewatch/archive tidewatch/scripts tidewatch/logs
cd tidewatch
touch notes/field_log.txt notes/private_draft.txt 'notes/Field Notes.txt'
touch data/raw/gauge_a.csv data/raw/gauge_b.csv data/raw/gauge_c_old.csv data/processed/gauge_a_clean.csv
touch archive/gauges_2023.csv scripts/sync_readings.sh scripts/check_gauges.sh logs/events.log logs/warnings.txt
touch -t 202301010000 archive/gauges_2023.csv data/raw/gauge_c_old.csv

The last line gives two files an old modification date (January 2023), so the age tests have something to find.


Scene 1 — Preview a glob before you use it

Scene. Sam wants to list the raw gauge files without typing each name, and wants to be sure what the pattern covers.

echo data/raw/*.csv
echo data/raw/gauge_?.csv
echo data/raw/gauge_[ab].csv
data/raw/gauge_a.csv data/raw/gauge_b.csv data/raw/gauge_c_old.csv
data/raw/gauge_a.csv data/raw/gauge_b.csv
data/raw/gauge_a.csv data/raw/gauge_b.csv

Interpretation. echo simply prints whatever the shell hands it, so it shows the expansion. The * caught all three files. The ? and [ab] patterns each caught exactly one character after gauge_, so gauge_c_old.csv, with its longer tail, fell outside. Now Sam can safely run ls -l data/raw/gauge_?.csv, knowing the shell will turn it into those two names.

Takeaway. echo plus a glob is a free preview. Use it before any command that changes files.


Scene 2 — Quotes and empty matches

Scene. Sam compares a quoted and unquoted star, and then tries a pattern that matches nothing.

echo '*.csv'
ls zzz*.csv
*.csv
ls: zzz*.csv: No such file or directory

Interpretation. In the first command, quotes switched expansion off, so the star was printed as a plain character. In the second, no file name begins with zzz. In bash the pattern is passed through unchanged, which is why the error repeats the star. In zsh you would instead see "no matches found". Either way, a star appearing in an error message is a clue that nothing matched.

Takeaway. Unquoted globs are for the shell to expand; quoted globs are for commands that interpret patterns themselves. An error that echoes your pattern back means "no match."


Scene 3 — Walking the whole tree with find

Scene. Sam wants the shape of the project and then every CSV anywhere in it.

find . -type d | sort
find . -name '*.csv' | sort
find . -type f -name '*.txt' | sort
.
./archive
./data
./data/processed
./data/raw
./logs
./notes
./scripts
./archive/gauges_2023.csv
./data/processed/gauge_a_clean.csv
./data/raw/gauge_a.csv
./data/raw/gauge_b.csv
./data/raw/gauge_c_old.csv
./logs/warnings.txt
./notes/Field Notes.txt
./notes/field_log.txt
./notes/private_draft.txt

Interpretation. The first command prints only directories, which gives a clean outline of the tree. The second walks every level and prints each entry whose name ends in .csv; a plain ls *.csv from this folder would have found none, because a glob covers only one level. The third combines two tests (a regular file and a name ending in .txt), so folders are excluded even if one were named something.txt. Every path begins with . because that is where Sam started.

Takeaway. find searches the whole depth, and tests stack with AND. Read it as: walk, test, print.


Scene 4 — Stale data, counted and listed

Scene. Sam suspects some data files are very old and wants to know how many CSVs there are overall, and which of them have not been touched in a month.

find . -name '*.csv' | wc -l
find . -name '*.csv' -mtime +30 | sort
       5
./archive/gauges_2023.csv
./data/raw/gauge_c_old.csv

Interpretation. The first pipeline counts paths, one per line, so it reports five CSV files. The second adds the age test, and only the two files given the 2023 date pass. Notice how naturally find joins the pipes from the previous module: its output is one path per line, which is exactly what wc -l and sort expect. If Sam wants to be sure about the dates, ls -l archive data/raw shows them.

Sam also looks for the file whose name contains a space:

find notes -name 'Field*'
notes/Field Notes.txt

Takeaway. -mtime +30 is a rough "older than a month" filter. Quote any pattern or name that contains spaces or wildcards.


Scene 5 — The unquoted star, and listing before acting

Scene. Sam moves into data/raw and makes two decisions, one careless and one careful.

cd data/raw
find . -name *.csv
find: gauge_b.csv: unknown primary or operator

Interpretation. The exact message varies between systems. Because this folder contains CSV files, the shell expanded *.csv into three names before find ran, so find received -name gauge_a.csv gauge_b.csv gauge_c_old.csv and could not make sense of the extra words. Quoting fixes it:

find . -name '*.csv'
./gauge_a.csv
./gauge_b.csv
./gauge_c_old.csv

Now the careful decision. Sam wants to clear out the old file, and remembers there is a -delete action. Instead of reaching for it, Sam goes back up a level and lists what such a command would affect:

cd ../..
find . -name '*.csv' -mtime +30 -print | sort
./archive/gauges_2023.csv
./data/raw/gauge_c_old.csv

Reading the list, Sam notices that archive/gauges_2023.csv is a deliberate keepsake. A blanket delete would have removed it. So Sam removes only the one file intended, with a separate and explicit command:

rm data/raw/gauge_c_old.csv

Takeaway. Quote patterns meant for find. List before you act, read the list, and delete with a command that names only what you mean to lose.

Practice check — finding files

Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.

  1. 1.`data/raw/` contains `gauge_a.csv`, `gauge_b.csv`, and `gauge_c_old.csv`. Which pattern matches only the first two?

  2. 2.What does `ls data/raw/gauge_[ab].csv` match?

  3. 3.Why should you write `find . -name '*.csv'` with quotes around the pattern?

  4. 4.Which command lists only regular files under `notes/` whose names end in `.txt`?

  5. 5.What does `find . -name '*.csv' -mtime +30` report?

  6. 6.Which statement best describes how `find` differs from `ls -R`?

  7. 7.You want to remove old CSV files with `find`. What is the sensible first step?