Skip to content

File Ownership: UID, GID, chown, and chgrp

ls -l prints a file's owner as something like ali:developers. The kernel, however, never checks permissions against that text — it uses the UID and GID numbers stored in the inode. That difference matters a great deal when a backup is restored on another server, when a container shows an unfamiliar number instead of a name, or when files are left behind by an account that no longer exists.

The previous article, File Permissions, covered how the kernel picks one permission class — owner, group, or other — for a process. This one covers where that "owner" and "group" actually come from: ownership is a pair of numbers stored in the inode. You will read ls, stat, id, and getent together; make controlled changes with chown, chgrp, --reference, and --from; see how ownership behaves differently for a hard link than for a symbolic link; and scope a bulk chown operation safely with a target check, mount awareness, symlink audit, and a rollback plan.

Ownership Is Stored as a Number, Not a Name

Alongside other metadata, every inode stores two identifiers:

  • UID (user ID): the numeric identifier of the file's owner.
  • GID (group ID): the numeric identifier of the file's group.

A username is a human-readable label for a UID, and a group name is one for a GID. UID 1001 might be ali on one host and deploy on another. Copying a file to a second host does not automatically preserve its intended meaning — only its raw numeric metadata travels with it.

stat -c 'mode=%A owner=%U uid=%u group=%G gid=%g path=%n' -- <file_path>

A representative result:

mode=-rw-r----- owner=root uid=0 group=inventory gid=992 path=/srv/inventory/app.conf

owner=root uid=0 means the inode's UID 0 resolves to the name root in this host's account database. group=inventory gid=992 shows the same relationship for the group. Your own system's UIDs and GIDs will differ.

ls -ln prints the raw numbers without resolving names:

ls -ln -- <file_path>

This is useful for spotting a mismatch in a backup, on NFS, or inside a container. For automation, though, prefer an explicit format such as stat -c '%u:%g' over parsing ls text.

Where Do Names Come From?

The Name Service Switch (NSS) decides, through /etc/nsswitch.conf, which sources to search for account data. That source is not necessarily limited to /etc/passwd and /etc/group — LDAP or another directory service can be involved too. Because of that, grepping only a local file is not a complete check.

getent passwd <username>
getent group <group>
id <username>

For example:

inventory:x:992:992:Inventory service:/var/lib/inventory:/usr/sbin/nologin
inventory:x:992:
uid=992(inventory) gid=992(inventory) groups=992(inventory)

The first line shows the user's UID and primary GID, the second shows the group's GID, and id shows the account's primary and supplementary groups. If getent returns nothing, do not immediately conclude that a new account must be created or that a numeric chown is needed — check the directory service, NSS configuration, and network state as well.

Note

Deleting an account does not delete its files. Because the name can no longer be resolved, the owner or group column in ls -l may show a raw number. If that UID is later assigned to a different account, the old files can appear to belong to the new one. That is exactly why an account's files should be audited separately as part of deleting it.

Lab Setup

This lab only works inside the current user's home directory and does not require sudo.

mkdir -p ~/lab/ownership/data
printf '%s\n' 'ownership-lab' > ~/lab/ownership/data/report.txt
ln ~/lab/ownership/data/report.txt ~/lab/ownership/report-hardlink.txt
ln -s data/report.txt ~/lab/ownership/report-symlink.txt
stat -c '%F inode=%i owner=%U(%u) group=%G(%g) path=%n' \
    ~/lab/ownership/data/report.txt \
    ~/lab/ownership/report-hardlink.txt \
    ~/lab/ownership/report-symlink.txt

A representative result:

regular file inode=183511 owner=ali(1000) group=ali(1000) path=/home/ali/lab/ownership/data/report.txt
regular file inode=183511 owner=ali(1000) group=ali(1000) path=/home/ali/lab/ownership/report-hardlink.txt
symbolic link inode=183514 owner=ali(1000) group=ali(1000) path=/home/ali/lab/ownership/report-symlink.txt

Your home path, names, IDs, and inode numbers will differ. The first two paths should share one inode number; the symlink's should be different.

How a New Object's Owner and Group Are Chosen

