Module 2: Working with Files and Text

5. Finding files with find

Description

By the end of this lesson you will be able to build any find query: tell it where to search, under what conditions — name, type, size, modification date, depth — and what to do with what it finds, including batch deletion without destroying what you should not.

This is not a syntax exercise. You SSH into a production server after a deployment that went wrong and need to know which configuration file was modified in the last few hours. Or the disk is filling up and you have to find, among thousands of files, which ones weigh more than a gigabyte. On that server there is no file explorer with right-click and "search." There is a terminal and find, full stop.

Connection to the module: the previous lesson taught you to read a file once you already know which one it is. This one closes the gap before that: finding which one it is, before you can read it. The next lesson searches inside a file's content with grep; this one searches by its metadata — name, type, size, date — without opening anything.


find as a sentence: where, what condition, what to do

Think about how you would give instructions to someone who does not know your house: "in the kitchen, look for the empty jars, and throw them in the trash." That instruction has three parts: a place, a condition describing what counts as "found," and an action on whatever meets the condition. find reads exactly the same way:

find <where> <conditions> <action>

Technically, find recursively walks the directory tree starting from the point you give it, and evaluates, on every entry (file, directory, link), an expression made of one or more conditions. If you do not ask for any action, the default action is to print the full path. Everything you are going to learn in this lesson is different ways of filling in those three parts.

Worked example

You are going to build a toy project tree and run real queries against it. Use mkdir and touch (you know them from lesson 2, brace expansion included):

mkdir -p project/{src,tests,config,logs,node_modules/lodash}
touch project/config/{app.yaml,database.yaml,secrets.env}
touch project/src/{index.js,utils.js,index.js.bak}
touch project/tests/index.test.js
touch project/node_modules/lodash/index.js
head -c 2097152 /dev/urandom > project/logs/access.log

The last line is not find: it is head -c 2097152 reading 2,097,152 bytes (2 mebibytes) of random data to simulate a log file with real weight (the ones touch creates weigh 0 bytes). The number goes with no unit suffix on purpose — support for suffixes like 2M in head -c varies between systems, while a plain byte count works the same on any of them. The tree you just created looks like this:

project/
├── config/
│   ├── app.yaml
│   ├── database.yaml
│   └── secrets.env
├── logs/
│   └── access.log        (2 MiB)
├── node_modules/
│   └── lodash/
│       └── index.js
├── src/
│   ├── index.js
│   ├── index.js.bak
│   └── utils.js
└── tests/
    └── index.test.js

Filtering by name with -name and -iname. -name compares the pattern against each entry's base name — no directory path — using shell wildcards (*, ?), and that is why the pattern always goes in quotes: if you do not protect it, it is your own shell that expands the wildcard before find ever receives it, not find.

find project -name "*.yaml"

What to expect:

project/config/app.yaml
project/config/database.yaml

(The exact line order can vary depending on your filesystem; what matters is which paths show up, not what order.)

-iname is the same, but ignores case throughout the pattern. Useful when you inherit a tree with inconsistent conventions:

find project -iname "*.ENV"

What to expect:

project/config/secrets.env

Filtering by type with -type. -type f selects only regular files, -type d only directories, -type l only symbolic links. Combined with -maxdepth, which limits how many directory levels find descends from the starting point:

find project -maxdepth 1 -type d

What to expect:

project
project/config
project/logs
project/node_modules
project/src
project/tests

(Again: the actual order depends on your filesystem, not on find. What must match are the six paths, not the order they appear in.)

-maxdepth 1 includes the starting point itself (level 0) plus one level down. Without that limit, find would keep descending through node_modules/lodash and any subfolder your real project has, producing hundreds of lines of noise you did not ask for.

Filtering by size with -size. The unit is stuck to the number: c for bytes, k for kibibytes, M for mebibytes, G for gibibytes. The + prefix means "greater than," - means "less than"; no prefix means the exact size.

find project -type f -size +1M

What to expect:

project/logs/access.log

Only the 2 MiB log meets "greater than 1 mebibyte." The rest of the files, created with touch, weigh 0 bytes.


Filtering by date: -mtime and -mmin

