| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | coreutils 9.5-8.el10_2bash 5.2.26-6.el10findutils 4.10.0-5.el10file 5.45-9.el10 |
| Applies to | Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora, Arch Linux |
| Privilege | Normal user inside your own directories; sudo or root only for paths you do not own |
| Scope | Everyday file and directory tasks: create with touch and mkdir, copy files and trees, preserve attributes, move and rename, delete safely, target groups of files with wildcards and brace expansion, and verify results with ls, stat, file, and du. |
| Related guides | Redirection and pipes Grep and regular expressions Access remote systems with SSH RHCSA command reference |
Six commands cover almost every file task you perform on a Linux system: touch, mkdir, cp, mv, rm, and rmdir. They are simple to type and unforgiving when you get an argument order or a wildcard wrong, because the shell does not ask twice and there is no Trash folder waiting to save you.
This guide walks each task on a normal student account, shows the real output from a RHEL 10 lab, and points out the places where the command does something other than what beginners expect.
Linux File Management Basics
Four ideas explain most beginner mistakes with cp, mv, and rm. Get these straight and the command output stops surprising you.
- Files versus directories — A directory is itself a file that holds names pointing at other files.
cpandrmrefuse to touch a directory until you add-rfor recursive, because walking a tree is a different operation from handling one file. - Source and destination —
cpandmvalways read left to right: every argument is a source except the last one, which is the destination. When the destination is an existing directory, the sources land inside it. When it is a name that does not exist yet, you are creating that name. - Absolute versus relative paths — An absolute path starts at
/and means the same thing from anywhere. A relative path is resolved from your current directory, socp notes.txt project/only works while you are sitting in the directory that holds both. - Current working directory — Every relative path, and every wildcard you type, is evaluated against the directory you are in right now. Running
pwdbefore a recursive delete costs one second and prevents the worst class of accident.
Paths and the tree they point into are covered in the Linux filesystem hierarchy lesson; quoting and expansion rules come from the shell itself, which the Linux command line lesson explains in more depth.
Everything below runs in a scratch directory so nothing important is at risk:
mkdir ~/filelab && cd ~/filelabConfirm where you landed, because every relative path and wildcard below is resolved from here:
pwd/home/student/filelabThat is the directory every relative path on this page resolves against.
Linux File Management Quick Reference
Use this table to find the command, then read the matching section for the behavior that is easy to get wrong.
| Task | Example |
|---|---|
| Create empty file | touch file.txt |
| Create directory | mkdir project |
| Create nested directories | mkdir -p project/logs/archive |
| Copy file to a new name | cp file.txt backup.txt |
| Copy file into a directory | cp file.txt project/ |
| Copy directory tree | cp -r project backup |
| Copy tree contents only | cp -rT project backup |
| Copy keeping attributes | cp -a project archive |
| Move file into a directory | mv old.txt project/ |
| Rename file | mv old.txt new.txt |
| Delete file | rm file.txt |
| Delete empty directory | rmdir project |
| Delete directory tree | rm -r project |
| Inspect metadata | stat file.txt |
| Identify type | file filename |
| Measure disk usage | du -sh directory |
Two habits belong with that list rather than in it. Add -i when overwriting or deleting matters, and preview any wildcard with echo or printf before you hand it to rm. Both are demonstrated in the sections below. A broader command index lives in the Linux commands reference.
Create Files with touch
touch creates an empty file when the name does not exist. Its real job is updating timestamps, and file creation is the side effect you use most often.
touch notes.txttouch prints nothing when it succeeds, so ask ls what actually appeared:
ls -l notes.txt-rw-r--r--. 1 student student 0 Aug 7 22:56 notes.txtThe size column reads 0 because touch writes no content. It is not an editor: to put text in the file, use a shell redirect or open it in a text editor.
Several names in one command create several files:
touch report.txt summary.txt config.confList the directory to see all of them alongside the first file:
ls -ltotal 0
-rw-r--r--. 1 student student 0 Aug 7 22:56 config.conf
-rw-r--r--. 1 student student 0 Aug 7 22:56 notes.txt
-rw-r--r--. 1 student student 0 Aug 7 22:56 report.txt
-rw-r--r--. 1 student student 0 Aug 7 22:56 summary.txtAll four exist with mode 644 from the default umask, owned by the account that ran the command.
Now the behavior people trip on. Record all three timestamps of an existing file. Use --printf rather than -c here, because -c does not interpret the \n escapes and %n in a stat format means the file name, not a newline:
stat --printf='access=%x\nmodify=%y\nchange=%z\n' notes.txtaccess=2026-08-08 10:30:54.382771530 +0530
modify=2026-08-08 10:30:54.382771530 +0530
change=2026-08-08 10:30:54.382771530 +0530Wait two seconds so the change is easy to see, then touch the same file and read them again:
sleep 2 && touch notes.txtRead the same three fields again and compare them against the values above:
stat --printf='access=%x\nmodify=%y\nchange=%z\n' notes.txtaccess=2026-08-08 10:30:56.450771380 +0530
modify=2026-08-08 10:30:56.450771380 +0530
change=2026-08-08 10:30:56.450771380 +0530All three moved. By default touch updates both the access and the modification timestamp, and the inode change time follows because writing that metadata is itself an inode update. What touch does not do is truncate or rewrite the contents. Put text in the file and touch it again to prove that:
echo hello > notes.txtNow run touch against a file that has content in it:
touch notes.txtRead the contents back:
cat notes.txthelloThe content survived. touch does not destroy file contents, but it deliberately changes timestamps, and those are not always cosmetic: make decides what to rebuild from modification times, incremental backups select files the same way, and find -newer comparisons shift with them. Treat a stray touch on a tracked file as a real change rather than a no-op. When you want a timestamp refresh but no new file created if the name is missing, add -c:
touch -c missing.txtThe command succeeds quietly, so check whether it left a file behind:
ls missing.txtls: cannot access 'missing.txt': No such file or directory-c stayed quiet and created nothing, which is the behavior you want in scripts that should only refresh files that already exist.
Create Directories with mkdir
mkdir creates one directory per name you pass.
mkdir projectUse -d on ls so it describes the directory itself rather than listing what is inside it:
ls -ld projectdrwxr-xr-x. 2 student student 6 Aug 7 22:56 projectThe leading d marks it as a directory, and the execute bits are what let you cd into it.
Multiple names work the same way as with touch:
mkdir logs backupsBoth directories appear beside the files you already created. What mkdir will not do by default is invent missing parent directories:
mkdir project/logs/archivemkdir: cannot create directory ‘project/logs/archive’: No such file or directoryThe message is confusing the first time, because the path you asked for is exactly what is missing. mkdir is telling you that project/logs does not exist yet, so it has nowhere to create archive. Add -p to build the whole chain:
mkdir -p project/logs/archiveWalk the tree to see which levels now exist:
find project -type d | sortproject
project/logs
project/logs/archiveOne command created the intermediate logs directory and the final archive directory. Add -v when you want mkdir to report each level it created:
mkdir -pv project/src/handlersmkdir: created directory 'project/src'
mkdir: created directory 'project/src/handlers'Only the two new levels are listed, which tells you project was reused rather than recreated.
Asking for a directory that already exists is an error:
mkdir projectmkdir: cannot create directory ‘project’: File existsWith -p the same command succeeds quietly instead:
mkdir -p projectThere is no message either way, so read the exit status to see whether it counted as success:
echo $?0That exit status of 0 is why mkdir -p is the form used in scripts. It means "make sure this path exists" rather than "create this now", so re-running your script does not fail on the second pass.
Copy Files with cp
cp takes sources first and the destination last. Copying to a name that does not exist creates that name:
cp notes.txt notes-backup.txtName both files in one ls so you can compare the copy against the original:
ls -l notes.txt notes-backup.txt-rw-r--r--. 1 student student 6 Aug 7 22:56 notes-backup.txt
-rw-r--r--. 1 student student 6 Aug 7 22:56 notes.txtBoth files hold the same six bytes and the original is untouched, which is the whole difference between cp and mv.
When the destination is an existing directory, the file keeps its name and lands inside:
cp notes.txt project/Look inside the destination directory to confirm the name it arrived under:
ls -l project/total 4
drwxr-xr-x. 3 student student 21 Aug 7 22:56 logs
-rw-r--r--. 1 student student 6 Aug 7 22:56 notes.txt
drwxr-xr-x. 3 student student 22 Aug 7 22:56 srcThe copy is project/notes.txt. Writing the trailing slash is a good habit: if you mistype the directory name, cp fails instead of silently creating a regular file with that name.
More than two arguments only makes sense when the last one is a directory:
cp report.txt summary.txt config.conf backups/Check that each source kept its own name rather than overwriting the others:
ls backups/config.conf
report.txt
summary.txtAll three sources kept their names inside backups. Copy notes.txt there as well, since the next section needs a file that already exists at the destination:
cp notes.txt backups/Add -v when you want cp to narrate what it did:
cp -v notes.txt logs/'notes.txt' -> 'logs/notes.txt'The arrow shows the resolved destination path, which is useful when a shell expansion produced the argument list.
Control overwriting
By default cp overwrites the destination without a word. Change the source and copy again:
echo changed > notes.txtCopy it over the file that is already sitting in project/:
cp notes.txt project/Read the destination to see which version survived:
cat project/notes.txtchangedThe previous project/notes.txt is gone with no prompt and no backup. Add -i to be asked first:
cp -i notes.txt backups/notes.txtcp: overwrite 'backups/notes.txt'? nAnswering n leaves the destination exactly as it was. Answer y and the copy proceeds. When you want the opposite default, -n skips existing files instead of asking:
cp -n notes.txt backups/notes.txtNothing is printed and nothing is asked, so the exit status is the only signal of what happened:
echo $?0The existing file was left alone and the command still reported success, so -n is safe inside scripts that must not clobber data. On coreutils 9 you can spell the same intent as cp --update=none, which reads more clearly in automation.
Copy Directories Recursively
Point cp at a directory without -r and it declines:
cp project project-copycp: -r not specified; omitting directory 'project'The message names the fix. Add -r and the whole tree is copied:
cp -r project project-backupWalk the new tree to see what shape it took:
find project-backup | sortproject-backup
project-backup/logs
project-backup/logs/archive
project-backup/notes.txt
project-backup/src
project-backup/src/handlersBecause project-backup did not exist, it became a copy of project itself. The | sort is there for a reason: find reports entries in directory order, which is neither alphabetical nor stable between systems, so sorting is what makes two listings comparable. This is where destination semantics matter most: run the identical command a second time, now that the destination does exist, and the result changes.
cp -r project project-backupLimit the walk to two levels, which is enough to show where the second copy went:
find project-backup -maxdepth 2 | sortproject-backup
project-backup/logs
project-backup/logs/archive
project-backup/notes.txt
project-backup/project
project-backup/project/logs
project-backup/project/notes.txt
project-backup/project/src
project-backup/src
project-backup/src/handlersThe second run nested a full project directory inside project-backup. Nothing was overwritten and nothing warned you. Whether cp -r src dest means "become dest" or "go inside dest" depends entirely on whether the destination already exists, so check with ls -d before copying into a path you did not create moments ago.
A trailing slash on the source does not change this. Many readers expect it to mean "contents only" because that is how rsync behaves:
mkdir existing-destCopy the tree with a trailing slash on the source and see whether it means anything:
cp -r project/ existing-dest/List the destination to find out where the files ended up:
ls existing-destprojectThe source directory was nested anyway. Clear that nested copy out and add a hidden file to the source, because dotfiles are where the next comparison gets interesting:
rm -r existing-dest/projectCreate the hidden file in the source tree:
touch project/.envTo write the contents of a tree directly into an existing directory, use -T, which tells cp to treat the destination as the target itself rather than a container:
cp -rT project existing-destUse ls -a this time, because the point of the comparison is whether .env came across:
ls -a existing-dest.
..
.env
logs
notes.txt
srcNow existing-dest holds the contents of project with no extra level, and the dotfile came along too. That last detail deserves its own demonstration, because the wildcard approach behaves differently:
mkdir star-dest && cp -r project/* star-dest/List everything again, including hidden names:
ls -a star-dest.
..
logs
notes.txt
src.env is missing. The shell expanded * before cp ever ran, and by default * does not match names beginning with a dot. Using project/. as the source avoids the shell entirely:
mkdir dot-dest && cp -r project/. dot-dest/Compare this listing against the wildcard one:
ls -a dot-dest.
..
.env
logs
notes.txt
srcThe dotfile is present. For configuration directories such as .ssh or .config this distinction is the difference between a complete copy and a quietly broken one.
One more guard is worth seeing. Copying a directory into its own subdirectory would recurse forever, so cp stops:
cp -r project project/innercp: cannot copy a directory, 'project', into itself, 'project/inner'The refusal is not completely clean. cp creates the destination and starts copying entries into it before it detects the loop, so project/inner is left behind and is not necessarily empty. Remove it recursively rather than with rmdir, or it turns up in every later listing of the tree:
rm -r project/innerFor large trees or copies you need to resume, cp is the wrong tool and rsync is the right one. Copying to another machine is a different job again, handled by scp over an SSH connection.
Preserve File Attributes While Copying
A plain cp creates a new file, so it gets a new timestamp and, when no default ACL applies, a mode filtered through your umask. Set up a file with distinctive metadata:
chmod 640 config.conf && touch -d '2026-01-15 09:30:00' config.confRecord the mode and modification time so there is a baseline to compare against:
stat -c '%a %y %n' config.conf640 2026-01-15 09:30:00.000000000 +0530 config.confCopy it normally and compare:
cp config.conf plain-copy.confRead the same two fields on the copy:
stat -c '%a %y %n' plain-copy.conf640 2026-08-07 22:57:19.218088197 +0530 plain-copy.confThe mode came across but the modification time is now, because the copy is genuinely a new file. -p preserves mode, ownership where permitted, and timestamps:
cp -p config.conf preserved-copy.confCheck the timestamp on this copy:
stat -c '%a %y %n' preserved-copy.conf640 2026-01-15 09:30:00.000000000 +0530 preserved-copy.confJanuary is back. Do not read the matching 640 above as proof that plain cp keeps permissions, because your umask is applied to new files. A world-writable source makes the difference visible:
touch wide.conf && chmod 666 wide.confCopy it with no flags at all:
cp wide.conf wide-plain.confPrint the mode of the source and the copy side by side:
stat -c '%a %n' wide.conf wide-plain.conf666 wide.conf
644 wide-plain.confThe umask of 022 stripped both write bits from the copy, which is the same masking every create call goes through and is covered in umask on Linux. With -p the mode is reproduced exactly:
cp -p wide.conf wide-p.confRead the mode on that copy:
stat -c '%a %n' wide-p.conf666 wide-p.confFor whole trees, -a is the flag to remember. It means archive mode: recursive, plus preserve everything preservable. Add a symbolic link to the lab tree first, so there is something for the flag to prove.
ln -s ../config.conf project/config-linkCopy the tree in archive mode:
cp -a project project-archiveInspect the copied link with ls -l, which shows both the type character and the target:
ls -l project-archive/config-linklrwxrwxrwx. 1 student student 14 Aug 7 22:57 project-archive/config-link -> ../config.confThe symbolic link was copied as a link rather than replaced by a copy of its target. cp -r also keeps symlinks as symlinks, but it does not carry directory timestamps, which -a does. Make a plain recursive copy and compare the two directory times:
cp -r project project-rPrint the modification time of the original directory and the -r copy together:
stat -c '%y %n' project project-r2026-08-07 22:57:19.253088194 +0530 project
2026-08-07 22:57:19.341399118 +0530 project-rThe -r copy carries the time of the copy operation. A few limits are worth stating plainly rather than discovering later:
- Ownership — preserving the owner requires privilege. As a normal user your copies belong to you no matter which flag you pass, so restoring another account's files needs chown afterwards or root at copy time.
- Hard links —
-akeeps hard-linked files linked inside the copied tree;-ralone can turn them into separate files. - ACLs and extended attributes —
-aattempts to preserve the source's ACLs and extended attributes. A plaincpdoes not preserve the source ACL; a newly created destination instead gets permissions according to its mode, umask, and any default ACL inherited from the destination directory. - SELinux context — on RHEL this is the surprise. A plain copy is labeled for the destination path, not the source, and
-aonly tries to carry the source label across.
That last point is easy to verify. The lab file carries the home-directory label:
ls -Z config.confunconfined_u:object_r:user_home_t:s0 config.confCopy it onto a different filesystem and read the label again:
cp config.conf /dev/shm/Use ls -Z to print the SELinux context of the copy:
ls -Z /dev/shm/config.confunconfined_u:object_r:user_tmp_t:s0 /dev/shm/config.confThe copy was labeled user_tmp_t for its new location rather than keeping the source label. Archive mode attempts to carry the original context instead:
cp -a config.conf /dev/shm/config-a.confRead the context on this copy and compare it with the plain one:
ls -Z /dev/shm/config-a.confunconfined_u:object_r:user_home_t:s0 /dev/shm/config-a.confIn this lab cp -a did preserve user_home_t. Read that as the attempt succeeding, not as a guarantee, because the two flags differ in how they report trouble.
Archive mode tries to preserve the SELinux context and extended attributes but ignores any failure and prints no diagnostic. A copy that quietly fell back to the destination's default label looks exactly like one that worked.
--preserve=context is the strict form: it preserves the context or fails with full diagnostics. Use it whenever a missing label has to be an error rather than a surprise.
Four cases cover almost everything you will hit, and they do not all behave the same way:
- Plain copy to a new file — the new file normally gets the label the destination path is supposed to have, which is what happened above.
- Copy over an existing file — the destination file normally keeps the context it already had, since
cpwrites into an inode that is already labeled. cp -aor--preserve=context— the source context is carried across, with the reporting difference described above.mv— the file keeps its original context even in a new directory, so the old label travels with it.
That last case is the one that breaks services, and it is the reason the next section on moving files matters beyond convenience.
Move and Rename Files with mv
mv covers two intents with one command. Create the sample files this section moves around:
touch draft.txt one.log two.log three.log old-name dest.txt src.txtGive mv a new name in the same directory and it renames:
mv draft.txt release-notes.txtAsk for the old name rather than the new one, since that is what proves a rename is not a copy:
ls -l draft.txtls: cannot access 'draft.txt': No such file or directoryThe old name is gone rather than duplicated. Give it a directory and it moves, keeping the filename:
mv release-notes.txt project/List the destination to confirm the filename was carried over unchanged:
ls project/config-link
logs
notes.txt
release-notes.txt
srcSeveral sources with a directory last move them together:
mv one.log two.log three.log logs/Check that all three landed rather than overwriting one another:
ls logs/notes.txt
one.log
three.log
two.logAll three arrived in logs. As with cp, -v reports what happened, which is worth using on directory renames where a typo is expensive:
mv -v old-name new-namerenamed 'old-name' -> 'new-name'Overwriting is silent by default here too, and with mv the loss is worse because the source is consumed as well. Give the two files distinct contents so the result is unambiguous:
printf 'source-content\n' > source.txtGive the file you are about to overwrite different contents:
printf 'target-content\n' > target.txtMove one onto the other with no flags:
mv source.txt target.txtRead what the target holds now:
cat target.txtsource-contentThe previous contents of target.txt are unrecoverable. Use -i to be prompted:
mv -i src.txt dest.txtmv: overwrite 'dest.txt'? nDeclining leaves both files exactly as they were. -n makes skipping the default without any prompt:
mv -n src.txt dest.txtNothing is printed, so check whether the source was consumed:
ls src.txtsrc.txtThe source is still there because the move was refused, which is the behavior you want when a script must never replace an existing target. For pattern-based bulk renaming across many files, a loop or a dedicated tool beats repeated mv calls; see renaming files in Linux for those techniques.
Move Directories
Directories need no special flag with mv, which surprises people who just learned cp -r. Build a small tree to move:
mkdir -p reports/januaryMoving it into an existing directory nests the tree inside it:
mv reports project/Walk the moved tree at its new location:
find project/reports | sortproject/reports
project/reports/januaryThe january subdirectory came along, because mv relocated the entry rather than walking the tree. Confirm the source is really gone rather than copied:
ls -d reportsls: cannot access 'reports': No such file or directoryThat is the check worth repeating after any move. Within one filesystem mv only rewrites directory entries, and the inode number proves it. Note the inode of a fresh file first:
touch inode-test.txtThe %i format prints the inode number, which is the file's identity on that filesystem:
stat -c '%i %n' inode-test.txt1432743 inode-test.txtMove it into a subdirectory on the same filesystem:
mv inode-test.txt logs/inode-test.txtRead the inode number at the new path:
stat -c '%i %n' logs/inode-test.txt1432743 logs/inode-test.txtSame inode, so no data was read or written and the operation was instant regardless of file size. Crossing a filesystem boundary is a different story:
mv logs/inode-test.txt /dev/shm/inode-test.txtRead the inode number once more, now that the file sits on a different filesystem:
stat -c '%i %n' /dev/shm/inode-test.txt6 /dev/shm/inode-test.txtThe new inode number shows mv fell back to copying the data and then deleting the source. Three consequences follow from that fallback:
- Time — a cross-filesystem move of a large tree takes as long as a copy, because it is one.
- Space — you need room for the data on both filesystems until the move finishes.
- Interruption —
mvcopies first and removes the source only after the copy succeeds. When the copy reports a failure, GNUmvcleans up its own partially created destination, so an ordinary error does not strand half a file. An abrupt end such asSIGKILL, a crash, or a storage failure givesmvno chance to clean up, and moving several directories at once can leave earlier ones already migrated. Check both sides before retrying.
The SELinux label travels as well, and that is the consequence most likely to cost you an afternoon. Read the context of the file that just crossed filesystems:
ls -Z /dev/shm/inode-test.txtunconfined_u:object_r:user_home_t:s0 /dev/shm/inode-test.txtThe data was rewritten into a new inode on another filesystem, yet the context came along instead of matching the new location. Inside /dev/shm nothing cares.
Move a file out of your home directory into a service path such as /var/www/html and it arrives labeled user_home_t, a type that has no business outside home directories. The web server is denied access even though the mode bits look fine. Red Hat documents this exact case.
The fix is restorecon against the destination, which applies the context the path is supposed to have. ls -Z shows what you have and matchpathcon -V spells out the mismatch before you correct it.
Delete Files with rm
Start with the simplest form. rm removes the names you list:
rm target.txtAsk for the file again to confirm it is really gone:
ls target.txtls: cannot access 'target.txt': No such file or directoryNothing was printed on success, which is normal for rm. Several names work in one command, so create a few throwaway files:
touch a.tmp b.tmp c.tmp important.txt f1.tmp f2.tmp f3.tmp f4.tmp f5.tmpRemove three of them in a single command:
rm a.tmp b.tmp c.tmpDeleting a name that does not exist is an error you will see in scripts:
rm definitely-missing.txtrm: cannot remove 'definitely-missing.txt': No such file or directoryThe message goes to standard error, and a script branches on the exit status rather than the text:
echo $?1-f suppresses that complaint and returns success, which is exactly why cleanup scripts use it:
rm -f definitely-missing.txtNo message appears this time, so read the status to see how the same missing file was treated:
echo $?0Understand what -f actually changes. It does not delete "harder"; it silences missing-file errors and skips the confirmation prompt.
When both -f and -i appear on one command line, the order decides which one wins. -f ignores any -i that came before it, and -i ignores any -f that came before it, so the last of the two is the one in effect. Reading the flags left to right tells you what will happen.
For confirmation before each removal, use -i:
rm -i important.txtrm: remove regular empty file 'important.txt'? nThe file survived because the answer was n. Prompting once per file becomes unbearable across dozens of names, so GNU rm offers -I, which asks a single question when you pass more than three files:
rm -I f1.tmp f2.tmp f3.tmp f4.tmp f5.tmprm: remove 5 arguments? yOne prompt reporting the count is genuinely useful: if you expected three files and it says 5 arguments, your wildcard matched more than you intended and you can still answer n.
rm unlinks files immediately. There is no undo and no desktop Trash folder involved, even on a graphical system. Anything you delete at the shell is gone unless you have a backup or a filesystem snapshot.
Recovery after the fact means filesystem forensics on an unmounted volume, with poor odds; the approach and its limits are described in undoing rm in Linux. Prevention is far cheaper than recovery, so preview wildcards and keep backups of anything you cannot recreate.
One RHEL-specific detail catches people out. The root account ships with an alias:
type rmrm is aliased to `rm -i'Interactive prompts appear for root but not for a normal user, and not inside scripts or over ssh with a non-interactive shell, because aliases are not expanded there. Never build a habit on top of that prompt: the muscle memory follows you to systems and contexts where it does not exist.
That alias is also where the option ordering above stops being trivia. Because the alias expands first, typing rm -f file as root runs rm -i -f file, and since -f comes after -i it cancels the prompt entirely. The safety net you think you have disappears on exactly the command where you were least careful.
Delete Directories
Two commands remove directories, and picking the right one is a safety decision.
rmdir deletes only empty directories. Point it at a populated tree and it refuses:
rmdir projectrmdir: failed to remove 'project': Directory not emptyThat refusal is a feature. It means rmdir can never destroy data you forgot about. On an empty directory it succeeds, and -v confirms the action:
mkdir empty-dirRemove it with -v so the command reports what it did:
rmdir -v empty-dirrmdir: removing directory, 'empty-dir'rmdir -p walks up and removes empty parents in one call, which cleans up a nested scaffold left over from mkdir -p:
mkdir -p a/b/cRemove the deepest path and let rmdir work back up the chain:
rmdir -p a/b/cAsk for the top-level directory to see how far up the removal went:
ls -d als: cannot access 'a': No such file or directoryAll three levels went away because each became empty in turn. Anything with contents needs rm -r, and the habit worth building is to look before you remove. Build a populated tree:
mkdir -p scratch/subPut a file at each level so the tree is not empty:
touch scratch/one.txt scratch/sub/two.txtList it before deleting anything:
find scratch | sortscratch
scratch/one.txt
scratch/sub
scratch/sub/two.txtFour entries, all expected, so the delete is safe to run:
rm -r scratchConfirm the whole tree went, not just the files inside it:
ls -d scratchls: cannot access 'scratch': No such file or directoryThe tree is gone with no output and no prompt. Reach for rm -ri when you want to walk a tree interactively, and treat rm -rf as a deliberate choice rather than your default typing pattern: -f removes the last chance to notice that the path expanded to something other than what you meant. If the data has any value, archive it first with tar and delete the archive later once you are certain.
Use Wildcards for Multiple Files
Wildcards are a shell feature, not a cp or rm feature. The shell expands the pattern into a list of matching filenames and hands that list to the command, which never sees your *. That single fact explains most wildcard accidents.
Work in a fresh directory so the matches below are exactly what you see:
mkdir ~/filelab/globs && cd ~/filelab/globsCreate a mix of logs, numbered files, and one hidden file, since each one exposes a different matching rule:
touch app-2026-01.log app-2026-02.log app-2026-03.log file1.txt file2.txt file3.txt file10.txt .hidden.txtBecause expansion happens first, you can preview any pattern with a harmless command. printf prints one match per line:
printf '%s\n' *.logapp-2026-01.log
app-2026-02.log
app-2026-03.logThree files, exactly as intended. Prefixing the real command with echo shows the argument list the command would receive:
echo rm *.logrm app-2026-01.log app-2026-02.log app-2026-03.logThat is the dry run worth doing before every destructive wildcard. Reading it and then removing the echo costs seconds and catches the pattern that matched a directory or a file you needed.
The common patterns are quick to learn:
| Pattern | Matches |
|---|---|
* |
Any number of characters, including none |
? |
Exactly one character |
[abc] |
One character from the set |
[0-9] |
One character in the range |
[!abc] |
One character not in the set |
? matching exactly one character is easy to demonstrate:
ls file?.txtfile1.txt
file2.txt
file3.txtfile10.txt exists in this directory but did not match, because 10 is two characters. A set matches only the characters you list:
ls file[12].txtfile1.txt
file2.txtA range covers consecutive characters:
ls file[0-9].txtfile1.txt
file2.txt
file3.txtRanges work on single characters, so [0-9] will never match 10. One more rule saves configuration directories from half-copies:
echo *.txtfile10.txt file1.txt file2.txt file3.txtThe hidden .hidden.txt in this directory is absent, because by default * does not match names beginning with a dot. That default is a Bash setting rather than a law of the shell: shopt -s dotglob makes * match dotfiles too, and shopt -u dotglob puts it back.
Leave it off unless you have a reason, and check it with shopt dotglob if a glob behaves unexpectedly on someone else's system.
Finally, a pattern that matches nothing is passed through literally by default:
ls *.mdls: cannot access '*.md': No such file or directoryBash handed the unexpanded *.md to ls, which then looked for a file with that literal name. Seeing the pattern quoted back at you in an error message is the signal that nothing matched. When you need to select files by age, size, or type instead of by name, that is a job for the find command.
Use Brace Expansion
Brace expansion also happens in the shell, but it works differently from a wildcard: it generates strings from a list or a range, whether or not matching files exist. echo shows exactly what the shell produces:
echo file{1..5}.txtfile1.txt file2.txt file3.txt file4.txt file5.txtFive names were generated from a range, and none of them had to exist. That makes braces the natural tool for creating things:
touch file{6..8}.txtList the single-digit names to see the new files beside the originals:
ls file?.txtfile1.txt
file2.txt
file3.txt
file6.txt
file7.txt
file8.txtThree files created in one command. A comma list builds a directory scaffold just as quickly:
mkdir -p site/{src,docs,logs}Walk the result to confirm all three subdirectories exist under one parent:
find site -type d | sortsite
site/docs
site/logs
site/srcThe classic idiom leaves one item of the list empty to build a backup name from the original:
printf 'port=8080\n' > app.confCopy it to a backup name using an empty first list item:
cp app.conf{,.bak}List both names to confirm what the expansion produced:
ls -l app.conf app.conf.bak-rw-r--r--. 1 student student 10 Aug 7 22:58 app.conf
-rw-r--r--. 1 student student 10 Aug 7 22:58 app.conf.bakThe shell expanded that to cp app.conf app.conf.bak, which is why it looks cryptic and works reliably. The distinction from globbing is worth stating once more:
- Globs match —
*.txtproduces only names that exist on disk, and expands to the literal pattern when nothing matches. - Braces generate —
{1..5}produces text regardless of the filesystem, so it works for names you are about to create. - Order matters — braces expand before globs, so
ls {a,b}*.txtfirst becomesls a*.txt b*.txtand then each glob is matched.
Because braces do not check the filesystem, they are safe to preview and useful for planning names:
echo backup-{2026-01,2026-02}.tar.gzbackup-2026-01.tar.gz backup-2026-02.tar.gzNeither archive exists, yet the shell produced both names, ready to pass to whatever command creates them.
Inspect Files Before and After an Operation
Verification is what turns a risky command into a routine one. Four tools answer the questions that matter, and none of them change anything. Return to the lab directory and create a sample file:
cd ~/filelab && printf 'plain text\n' > sample.txtfile reports what a name actually is, based on content rather than extension:
file sample.txtsample.txt: ASCII textPoint the same command at a directory and the answer changes:
file projectproject: directoryKnowing you are pointing at a directory explains in advance why cp will ask for -r. The same command identifies binaries:
file /usr/bin/ls/usr/bin/ls: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=ec104cdb4bbecc0f336429154004f7474c3c7bbc, for GNU/Linux 3.2.0, strippedstat prints the metadata behind every operation on this page: type, size, mode, owner, inode, and all three timestamps.
stat sample.txtFile: sample.txt
Size: 11 Blocks: 8 IO Block: 4096 regular file
Device: 253,0 Inode: 36916569 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1001/ student) Gid: ( 1001/ student)
Context: unconfined_u:object_r:user_home_t:s0
Access: 2026-08-07 23:43:34.440691269 +0530
Modify: 2026-08-07 23:43:34.436929737 +0530
Change: 2026-08-07 23:43:34.436929737 +0530
Birth: 2026-08-07 23:43:34.436929737 +0530Modify is what touch and a write change; Change tracks inode updates such as a mode change; Links reveals hard links. For scripted checks, -c selects just the fields you want:
stat -c '%F %s bytes mode=%a owner=%U:%G' sample.txtregular file 11 bytes mode=644 owner=student:studentThat one line is a compact before-and-after record when you copy files between accounts. du answers how much space a tree occupies, which is the number to check before copying or deleting. Build a directory with known contents:
mkdir -p sized/subWrite a 200K file at the top level:
dd if=/dev/zero of=sized/big.bin bs=1K count=200 status=nonePut a smaller one in the subdirectory so the two levels report different totals:
dd if=/dev/zero of=sized/sub/small.bin bs=1K count=40 status=noneThen measure it:
du -sh sized240K sizedThe -s gives a single summary and -h makes it readable. Drop -s to see each subdirectory contribute to the total:
du -h sized40K sized/sub
240K sizedThe subdirectory accounts for 40K of the 240K total, so the rest sits in files directly under sized. Deeper reporting on space, including free space per filesystem, belongs to df and du.
ls remains the fastest check after any operation. Use -l with -h for readable sizes:
ls -lh sizedtotal 200K
-rw-r--r--. 1 student student 200K Aug 7 22:58 big.bin
drwxr-xr-x. 2 student student 23 Aug 7 22:58 subAdd -a for dotfiles, and -R to descend into subdirectories when you want to confirm a recursive copy landed correctly; listing files recursively covers the output format in more detail. When the question is "how many files did that create", counting is its own small skill, covered in counting files in a directory.
Handle Filenames with Spaces or Special Characters
The shell splits arguments on spaces, so a filename containing one arrives as two arguments unless you protect it. Create the awkward names this section works with:
touch "my report.txt" "quarterly plan.txt" ./-draft.txt ./-report.txtListing one of them works as long as the name stays quoted:
ls -l "my report.txt"-rw-r--r--. 1 student student 0 Aug 7 22:58 my report.txtNow the mistake, which is worth showing honestly because the result is not just an error:
rm my report.txtrm: cannot remove 'my': No such file or directoryrm received two arguments. It complained about my, which does not exist, and said nothing about the second one. In this lab a file named report.txt did exist, so that unrelated file was deleted while the message drew attention elsewhere. A partial error message is not proof that nothing happened.
Quoting fixes it, and works with either quote style:
mv "my report.txt" my_report.txtEscaping the space with a backslash is equivalent:
ls -l quarterly\ plan.txt-rw-r--r--. 1 student student 0 Aug 7 22:58 quarterly plan.txtUse quotes when you type paths yourself and quotes around variables in scripts, since "$file" is what keeps a space-containing value in one piece. Tab completion escapes spaces for you, which is another reason to let it type paths.
A filename beginning with - breaks a different rule: the command reads it as an option.
rm -draft.txtrm: invalid option -- 'a'
Try 'rm ./-draft.txt' to remove the file '-draft.txt'.
Try 'rm --help' for more information.rm parsed the name as the flags -d -r -a -f -t, rejected -a, and helpfully printed the fix. Two forms work. A -- separator tells the command that nothing after it is an option:
rm -- -draft.txtOr give the file a path, so the argument no longer starts with a dash:
rm ./-report.txtConfirm the awkward name is gone:
ls ./-report.txtls: cannot access './-report.txt': No such file or directoryBoth removed the file cleanly. The ./ prefix is the form I reach for first, because it works with any command rather than only with the ones that honor --.
Common File Management Errors
Most failures come back to a handful of causes. This table maps the message to the reason and the fix.
| Message or symptom | Likely cause | Fix |
|---|---|---|
No such file or directory |
Typo, wrong working directory, or missing parent | pwd and ls the parent; mkdir -p for missing parents |
Permission denied |
No write permission on the parent directory | Check ls -ld on the parent; use a path you own or run with sudo |
-r not specified; omitting directory |
cp given a directory without recursion |
Add -r, or -a to preserve attributes |
Directory not empty |
rmdir used on a populated directory |
Inspect with find, then rm -r when certain |
| Destination gained an extra level | Recursive copy into a destination that already existed | Use cp -rT source dest for contents only |
cannot copy a directory into itself |
Destination sits inside the source tree | Copy to a sibling path, then remove the leftover empty directory |
invalid option -- 'a' |
Filename begins with - |
rm ./-name or rm -- -name |
Operation not permitted on delete |
File carries the immutable attribute | Check with lsattr, then clear it using chattr |
| Wildcard matched more files than expected | Pattern too broad, or hidden files ignored | Preview with echo or printf; use -I on rm |
| Deleted file cannot be recovered | rm unlinks immediately with no Trash |
Restore from backup or snapshot; adopt -i habits |
| Service cannot access a moved or copied file | The file retained a source SELinux context that does not match its destination | Check with ls -Z, then use restorecon to apply the context the path is supposed to have |
Practical File Management Example
This walkthrough ties the commands together on one small release directory. Every command below ran in sequence on the lab host.
Build the scaffold with one brace expansion:
mkdir -p webapp/{config,logs,output,tmp}Walk it once to confirm the layout before putting anything into it:
find webapp -type d | sortwebapp
webapp/config
webapp/logs
webapp/output
webapp/tmpFour directories exist under a single parent. Next, create the files the rest of the walkthrough manipulates:
printf 'listen_port=8080\nlog_level=info\n' > webapp/config/app.confAdd three throwaway build artifacts with a brace range:
touch webapp/tmp/build-{1..3}.tmpThen write the report that the walkthrough later promotes out of tmp:
printf 'id,total\n1,42\n2,17\n' > webapp/tmp/report.csvList both directories to check the starting state:
ls webapp/config webapp/tmpwebapp/config:
app.conf
webapp/tmp:
build-1.tmp
build-2.tmp
build-3.tmp
report.csvThe configuration file, three temporary build artifacts, and a generated report.csv are all in place. Copy the configuration to a backup name using the brace idiom, keeping mode and timestamps with -a:
cp -a webapp/config/app.conf{,.bak}Compare the two files in the directory listing:
ls -l webapp/config/total 8
-rw-r--r--. 1 student student 32 Aug 7 22:59 app.conf
-rw-r--r--. 1 student student 32 Aug 7 22:59 app.conf.bakBoth files are the same size, so the copy is complete. Rename the backup to carry a date, which is a move within one directory:
mv -v webapp/config/app.conf.bak webapp/config/app.conf.2026-08-07renamed 'webapp/config/app.conf.bak' -> 'webapp/config/app.conf.2026-08-07'The verbose output confirms the new name before you rely on it. Move the finished report out of the scratch directory into output:
mv webapp/tmp/report.csv webapp/output/List both sides of the move in one command, since a move has to be checked at both ends:
ls webapp/output/ webapp/tmp/webapp/output/:
report.csv
webapp/tmp/:
build-1.tmp
build-2.tmp
build-3.tmpreport.csv is in output and only the disposable build files remain in tmp. With the report safely relocated, remove the temporary tree:
rm -r webapp/tmpCheck that the directory itself went, not only its contents:
ls -d webapp/tmpls: cannot access 'webapp/tmp': No such file or directoryThe scratch directory is gone. Verify the final state rather than assuming it:
find webapp | sortwebapp
webapp/config
webapp/config/app.conf
webapp/config/app.conf.2026-08-07
webapp/logs
webapp/output
webapp/output/report.csvConfiguration plus dated backup, an empty log directory, and the report in output. A size check closes the loop:
du -sh webapp12K webappConfirm the backup really holds the configuration rather than an empty file:
cat webapp/config/app.conf.2026-08-07listen_port=8080
log_level=infoThe content came through, so the copy, the rename, and the delete all did what was intended. Reading a small file with cat is fine, but on anything larger compare the two files with cmp instead, which reports the first differing byte and stays silent when they match. That create, copy, rename, move, delete, verify sequence is the pattern behind most real maintenance work.
When you are finished experimenting, remove the practice directory:
rm -r ~/filelabReferences
- coreutils manual — cp invocation
- coreutils manual — mv invocation
- coreutils manual — rm invocation
- Bash manual — filename expansion and brace expansion
- glob(7) — pattern matching
- Using SELinux on RHEL 10 — troubleshooting problems related to SELinux
Summary
File management on Linux comes down to six commands and a habit of verifying. touch creates empty files and updates timestamps without ever truncating content. mkdir -p builds a whole path and stays quiet when it already exists, which is why scripts prefer it. cp duplicates and needs -r for trees, while mv relocates or renames and needs no flag for directories at all. rm deletes files, rmdir refuses to delete anything non-empty, and rm -r handles a populated tree.
The behavior that catches people is destination semantics rather than the commands themselves. A recursive copy becomes the destination when that path does not exist and nests inside it when it does. A trailing slash on the source changes nothing about that, so cp -rT is the flag for contents-only copies.
Wildcards are expanded by the shell before the command runs. With Bash's default settings * does not match a leading dot, which is how half-copied configuration directories happen. Braces generate names whether or not files exist, which is what makes cp app.conf{,.bak} work.
Plain copies get a fresh timestamp, a mode filtered through your umask or an inherited default ACL, and the SELinux label their destination path is supposed to have, which is usually what a service needs. Reach for -a or --preserve=context only when the copy must match the original, and remember that a move carries the old label into the new location.
The habit worth keeping is small: run pwd, preview a wildcard with echo, then check the result with ls, stat, find, or du. Deletion has no undo, so that preview is the only safety net that reliably exists. From here, permissions and ownership are the natural next step, and the RHCSA tutorial syllabus puts these file operations in sequence with the rest of the essential-tools material.