A new file's owner is normally the filesystem UID of the process that created it. In everyday programs, that is the same as the effective UID. sudo touch file and a plain touch file create files with different owners, precisely because of the creating process's identity.

For a new object's group, there are two main cases:

  1. by default, the process's filesystem GID is used — in practice, usually the user's primary group;
  2. if the parent directory has the set-group-ID (setgid) bit enabled, the new object inherits the parent directory's group instead.

The file system type and mount options such as grpid/bsdgroups can also affect this choice. Check the actual environment rather than assuming:

id
stat -c 'mode=%A (%a) owner=%U:%G path=%n' -- <parent_directory>
findmnt -T <parent_directory> -o TARGET,FSTYPE,OPTIONS

Why Does a Shared Directory Need setgid?

Suppose /srv/reports is owned by root:reporters, and several users in the reporters group create files there. Without setgid, a new file can end up in each user's own primary group instead, and a supposedly shared directory ends up with mixed group ownership.

First check the group and the current state:

getent group reporters
id <username>
stat -c '%A %a %U:%G %n' -- /srv/reports
findmnt -T /srv/reports -o TARGET,FSTYPE,OPTIONS

Then, if organizational policy calls for making the directory shared:

sudo chgrp reporters /srv/reports
sudo chmod 2770 /srv/reports
stat -c '%A %a %U:%G %n' -- /srv/reports

The 2 is the directory's setgid bit. With 2770, new objects inherit the reporters group, and new subdirectories usually inherit the setgid bit too. This command does not change the group of files already inside the directory.

Warning

Do not run this example blindly against a real production directory. Before substituting a verified path for /srv/reports, take a snapshot or metadata backup, write down the application's expected owner/group policy, and test it in a lab directory first. setgid controls inheritance, but it does not by itself grant group write access — a new object's mode is also shaped by umask and any default ACL.

Do not confuse ownership with umask: ownership decides which UID/GID an object belongs to, while umask decides which ordinary permission bits are cleared at creation time.

chown and chgrp Syntax

The basic GNU Coreutils syntax:

chown [OPTION]... [OWNER][:[GROUP]] FILE...
chown [OPTION]... --reference=REF_FILE FILE...
chgrp [OPTION]... GROUP FILE...

What each form does:

Form Result
chown ali file sets the owner to ali, group unchanged
chown ali:developers file changes owner and group together
chown :developers file changes only the group
chgrp developers file changes only the group
chown ali: file sets the owner to ali, group to ali's login group
chown --reference=ref file copies ref's owner and group onto file

The older owner.group separator appears on some systems, but a portable script should write owner:group. There is a space between the owner/group specification and the file path — a form like ali : developers is invalid.

Who Can Change What?

Changing a file's owner to a different user on Linux normally requires the CAP_CHOWN capability, which is why administrators use sudo chown .... An ordinary file owner can only change the group to one of the groups the calling process belongs to:

id -Gn
chgrp <member_group> <your_own_file>

A user who has just been added to a new group may not see that membership in an already-open shell process right away. If id shows the new record but id -Gn in the current session shows the old state, open a new login session — do not paper over the problem with sudo chown.

A Controlled Change to a Single File

In production, prove the names, the target, and the previous state before running the command:

getent passwd <new_owner>
getent group <new_group>
stat -c '%A %a %U(%u):%G(%g) %n' -- <absolute_file_path>
namei -l <absolute_file_path>

Then:

sudo chown <new_owner>:<new_group> -- <absolute_file_path>
stat -c '%A %a %U(%u):%G(%g) %n' -- <absolute_file_path>

sudo is required to assign ownership to a different UID/GID. -- marks everything after it as a filename, not an option — this matters especially when a name happens to start with -.

When several files need to match one reference file's ownership:

stat -c '%U:%G %n' -- <reference_file> <target_file>
sudo chown --reference=<reference_file> -- <target_file>
stat -c '%U:%G %n' -- <reference_file> <target_file>

--reference reduces the risk of manually copying numbers incorrectly, but the reference file's own correctness still needs to be verified first.

--from: Skipping Unexpected Objects

GNU chown's --from=<old_owner>:<old_group> option only changes ownership if the object's current ownership exactly matches the given values:

sudo chown --from=<old_owner>:<old_group> \
    <new_owner>:<new_group> -- <absolute_file_path>

This narrows the window between a read-only stat check and the actual chown where the state could have changed. Even when the command's exit status is 0, a file that did not match --from may have been silently skipped — always confirm the result with stat.

A hard link is not a separate copy — it is another name for the same inode. chgrp on one of the two hard links in the lab changes the other name's result too:

chgrp "$(id -gn)" -- ~/lab/ownership/report-hardlink.txt
stat -c 'inode=%i %U:%G %n' \
    ~/lab/ownership/data/report.txt \
    ~/lab/ownership/report-hardlink.txt

Both lines show the same inode and ownership, because chgrp changed the inode the directory entry points to, not the directory entry itself.

A symbolic link has its own inode and ownership, but a plain chown link normally dereferences the link and acts on its target instead. Look at the two objects separately:

stat -c 'link:   %F %U:%G %n' -- ~/lab/ownership/report-symlink.txt
stat -Lc 'target: %F %U:%G %n' -- ~/lab/ownership/report-symlink.txt

GNU chown -h (or --no-dereference) acts on the symlink itself instead of its target:

chown -h "$(id -un):$(id -gn)" -- ~/lab/ownership/report-symlink.txt

On most Linux systems, a symlink's own mode bits do not govern access, but its ownership can matter for sticky-bit deletion rules or a protected symlink policy.

Warning

A recursive chown that dereferences symlinks is risky: if another user places a link pointing outside the tree, the command can touch an object well outside its intended scope. On an untrusted or actively changing tree, stopping the service, working on an immutable deployment copy, or using another trusted synchronization method is safer.

Scoping a Recursive Change Safely

chown -R touches every object inside a tree, and there is no --dry-run for it. A preview has to be a separate, read-only command instead.

For example, finding only objects still owned by an old ci-runner:ci-runner:

find <target_directory> -xdev \
    -user ci-runner -group ci-runner \
    -printf '%y %m %u:%g %p\n'

-xdev avoids crossing into another mount. If the resulting paths, object types, or count are not what you expect, stop here. Also check symlink targets, the mount tree, and the target directory itself:

realpath -- <target_directory>
stat -c '%F %A %U:%G %n' -- <target_directory>
findmnt -R -T <target_directory>
find <target_directory> -xdev -type l -printf '%p -> %l\n'

A snapshot or configuration management tool is the preferred rollback. If ACL tools are available, ownership, mode, and ACLs can be captured as a text backup:

backup_file=$(sudo mktemp /root/ownership.before.XXXXXX)
sudo getfacl -Rp <target_directory> | sudo tee "$backup_file" >/dev/null
sudo chmod 0600 "$backup_file"
printf '%s\n' "$backup_file"

Replace <target_directory> only with an absolute path that has already passed the checks above. Record the backup path in your change record.

In a GNU environment, a form scoped to one file system that re-checks the old ownership:

sudo find <target_directory> -xdev \
    -user ci-runner -group ci-runner \
    -exec chown -h --from=ci-runner:ci-runner appuser:appgroup -- {} +

This command changes many objects. Run it only after it has been validated on a test copy, a backup has been taken, and the preview list above has been reviewed. find does not follow symlinks by default, and chown -h acts on the link itself; --from helps avoid re-touching an object whose ownership already changed after the earlier find check.

Verification:

find <target_directory> -xdev \
    \( ! -user appuser -o ! -group appgroup \) \
    -printf 'unexpected %u:%g %p\n'

This check only makes sense under a policy that every object should end up exactly appuser:appgroup. If different subdirectories follow different policies, check each one with its own predicate.

Rollback:

sudo setfacl --restore="$backup_file"

Then verify the restored state with stat, find, and a side-effect-free real check under the service account. Do not run this command on a guess if no backup was taken, the backup is incomplete, or the tree has changed since the backup.

Side Effects of Changing Ownership