-mtime measures in days since the last modification, -mmin measures in minutes — same idea, different scale. +n means "more than n units ago," -n means "less than n units ago." One detail that surprises people the first time: find rounds to whole 24-hour periods for -mtime (whole minutes for -mmin), so -mtime +1 actually requires at least two full days, not "more than one day" in a literal sense.

Since you just created the tree in the previous section, everything in it was modified less than an hour ago. You can check:

find project -type f -mmin -60

What to expect:

project/config/app.yaml
project/config/database.yaml
project/config/secrets.env
project/logs/access.log
project/node_modules/lodash/index.js
project/src/index.js
project/src/index.js.bak
project/src/utils.js
project/tests/index.test.js

All nine files show up because all nine were modified less than 60 minutes ago. In a real-world scenario, the natural scale for "what did the last deployment touch" is usually days, not minutes:

find /var/www/app -type f -mtime -1        # modified in the last 24 hours
find /var/log -type f -mtime +30           # untouched for more than a month: archiving candidate

Combining conditions: -and, -or, and !

When you write two conditions back to back with nothing between them, find joins them with an implicit -and: the entry has to meet both. You already did this above with -type f -size +1M.

For "or" you need -o (or -or), and almost always grouping with escaped parentheses — escaped because the shell interprets ( and ) as subshell syntax if you do not protect them:

find project -type f \( -name "*.log" -o -name "*.bak" \)

What to expect:

project/logs/access.log
project/src/index.js.bak

The parentheses are not decoration: -and binds tighter than -or, so without them find project -type f -name "*.log" -o -name "*.bak" would be read as (-type f -a -name "*.log") -o (-name "*.bak") — the second branch has no -type f, and it would also find a directory that happened to be named *.bak.

To negate, ! (or -not, the equivalent that is not part of the POSIX standard but that almost any modern find accepts):

find project -type f ! -name "*.log"

What to expect:

project/config/app.yaml
project/config/database.yaml
project/config/secrets.env
project/node_modules/lodash/index.js
project/src/index.js
project/src/index.js.bak
project/src/utils.js
project/tests/index.test.js

The eight files in project, except the log.


Acting on the results: -exec and -delete

So far, every command has only printed paths. -exec lets you run any program on each result. {} is the placeholder find replaces with each found entry's path, and \; closes the instruction — escaped because ; is also shell syntax:

find project -type f -name "*.js" -exec wc -l {} \;

What to expect:

0 project/src/index.js
0 project/src/utils.js
0 project/node_modules/lodash/index.js

Zero lines on all three, because touch created them empty. There is a variant, -exec command {} +, that instead of launching a new process for every file batches several names into a single invocation — same as xargs, the tool you are going to meet in module 3 when you chain commands together.

The mandatory rule before deleting: first you list, you verify the list, and only then you act. -delete is not a separate command tacked on after an already-confirmed search: it is another condition inside the same expression, and find evaluates that expression left to right. That means the order in which you write conditions is not cosmetic.

Step 1, list with the exact same conditions you are going to use to delete:

find project -type f -name "*.bak"

What to expect:

project/src/index.js.bak

Step 2, you visually verify that list — and nothing but that list — is what you want to remove.

Step 3, only then do you add -delete at the end, without touching any other part of the expression:

find project -type f -name "*.bak" -delete

No output: no output means success. You confirm by running step 1's list-only command again and checking it no longer returns anything.

-delete also avoids the cost of launching an rm process for every file, which is what you would do with -exec rm {} \;. But the speed gain is secondary: what matters is that it remains part of the same expression evaluated in order, and that is what the first common mistake below is about.


Modern alternatives: fd

fd is a Rust rewrite built for everyday use: shorter syntax (fd pattern instead of find -iname '*pattern*'), colored, case-insensitive by default, and it respects .gitignore without you asking it to. For searching your own project, once installed, it is more pleasant than find.

This guide teaches find first for a reason that has nothing to do with preference: find comes preinstalled on any Unix machine you are going to connect to — a production server, a freshly spun-up container, a coworker's VM. fd is almost never there, and you cannot always install a new package on someone else's system. Learn find to survive on any terminal; learn fd afterward for your own machine, if you want comfort.


Common mistakes

