Create, Copy, Move and Delete Files in Linux

Tested on RHEL 10.2 (Coughlan)
Package coreutils 9.5-8.el10_2
bash 5.2.26-6.el10
findutils 4.10.0-5.el10
file 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.

IMPORTANT
This guide covers creating, copying, moving, and deleting files and directories. Reading and changing modes and ownership belongs in Linux file permissions, and inodes versus symlink behavior belongs in hard and symbolic links.

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. cp and rm refuse to touch a directory until you add -r for recursive, because walking a tree is a different operation from handling one file.
  • Source and destinationcp and mv always 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, so cp 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 pwd before 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:

bash
mkdir ~/filelab && cd ~/filelab

Confirm where you landed, because every relative path and wildcard below is resolved from here:

bash
pwd
output
/home/student/filelab

That 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.

bash
touch notes.txt

touch prints nothing when it succeeds, so ask ls what actually appeared:

bash
ls -l notes.txt
output
-rw-r--r--. 1 student student 0 Aug  7 22:56 notes.txt

The 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:

bash
touch report.txt summary.txt config.conf

List the directory to see all of them alongside the first file:

bash
ls -l
output
total 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.txt

All 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:

bash
stat --printf='access=%x\nmodify=%y\nchange=%z\n' notes.txt
output
access=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 +0530

Wait two seconds so the change is easy to see, then touch the same file and read them again:

bash
sleep 2 && touch notes.txt

Read the same three fields again and compare them against the values above:

bash
stat --printf='access=%x\nmodify=%y\nchange=%z\n' notes.txt
output
access=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 +0530

All 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:

bash
echo hello > notes.txt

Now run touch against a file that has content in it:

bash
touch notes.txt

Read the contents back:

bash
cat notes.txt
output
hello

The 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:

bash
touch -c missing.txt

The command succeeds quietly, so check whether it left a file behind:

bash
ls missing.txt
output
ls: 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.

bash
mkdir project

Use -d on ls so it describes the directory itself rather than listing what is inside it:

bash
ls -ld project
output
drwxr-xr-x. 2 student student 6 Aug  7 22:56 project

The 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:

bash
mkdir logs backups

Both directories appear beside the files you already created. What mkdir will not do by default is invent missing parent directories:

bash
mkdir project/logs/archive
output
mkdir: cannot create directory ‘project/logs/archive’: No such file or directory

The 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:

bash
mkdir -p project/logs/archive

Walk the tree to see which levels now exist:

bash
find project -type d | sort
output
project
project/logs
project/logs/archive

One command created the intermediate logs directory and the final archive directory. Add -v when you want mkdir to report each level it created:

bash
mkdir -pv project/src/handlers
output
mkdir: 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:

bash
mkdir project
output
mkdir: cannot create directory ‘project’: File exists

With -p the same command succeeds quietly instead:

bash
mkdir -p project

There is no message either way, so read the exit status to see whether it counted as success:

bash
echo $?
output
0

That 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:

bash
cp notes.txt notes-backup.txt

Name both files in one ls so you can compare the copy against the original:

bash
ls -l notes.txt notes-backup.txt
output
-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.txt

Both 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:

bash
cp notes.txt project/

Look inside the destination directory to confirm the name it arrived under:

bash
ls -l project/
output
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 src

The 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:

bash
cp report.txt summary.txt config.conf backups/

Check that each source kept its own name rather than overwriting the others:

bash
ls backups/
output
config.conf
report.txt
summary.txt

All 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:

bash
cp notes.txt backups/

Add -v when you want cp to narrate what it did:

bash
cp -v notes.txt logs/
output
'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:

bash
echo changed > notes.txt

Copy it over the file that is already sitting in project/:

bash
cp notes.txt project/

Read the destination to see which version survived:

bash
cat project/notes.txt
output
changed

The previous project/notes.txt is gone with no prompt and no backup. Add -i to be asked first:

bash
cp -i notes.txt backups/notes.txt
output
cp: overwrite 'backups/notes.txt'? n

Answering 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:

bash
cp -n notes.txt backups/notes.txt

Nothing is printed and nothing is asked, so the exit status is the only signal of what happened:

bash
echo $?
output
0

The 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:

bash
cp project project-copy
output
cp: -r not specified; omitting directory 'project'

The message names the fix. Add -r and the whole tree is copied:

bash
cp -r project project-backup

Walk the new tree to see what shape it took:

bash
find project-backup | sort
output
project-backup
project-backup/logs
project-backup/logs/archive
project-backup/notes.txt
project-backup/src
project-backup/src/handlers

Because 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.