It is risky to assume chown only changes two columns in ls -l. On Linux, when an owner or group changes:

  • an executable's set-user-ID and set-group-ID bits can be cleared;
  • file capabilities attached to the file are cleared;
  • which u/g/o permission class a process falls into can change;
  • quotas, NFS UID mapping, a container user namespace, or an idmapped mount can present a different result than what the host itself shows.

Before and after changing ownership on an executable or capability-bearing file, check at least:

stat -c '%A %a %U:%G %n' -- <executable_path>
getcap -- <executable_path>

getcap typically comes from the libcap2-bin package (Ubuntu/Debian) or libcap (RHEL family). An empty result can simply mean no capability is set. Before manually restoring a security attribute in production, check the expected state in the package manager, deployment manifest, or configuration management.

What Happens on Copy and Move?

A plain cp source dest creates a new destination inode, and its ownership normally comes from the copying process, not from the source. cp -a or --preserve=ownership attempts to preserve the metadata, but that requires sufficient privilege and file system support. mv within one file system typically renames the same inode, so ownership is preserved.

A backup or deployment finishing "successfully" does not mean ownership came out correct. When assessing a restore, check stat, ACLs, and capabilities if relevant, alongside the file's content.

Practical Scenario: A Release Belongs to the Wrong Account

The CI system left the /srv/inventory/releases/2026-07-24 release owned by ci-runner:ci-runner. inventory.service runs as the inventory account. Policy requires the release code to be owned by root:inventory, readable by the service, but not writable by it.

1. Identify the Process and the Policy

systemctl show -p User -p Group inventory.service
getent passwd inventory
getent group inventory
id inventory

A representative, relevant excerpt:

User=inventory
Group=inventory
uid=992(inventory) gid=992(inventory) groups=992(inventory)

An empty User= in the unit can mean the system service runs as root; if a process is running, also confirm with ps -o pid,user,group,comm -C <process_name>.

2. Audit the Tree Without Changing It

release=/srv/inventory/releases/2026-07-24
realpath -- "$release"
findmnt -R -T "$release"
namei -l "$release"
sudo find "$release" -xdev -printf '%y %m %u:%g %p\n'
sudo find "$release" -xdev -type l -printf '%p -> %l\n'

The release variable only lives in the current shell session. realpath confirms the intended absolute path, findmnt shows any internal mounts, namei covers the parent directories, and find shows ownership and symlinks. The result should cover only this one release tree.

3. Prepare the Rollback

Take a VM or storage snapshot. In addition:

backup_file=$(sudo mktemp /root/inventory-release.acl.before.XXXXXX)
sudo getfacl -Rp "$release" | sudo tee "$backup_file" >/dev/null
sudo chmod 0600 "$backup_file"
printf '%s\n' "$backup_file"

The snapshot and backup need to cover every change made since the release was created. If an active deployment is still in progress, capturing the list without pausing it can produce an inconsistent snapshot.

4. Change Only the Expected Old Ownership

Review exactly which objects will be affected once more:

sudo find "$release" -xdev \
    -user ci-runner -group ci-runner \
    -printf '%y %m %u:%g %p\n'

Once the result on a verified test copy looks correct:

sudo find "$release" -xdev \
    -user ci-runner -group ci-runner \
    -exec chown -h --from=ci-runner:ci-runner root:inventory -- {} +

This approach leaves objects deliberately assigned to a different group untouched. If policy requires every release object to be root:inventory, remaining exceptions will show up in the next check.

5. Verify with the Service Account

sudo find "$release" -xdev \
    \( ! -user root -o ! -group inventory \) \
    -printf 'unexpected %u:%g %p\n'
sudo -u inventory -- test -r "$release/app.conf"
echo $?
sudo -u inventory -- test ! -w "$release/app.conf"
echo $?

The first find is expected to return nothing, and both echo commands should print 0: the service can read the configuration but cannot write to it. Mode and parent directory permissions must also match the policy — ownership alone does not guarantee access.

6. Rollback

If the service check or monitoring shows an unexpected result:

sudo setfacl --restore="$backup_file"
sudo find "$release" -xdev -printf '%y %m %u:%g %p\n'

Then retest the service specifically under the inventory account. If a snapshot or configuration management rollback is available and verified in production, prefer that path.

Common Mistakes