1. Believing -delete filters first and deletes afterward (conceptual). find does not split "finding" from "acting" into two phases: it evaluates the full expression, condition by condition, left to right, for every entry. If you write -delete before the filter — find project -delete -name "*.bak" — there is no earlier condition to stop it: -delete runs on every entry from the very first moment, and it deletes the whole tree. -name "*.bak" never gets a chance to filter anything, because the entry was already deleted before reaching that part of the expression. How to spot it: the list you verified in the list-only step and the final command with -delete must have exactly the same conditions in the same relative order. How to fix it: -delete always at the end of the expression, never at the start.

2. Forgetting quotes in the -name pattern. If you write find project -name *.yaml with no quotes and there is some .yaml file in your current directory, it is the shell — not find — that expands *.yaml before the command ever receives it. find ends up searching for a literal, exact file name instead of a pattern. How to spot it: the same command gives different results depending on which folder you run it from, or fails with "No such file or directory" citing a real file name. How to fix it: single or double quotes always, -name "*.yaml".

3. Combining -o without grouping with parentheses, assuming an earlier filter applies to both branches (conceptual). -and (implied by putting two conditions back to back) binds tighter than -or. If you write find project -type f -name "*.log" -o -name "*.bak" with no parentheses, you do not get "files that are .log or .bak": you get "(entry IS a file AND ends in .log) OR (ends in .bak, whether file, directory, or whatever)." How to spot it: the result includes entries that are not regular files even though you put -type f. How to fix it: group the part you want shared between both branches with escaped parentheses: -type f \( -name "*.log" -o -name "*.bak" \).


Exercises

1. Filtering inside a subfolder. On the project tree you created, write a single find command that lists only the regular files inside project/config (without descending into any other folder of the project).

See solution
find project/config -type f

Why it works: by passing project/config as the starting point, find never leaves that subfolder — no need for -maxdepth because there is nothing further down to explore — and -type f discards any directory that might be along the way.

2. Finding the "empty" files. Every file you created with touch weighs 0 bytes; only access.log weighs 2 MiB. Write a command that lists, across all of project, the regular files under 1 kilobyte.

See solution
find project -type f -size -1k

Why it works: -size -1k means "less than 1 kibibyte" (1024 bytes). The eight touch-created files (0 bytes) meet the condition; access.log (2 MiB) is left out by a mile.

3. Combining type and alternation with parentheses. Inside project/config, write a single command that finds the .yaml or .env files, making sure the -type f condition applies to both branches.

See solution
find project/config -type f \( -name "*.yaml" -o -name "*.env" \)

Why it works: the escaped parentheses group the two name alternatives before -and (implicit between -type f and the group) combines them, so -type f ends up applying to the result of the entire alternation, not just the first branch.

4. Debugging a dangerous command. A coworker sends you this command to clean up old backups and asks you to review it before running it:

find project -delete -name "*.js.bak"

Explain what would happen if you ran it as-is, and rewrite it following the list-verify-then-delete criterion.

See solution

What would happen: -delete appears before any filter, so it gets evaluated first for every entry in the tree — including project itself and all its subfolders — and it deletes everything, with -name "*.js.bak" never getting a chance to filter anything.

Safe version, in two steps:

find project -type f -name "*.js.bak"     # 1. list and visually verify
find project -type f -name "*.js.bak" -delete   # 2. only then, with the same conditions

Why it works: by listing first with exactly the conditions you plan to use for deleting, you confirm the list contains only what you want to remove before it becomes irreversible. -delete goes at the end, never at the start.


Summary and next step

You can now locate any file on your machine by name, type, size, date, or depth, combine those conditions with -and, -or, and !, and act on the results with -exec or -delete following explicit safety judgment: list, verify, and only then act.

Before moving on you should be able to:

  • Write a find with at least two combined conditions (for example -type f -size +1M, or an alternation grouped with -o).
  • Explain why -delete has to go at the end of the expression and not the start.
  • Run the full protocol — list, verify, delete — on a real set of files with no surprises.

What you are missing now is the other half of the problem: find gives you the right file's path, but does not tell you what is inside it. That is exactly what grep solves, the next lesson: finding a line of text anywhere in an entire project.


Resources