bash
cp -r project project-backup

Limit the walk to two levels, which is enough to show where the second copy went:

bash
find project-backup -maxdepth 2 | sort
output
project-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/handlers

The 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:

bash
mkdir existing-dest

Copy the tree with a trailing slash on the source and see whether it means anything:

bash
cp -r project/ existing-dest/

List the destination to find out where the files ended up:

bash
ls existing-dest
output
project

The 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:

bash
rm -r existing-dest/project

Create the hidden file in the source tree:

bash
touch project/.env

To 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:

bash
cp -rT project existing-dest

Use ls -a this time, because the point of the comparison is whether .env came across:

bash
ls -a existing-dest
output
.
..
.env
logs
notes.txt
src

Now 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:

bash
mkdir star-dest && cp -r project/* star-dest/

List everything again, including hidden names:

bash
ls -a star-dest
output
.
..
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:

bash
mkdir dot-dest && cp -r project/. dot-dest/

Compare this listing against the wildcard one:

bash
ls -a dot-dest
output
.
..
.env
logs
notes.txt
src

The 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:

bash
cp -r project project/inner
output
cp: 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:

bash
rm -r project/inner

For 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:

bash
chmod 640 config.conf && touch -d '2026-01-15 09:30:00' config.conf

Record the mode and modification time so there is a baseline to compare against:

bash
stat -c '%a %y %n' config.conf
output
640 2026-01-15 09:30:00.000000000 +0530 config.conf

Copy it normally and compare:

bash
cp config.conf plain-copy.conf

Read the same two fields on the copy:

bash
stat -c '%a %y %n' plain-copy.conf
output
640 2026-08-07 22:57:19.218088197 +0530 plain-copy.conf

The 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:

bash
cp -p config.conf preserved-copy.conf

Check the timestamp on this copy:

bash
stat -c '%a %y %n' preserved-copy.conf
output
640 2026-01-15 09:30:00.000000000 +0530 preserved-copy.conf

January 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:

bash
touch wide.conf && chmod 666 wide.conf

Copy it with no flags at all:

bash
cp wide.conf wide-plain.conf

Print the mode of the source and the copy side by side:

bash
stat -c '%a %n' wide.conf wide-plain.conf
output
666 wide.conf
644 wide-plain.conf

The 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:

bash
cp -p wide.conf wide-p.conf

Read the mode on that copy:

bash
stat -c '%a %n' wide-p.conf
output
666 wide-p.conf

For 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.

bash
ln -s ../config.conf project/config-link

Copy the tree in archive mode:

bash
cp -a project project-archive

Inspect the copied link with ls -l, which shows both the type character and the target:

bash
ls -l project-archive/config-link
output
lrwxrwxrwx. 1 student student 14 Aug  7 22:57 project-archive/config-link -> ../config.conf

The 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:

bash
cp -r project project-r

Print the modification time of the original directory and the -r copy together:

bash
stat -c '%y %n' project project-r
output
2026-08-07 22:57:19.253088194 +0530 project
2026-08-07 22:57:19.341399118 +0530 project-r

The -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-a keeps hard-linked files linked inside the copied tree; -r alone can turn them into separate files.
  • ACLs and extended attributes-a attempts to preserve the source's ACLs and extended attributes. A plain cp does 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 -a only tries to carry the source label across.

That last point is easy to verify. The lab file carries the home-directory label:

bash
ls -Z config.conf
output
unconfined_u:object_r:user_home_t:s0 config.conf

Copy it onto a different filesystem and read the label again:

bash
cp config.conf /dev/shm/

Use ls -Z to print the SELinux context of the copy:

bash
ls -Z /dev/shm/config.conf
output
unconfined_u:object_r:user_tmp_t:s0 /dev/shm/config.conf

The 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:

bash
cp -a config.conf /dev/shm/config-a.conf

Read the context on this copy and compare it with the plain one:

bash
ls -Z /dev/shm/config-a.conf
output
unconfined_u:object_r:user_home_t:s0 /dev/shm/config-a.conf

In 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 cp writes into an inode that is already labeled.
  • cp -a or --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:

bash
touch draft.txt one.log two.log three.log old-name dest.txt src.txt

Give mv a new name in the same directory and it renames:

bash
mv draft.txt release-notes.txt

Ask for the old name rather than the new one, since that is what proves a rename is not a copy:

bash
ls -l draft.txt
output
ls: cannot access 'draft.txt': No such file or directory

The old name is gone rather than duplicated. Give it a directory and it moves, keeping the filename:

bash
mv release-notes.txt project/

List the destination to confirm the filename was carried over unchanged:

bash
ls project/
output
config-link
logs
notes.txt
release-notes.txt
src

Several sources with a directory last move them together:

bash
mv one.log two.log three.log logs/

Check that all three landed rather than overwriting one another:

bash
ls logs/
output
notes.txt
one.log
three.log
two.log

All three arrived in logs. As with cp, -v reports what happened, which is worth using on directory renames where a typo is expensive:

bash
mv -v old-name new-name
output
renamed '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:

bash
printf 'source-content\n' > source.txt

Give the file you are about to overwrite different contents:

bash
printf 'target-content\n' > target.txt

Move one onto the other with no flags:

bash
mv source.txt target.txt

Read what the target holds now:

bash
cat target.txt
output
source-content

The previous contents of target.txt are unrecoverable. Use -i to be prompted:

bash
mv -i src.txt dest.txt
output
mv: overwrite 'dest.txt'? n

Declining leaves both files exactly as they were. -n makes skipping the default without any prompt:

bash
mv -n src.txt dest.txt

Nothing is printed, so check whether the source was consumed:

bash
ls src.txt
output
src.txt

The 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:

bash
mkdir -p reports/january

Moving it into an existing directory nests the tree inside it:

bash
mv reports project/

Walk the moved tree at its new location:

bash
find project/reports | sort
output
project/reports
project/reports/january

The january subdirectory came along, because mv relocated the entry rather than walking the tree. Confirm the source is really gone rather than copied:

bash
ls -d reports
output
ls: cannot access 'reports': No such file or directory

That 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:

bash
touch inode-test.txt

The %i format prints the inode number, which is the file's identity on that filesystem:

bash
stat -c '%i %n' inode-test.txt
output
1432743 inode-test.txt

Move it into a subdirectory on the same filesystem:

bash
mv inode-test.txt logs/inode-test.txt

Read the inode number at the new path:

bash
stat -c '%i %n' logs/inode-test.txt
output
1432743 logs/inode-test.txt

Same 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:

bash
mv logs/inode-test.txt /dev/shm/inode-test.txt

Read the inode number once more, now that the file sits on a different filesystem:

bash
stat -c '%i %n' /dev/shm/inode-test.txt
output
6 /dev/shm/inode-test.txt

The 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.
  • Interruptionmv copies first and removes the source only after the copy succeeds. When the copy reports a failure, GNU mv cleans up its own partially created destination, so an ordinary error does not strand half a file. An abrupt end such as SIGKILL, a crash, or a storage failure gives mv no 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:

bash
ls -Z /dev/shm/inode-test.txt
output
unconfined_u:object_r:user_home_t:s0 /dev/shm/inode-test.txt

The 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:

bash
rm target.txt

Ask for the file again to confirm it is really gone:

bash
ls target.txt
output
ls: cannot access 'target.txt': No such file or directory

Nothing was printed on success, which is normal for rm. Several names work in one command, so create a few throwaway files:

bash
touch a.tmp b.tmp c.tmp important.txt f1.tmp f2.tmp f3.tmp f4.tmp f5.tmp

Remove three of them in a single command:

bash
rm a.tmp b.tmp c.tmp

Deleting a name that does not exist is an error you will see in scripts:

bash
rm definitely-missing.txt
output
rm: cannot remove 'definitely-missing.txt': No such file or directory

The message goes to standard error, and a script branches on the exit status rather than the text:

bash
echo $?
output
1

-f suppresses that complaint and returns success, which is exactly why cleanup scripts use it:

bash
rm -f definitely-missing.txt

No message appears this time, so read the status to see how the same missing file was treated:

bash
echo $?
output
0

Understand 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:

bash
rm -i important.txt
output
rm: remove regular empty file 'important.txt'? n

The 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:

bash
rm -I f1.tmp f2.tmp f3.tmp f4.tmp f5.tmp
output
rm: remove 5 arguments? y

One 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.

WARNING
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:

bash
type rm
output
rm 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:

bash
rmdir project
output
rmdir: failed to remove 'project': Directory not empty

That 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:

bash
mkdir empty-dir

Remove it with -v so the command reports what it did:

bash
rmdir -v empty-dir
output
rmdir: 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:

bash
mkdir -p a/b/c

Remove the deepest path and let rmdir work back up the chain:

bash
rmdir -p a/b/c

Ask for the top-level directory to see how far up the removal went:

bash
ls -d a
output
ls: cannot access 'a': No such file or directory

All 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:

bash
mkdir -p scratch/sub

Put a file at each level so the tree is not empty:

bash
touch scratch/one.txt scratch/sub/two.txt

List it before deleting anything:

bash
find scratch | sort
output
scratch
scratch/one.txt
scratch/sub
scratch/sub/two.txt

Four entries, all expected, so the delete is safe to run:

bash
rm -r scratch

Confirm the whole tree went, not just the files inside it:

bash
ls -d scratch
output
ls: cannot access 'scratch': No such file or directory

The 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:

bash
mkdir ~/filelab/globs && cd ~/filelab/globs

Create a mix of logs, numbered files, and one hidden file, since each one exposes a different matching rule:

bash
touch app-2026-01.log app-2026-02.log app-2026-03.log file1.txt file2.txt file3.txt file10.txt .hidden.txt

Because expansion happens first, you can preview any pattern with a harmless command. printf prints one match per line:

bash
printf '%s\n' *.log
output
app-2026-01.log
app-2026-02.log
app-2026-03.log

Three files, exactly as intended. Prefixing the real command with echo shows the argument list the command would receive:

bash
echo rm *.log
output
rm app-2026-01.log app-2026-02.log app-2026-03.log

That 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:

bash
ls file?.txt
output
file1.txt
file2.txt
file3.txt

file10.txt exists in this directory but did not match, because 10 is two characters. A set matches only the characters you list:

bash
ls file[12].txt
output
file1.txt
file2.txt

A range covers consecutive characters:

bash
ls file[0-9].txt
output
file1.txt
file2.txt
file3.txt

Ranges work on single characters, so [0-9] will never match 10. One more rule saves configuration directories from half-copies:

bash
echo *.txt
output
file10.txt file1.txt file2.txt file3.txt

The 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:

bash
ls *.md
output
ls: cannot access '*.md': No such file or directory

Bash 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:

bash
echo file{1..5}.txt
output
file1.txt file2.txt file3.txt file4.txt file5.txt

Five names were generated from a range, and none of them had to exist. That makes braces the natural tool for creating things:

bash
touch file{6..8}.txt

List the single-digit names to see the new files beside the originals:

bash
ls file?.txt
output
file1.txt
file2.txt
file3.txt
file6.txt
file7.txt
file8.txt

Three files created in one command. A comma list builds a directory scaffold just as quickly:

bash
mkdir -p site/{src,docs,logs}

Walk the result to confirm all three subdirectories exist under one parent:

bash
find site -type d | sort
output
site
site/docs
site/logs
site/src

The classic idiom leaves one item of the list empty to build a backup name from the original:

bash
printf 'port=8080\n' > app.conf

Copy it to a backup name using an empty first list item:

bash
cp app.conf{,.bak}

List both names to confirm what the expansion produced:

bash
ls -l app.conf app.conf.bak
output
-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.bak

The 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*.txt produces 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}*.txt first becomes ls a*.txt b*.txt and then each glob is matched.

Because braces do not check the filesystem, they are safe to preview and useful for planning names:

bash
echo backup-{2026-01,2026-02}.tar.gz
output
backup-2026-01.tar.gz backup-2026-02.tar.gz

Neither 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:

bash
cd ~/filelab && printf 'plain text\n' > sample.txt

file reports what a name actually is, based on content rather than extension:

bash
file sample.txt
output
sample.txt: ASCII text

Point the same command at a directory and the answer changes:

bash
file project
output
project: directory

Knowing you are pointing at a directory explains in advance why cp will ask for -r. The same command identifies binaries:

bash
file /usr/bin/ls
output
/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, stripped

stat prints the metadata behind every operation on this page: type, size, mode, owner, inode, and all three timestamps.

bash
stat sample.txt
output
File: 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 +0530

Modify 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:

bash
stat -c '%F %s bytes mode=%a owner=%U:%G' sample.txt
output
regular file 11 bytes mode=644 owner=student:student

That 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:

bash
mkdir -p sized/sub

Write a 200K file at the top level:

bash
dd if=/dev/zero of=sized/big.bin bs=1K count=200 status=none

Put a smaller one in the subdirectory so the two levels report different totals:

bash
dd if=/dev/zero of=sized/sub/small.bin bs=1K count=40 status=none

Then measure it:

bash
du -sh sized
output
240K	sized

The -s gives a single summary and -h makes it readable. Drop -s to see each subdirectory contribute to the total:

bash
du -h sized
output
40K	sized/sub
240K	sized

The 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:

bash
ls -lh sized
output
total 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 sub

Add -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:

bash
touch "my report.txt" "quarterly plan.txt" ./-draft.txt ./-report.txt

Listing one of them works as long as the name stays quoted:

bash
ls -l "my report.txt"
output
-rw-r--r--. 1 student student 0 Aug  7 22:58 my report.txt

Now the mistake, which is worth showing honestly because the result is not just an error:

bash
rm my report.txt
output
rm: cannot remove 'my': No such file or directory

rm 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:

bash
mv "my report.txt" my_report.txt

Escaping the space with a backslash is equivalent:

bash
ls -l quarterly\ plan.txt
output
-rw-r--r--. 1 student student 0 Aug  7 22:58 quarterly plan.txt

Use 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.

bash
rm -draft.txt
output
rm: 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:

bash
rm -- -draft.txt

Or give the file a path, so the argument no longer starts with a dash:

bash
rm ./-report.txt

Confirm the awkward name is gone:

bash
ls ./-report.txt
output
ls: cannot access './-report.txt': No such file or directory

Both 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:

bash
mkdir -p webapp/{config,logs,output,tmp}

Walk it once to confirm the layout before putting anything into it:

bash
find webapp -type d | sort
output
webapp
webapp/config
webapp/logs
webapp/output
webapp/tmp

Four directories exist under a single parent. Next, create the files the rest of the walkthrough manipulates:

bash
printf 'listen_port=8080\nlog_level=info\n' > webapp/config/app.conf

Add three throwaway build artifacts with a brace range:

bash
touch webapp/tmp/build-{1..3}.tmp

Then write the report that the walkthrough later promotes out of tmp:

bash
printf 'id,total\n1,42\n2,17\n' > webapp/tmp/report.csv

List both directories to check the starting state:

bash
ls webapp/config webapp/tmp
output
webapp/config:
app.conf

webapp/tmp:
build-1.tmp
build-2.tmp
build-3.tmp
report.csv

The 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:

bash
cp -a webapp/config/app.conf{,.bak}

Compare the two files in the directory listing:

bash
ls -l webapp/config/
output
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.bak

Both files are the same size, so the copy is complete. Rename the backup to carry a date, which is a move within one directory:

bash
mv -v webapp/config/app.conf.bak webapp/config/app.conf.2026-08-07
output
renamed '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:

bash
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:

bash
ls webapp/output/ webapp/tmp/
output
webapp/output/:
report.csv

webapp/tmp/:
build-1.tmp
build-2.tmp
build-3.tmp

report.csv is in output and only the disposable build files remain in tmp. With the report safely relocated, remove the temporary tree:

bash
rm -r webapp/tmp

Check that the directory itself went, not only its contents:

bash
ls -d webapp/tmp
output
ls: cannot access 'webapp/tmp': No such file or directory

The scratch directory is gone. Verify the final state rather than assuming it:

bash
find webapp | sort
output
webapp
webapp/config
webapp/config/app.conf
webapp/config/app.conf.2026-08-07
webapp/logs
webapp/output
webapp/output/report.csv

Configuration plus dated backup, an empty log directory, and the report in output. A size check closes the loop:

bash
du -sh webapp
output
12K	webapp

Confirm the backup really holds the configuration rather than an empty file:

bash
cat webapp/config/app.conf.2026-08-07
output
listen_port=8080
log_level=info

The 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:

bash
rm -r ~/filelab

References


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.


Frequently Asked Questions

1. What is the difference between cp and mv in Linux?

cp duplicates data and leaves the original in place, so you end up with two copies. mv relocates or renames an entry and the source no longer exists afterwards. Within one filesystem mv only rewrites directory entries and keeps the same inode, which is why it finishes instantly even on large files.

2. Does cp -r copy hidden files?

Copying the directory itself with cp -r sourcedir destdir includes dotfiles because the shell never expands anything. Copying with a wildcard such as cp -r sourcedir/* destdir/ skips hidden entries, since * does not match names beginning with a dot under Bash default settings. Use sourcedir/. as the source when you want the contents including dotfiles.

3. Can I recover a file deleted with rm?

Treat rm as permanent. It unlinks the file immediately with no desktop Trash folder involved, and the shell offers no undo. Recovery depends on filesystem forensics with the volume unmounted and is unreliable, so keep backups and use interactive or dry-run habits instead of counting on recovery.

4. Why does rmdir say Directory not empty?

rmdir only removes empty directories, which makes it a safe default. If the directory still holds files or subdirectories the command refuses and reports Directory not empty. Inspect the contents first, then use rm -r once you are sure the whole tree should go.

5. Does a trailing slash on the source make cp copy only the contents?

No. That behavior belongs to rsync, not cp. With GNU cp the trailing slash on the source changes nothing, so copying into an existing directory still nests the source directory inside it. Use cp -rT source dest to write the contents directly into dest.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)