For Every File: for Loops

A for loop runs the same commands once for every word, number or file in a list, so renaming 12 files or 200 takes one line, with ${f%.tmp} cutting the old ending off each name.

Apprentice · Unix · Linux · Updated

After this lesson you can

  • Repeat commands for every word, number or file with a for loop
  • Rename many files at once with a for loop, cutting the old ending off with ${f%.tmp}

Key takeaways

  • for name in list; do commands; done runs the commands once for each item in the list, with $name holding that item.
  • The list can be words you type, numbers from braces such as {10..1}, or files from a wildcard such as telemetry/*.tmp.
  • ${f%.tmp} is the value of f with .tmp cut off the end, so "${f%.tmp}.log" is the new name.
  • Put echo in front of mv to see what a loop would do before it does it.

Your phone saved 200 vacation photos as IMG_0001.JPG to IMG_0200.JPG, and the website you're building wants them ending in a lowercase .jpg. In a file manager that's 200 renames: click, wait, type, Enter, 200 times. A data analyst with 50 reports to convert and a web developer with a folder of images to shrink face the same thing: one job, done once for each file. Bash has a word for "for each", and it's what turns a script from a list of commands into a small application that handles any number of files.

The examples are set at Mission Control on launch day. The telemetry files, the satellite's report from each orbit, came in with the wrong ending. Make the practice folder:

mkdir -p ~/terminal-practice/mission-control/telemetry
cd ~/terminal-practice/mission-control
touch telemetry/orbit-{01..12}.tmp
ls telemetry
orbit-01.tmp  orbit-04.tmp  orbit-07.tmp  orbit-10.tmp
orbit-02.tmp  orbit-05.tmp  orbit-08.tmp  orbit-11.tmp
orbit-03.tmp  orbit-06.tmp  orbit-09.tmp  orbit-12.tmp

Why one mv can't do it

Every file must end in .log instead of .tmp. A wildcard looks like the answer, but this fails:

mv telemetry/*.tmp telemetry/*.log
mv: target 'telemetry/*.log': No such file or directory

The shell expands each pattern before mv runs (Wildcards: *, ? and [ ]). telemetry/*.tmp became twelve names. telemetry/*.log matched nothing, so it stayed as it was, and mv took that last name as the folder to move the other twelve into. mv renames one file at a time, so it has to run twelve times, once for each file. That's a loop's job.

for: the same commands for each item

A loop repeats commands. A for loop works like someone calling the roll: for each name on the list, do the same thing. Here is the launch team's go/no-go poll:

for system in fuel power radio; do
  echo "Checking $system... go"
done
Checking fuel... go
Checking power... go
Checking radio... go

Read it aloud: "for each system in fuel, power and radio, do this, done." Each time round, the variable system holds the next word, and the commands between do and done run once with it. In a script you write a loop on several lines like this; typed at the prompt, the shell shows > on each new line until you type done. On one line, a ; goes before do and before done.

Counting with {10..1}

The list can be numbers. Brace expansion (Brace Expansion: Twelve Folders in One Line) writes a range for you: {1..10} is 1 to 10, and {10..1} counts down.

for n in {10..1}; do printf '%s ' "$n"; done; echo "Liftoff"
10 9 8 7 6 5 4 3 2 1 Liftoff

printf '%s ' prints each number followed by a space instead of a new line. One limit: the shell expands braces before it looks at variables, so a range can't come from a variable.

n=5
echo {1..$n}
{1..5}

Line by Line: while read shows a loop that counts to any number.

Every file: rename them all

The most useful list is files, from a wildcard. For each file, the new name is the old one with .tmp cut off and .log added. ${f%.tmp} does the cutting: read it "f, with .tmp cut off the end". It's called parameter expansion: the shell changes a variable's value as it uses it.

f=telemetry/orbit-01.tmp
echo "${f%.tmp}.log"
telemetry/orbit-01.log

Look before you rename. With echo in front, the loop prints each mv command instead of running it. head -n 3 shows the first three of the twelve:

for f in telemetry/*.tmp; do echo mv "$f" "${f%.tmp}.log"; done | head -n 3
mv telemetry/orbit-01.tmp telemetry/orbit-01.log
mv telemetry/orbit-02.tmp telemetry/orbit-02.log
mv telemetry/orbit-03.tmp telemetry/orbit-03.log

Those are right, so take the echo out:

for f in telemetry/*.tmp; do mv "$f" "${f%.tmp}.log"; done
ls telemetry
orbit-01.log  orbit-04.log  orbit-07.log  orbit-10.log
orbit-02.log  orbit-05.log  orbit-08.log  orbit-11.log
orbit-03.log  orbit-06.log  orbit-09.log  orbit-12.log

Twelve files or 200, it's the same line. The double quotes around "$f" keep a name with a space in it in one piece (Quotes: Double, Single and None), and as with any wildcard, * skips hidden files, whose names start with a dot. Run the loop a second time, though, and mv complains:

for f in telemetry/*.tmp; do mv "$f" "${f%.tmp}.log"; done
mv: cannot stat 'telemetry/*.tmp': No such file or directory

No file matched, so bash handed the pattern itself to the loop, which ran once with it. Nothing was harmed, and Stopping Early: break, continue and Counters shows how to skip it. In zsh on a Mac, a pattern that matches nothing stops the line with no matches found instead, and mv words its messages differently there.

Try it in the terminal

Terminal Quest: Mission Control, goal 3: played right on the lesson page.

Your turn: Mission Control's thirty telemetry files end in .tmp. Rename every one of them to .log with one loop. One file hides from the wildcard: can you find it?

Check yourself

2 questions to try on the lesson page.

So what

A for loop does the same job to every item in a list, so a script that handles one file handles 200 with no extra work: the start of every tool that tidies, converts or backs up a folder.

Key terms

Cheat sheets

Related