Using a Loop in Bash to Modify Filenames Containing Spaces
I often have to batch rename a list of files. I self-host a media server, and occasionally when I add new music, the filenames need to be adjusted. Perhaps they have redundant information that will be improperly displayed in the client apps I use. Let's suppose they look like the example below, and I want to remove the redundant track numbers at the beginning.
$ ls /home/jellyfin/Music/Album
01 01 song-title.flac
02 02 song-title.flac
03 03 song-title.flac
04 04 song-title.flac
05 05 song-title.flac
...
It would be much too tedious to modify these filenames one at a time. Instead, I use a loop. First, I'll show what I did the first time I tried to do this. If you merely want to see the solution that works, skip ahead to What Does Work.
What Usually Works, But Doesn't In This Case
My media server likely won't parse those filenames very nicely. The first time I tried to batch rename files, I used this common method.
# /home/jellyfin/Music/Album
for file in *; do
mv $file ${file:3}
done
The shell expands the glob character *, creating a string of all the filenames in the current directory. Then, the for loop[1] iterates through the resulting string, tokenizing it according to any spaces, tabs, and newlines, the default values of the IFS[2] variable. This works for most cases, and I can usually get by with it since I avoid spaces and special characters in my filenames, e.g. some-file-name.extension or another_file_name.extension. However, it doesn't work for filenames containing spaces.
IFS (Internal Field Separator) is a shell variable that defines the default delimiters for the shell: whenever the shell has to parse something, it will look at IFS. With IFS set to default values, our string will be parsed with 01 as the first value , 01 as the second, song-title.flac as the third, 02 as the fourth, and so on. Obviously, that's not what we want: if we try to perform operations on those tokens, the shell with throw several errors saying “No such file or directory.”
What Does Work
To really accomplish what should be a simple task, we need the output string to be tokenized by newlines alone, with the spaces in the filenames ignored. There are two ways to accomplish this, one with a for loop and the other with a while loop.
For Loop Method
IFS=$'\n'
for file in *; do
mv "$file" "${file:3}"
done
IFS=' ^I'$'\n'
This sets the IFS variable to tokenize using only a newline as a delimiter. It's straightforward enough: the only difference from my first attempt is the setting of the IFS variable and the inclusion of double quotes around the arguments to the mv command (this ensures any glob characters in the filename won't be expanded).
The trouble is the IFS variable must be manually reset to its original value after the operation is complete with IFS=' ^I'$'\n' (you can verify the value of IFS with echo "$IFS" | cat -te). If one forgets to do this amid carrying out several other commands that rely on the value of IFS, it could create problems.
While Loop Method
while IFS= read -r line; do
mv "$line" "${line:3}"
done < <(ls)
This is more cryptic, but certainly less verbose. It has a couple advantages to the previous method. First, it only temporarily sets the IFS variable, which behaves as a local variable, going out of scope when the loop terminates. No need to reset the value! Second, the call to read does the heavy lifting with tokenizing the input from ls: it reads until it hits a newline by default. I'll break down each component of this loop.
while IFS= read -r line; do
IFS=sets the Internal Field Separator to nothing, which means the shell will not tokenize the input fromlswith the default spaces, tabs, or newlines.read -r line[3] reads the input fromlsuntil it hits a newline and stores the resulting sub-string in the variableline. The-roption preserves any backslashes that may appear in the filenames. If it is omitted,readwill interpret backslashes as escape characters and they won't show up in the final string.
mv "$line" "${line:3}"
mvtakes multiple arguments, the last of which is the destination to which the previous sources are moved. By enclosing these two arguments in double quotes, three things are ensured: any filename with spaces will not be incorrectly interpreted as multiple arguments, glob characters[4] and metacharacters[5] will be not expanded by the shell, and variable substitution will still work.${line:3}is unique to this example. Parameter expansion[6] onlineto remove the leading three characters, producing a sub-string that goes from the 4th character to the end, i.e.03 03 song-title.flacis changed to03 song-title.flac.
done < <(ls)
- The whole loop, from
whiletodonecould be viewed as a one large command. <is redirecting the input to thewhileloop from standard input to the output of thelscommand. That input is ultimately parsed by thereadcommand, one line at a time.<(ls)is a process substitution[7]. Thelscommand is processed and the output interpreted as the contents of a file. Thus,< <(ls)is kind of like two commands in one: first,ls > file, and then< file. The difference is that no file is leftover after the operation is done.
Conclusion
With all its edge cases, looping through files in Bash can be surprisingly complicated; this short write-up doesn't present all the possible ways to handle the task. These are useful techniques, and they provide a means to understand Bash deeper by touching on several concepts: loops, shell variables like IFS, globbing, input redirects, the different types of quotes (single, double, backticks), parameter expansion, and process substitution. The syntax of the while loop in particular is worth integrating into one's vocabulary.