Treating chmod and chown as the Same Job

chmod changes permission bits; chown changes UID/GID. Widening both blindly when Permission denied appears breaks the intended policy. Find out first which account the process runs as and which permission class it actually falls into.

Giving Service Code to the Service's Own Owner

If a service only needs to read a file, storing its executable and configuration as root:<service_group> is often safer. A compromised service process should not be able to overwrite its own code or configuration. A separate, writable directory such as /var/lib/<service> is the right place for runtime data.

Assuming a New File Inherits the Parent Directory's Owner

A new file's owner comes from the creating process, not from the parent directory's owner. A setgid directory only manages group inheritance. If owner inheritance seems to be happening, re-check which account the creating process actually runs as.

Using a Numeric ID Blindly Without a Name

chown 1001:1001 can belong to a completely different account on another host. Check the mapping first with getent passwd 1001 and getent group 1001. The same number can also mean a different security identity between a container and its host.

Applying chown -R to an Entire Application Root

Release code, secrets, sockets, uploads, and logs can each need a different ownership policy. One recursive command erases those boundaries. Separate subdirectories by purpose, and use --from to limit the change to a known previous state.

A plain chown follows a symlink to its target; chown -h acts on the link itself. -H, -L, and -P change the result during a recursive walk. GNU chown -R defaults to -P, but a production command should still check the symlink policy explicitly rather than assume it.

Assuming Exit Status 0 Means Everything Changed

A file that did not match --from can be silently skipped. Different subtrees can also need different policies that do not fit one blanket check. Prove the final state with an independent stat or find predicate after the change.

Forgetting Executable Capabilities and Special Bits

Changing ownership can clear setuid/setgid bits or file capabilities. Before running chown on a privileged executable, record the package/deployment expectation along with stat and getcap output.

Exercises

  1. Print the three lab paths with stat in both named and numeric form. Resolve each UID/GID back to a name with getent.
  2. Compare the inode numbers of report.txt and report-hardlink.txt. Run chgrp "$(id -gn)" through one name and explain why the same result appears through the other.
  3. Compare stat and stat -L output for the symlink. Note which one shows the link's own inode and which shows the target's.
  4. Find the groups you belong to with id -Gn. If there is more than one, chgrp a test file to a supplementary group, then set it back to your primary group. Do not attempt to give ownership to another user.
  5. Build an audit report for ~/lab/ownership based on find -xdev -printf '%y %m %u:%g %p\n'. For a hypothetical production fix, note which objects would change, what backup would be taken, and which command would perform the rollback — do not change a real system directory.

Verification criterion: in your answers, connect the visible name to the inode's UID/GID, the NSS mapping, the process identity, and hard-link/symlink behavior — not just the name shown by default.

When you finish the lab, inspect the target read-only:

find ~/lab/ownership -xdev -maxdepth 3 -printf '%y %m %u:%g %p\n'

Only if the result shows nothing but the ~/lab/ownership test tree:

rm -rI -- ~/lab/ownership

Warning

rm -rI deletes the test tree with no undo mechanism. Run it only after confirming the absolute boundary with the find command above. Save any results you want to keep first; do not use this cleanup command on a production tree.

References

  • Linux man-pages: inode(7): https://man7.org/linux/man-pages/man7/inode.7.html
  • Linux man-pages: chown(2): https://man7.org/linux/man-pages/man2/chown.2.html
  • Linux man-pages: credentials(7): https://man7.org/linux/man-pages/man7/credentials.7.html
  • Linux man-pages: capabilities(7): https://man7.org/linux/man-pages/man7/capabilities.7.html
  • Linux man-pages: getent(1): https://man7.org/linux/man-pages/man1/getent.1.html
  • Linux man-pages: nsswitch.conf(5): https://man7.org/linux/man-pages/man5/nsswitch.conf.5.html
  • GNU Coreutils manual: chown invocation: https://www.gnu.org/software/coreutils/manual/html_node/chown-invocation.html
  • GNU Coreutils manual: chgrp invocation: https://www.gnu.org/software/coreutils/manual/html_node/chgrp-invocation.html
  • POSIX.1-2024: file ownership: https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap04.html