Skip to content
Linux Basics: Inspect File Identity and Link Metadata with GNU stat

Linux Basics: Inspect File Identity and Link Metadata with GNU stat

October 25, 2026

Two filenames can hold equal text while referring to different files, or refer to the same file through hard links. GNU stat exposes the metadata needed to tell those cases apart. It also lets you choose whether to inspect a symbolic link itself or the target it names.

These examples were tested on Linux with GNU Bash 5.2.37 and GNU coreutils 9.7. They use LC_ALL=C and only create practice files. No ownership, permissions, or system settings are changed.

Prepare a scratch directory

You need Bash, GNU stat, a text editor, and the cp, ln, mktemp, rm, and rmdir utilities. Keep using the same terminal. Setup must succeed before saving any example. Save each named script here and run the scripts once in order.

start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

1. Print only the fields your report needs

Save this as stat-size-report.sh:

#!/usr/bin/env bash
set -eu
export LC_ALL=C
printf 'cafe\n' > ascii.txt
printf 'caf\303\251\n' > utf8.txt
printf 'Name|Type|Logical size\n'
stat --printf='%n|%F|%s bytes\n' -- ascii.txt utf8.txt

Run it:

bash stat-size-report.sh

Output:

Name|Type|Logical size
ascii.txt|regular file|5 bytes
utf8.txt|regular file|6 bytes

%n prints the supplied name, %F prints the file type, and %s prints the logical length in bytes. The second fixture encodes café in UTF-8. The accented character occupies two bytes, so the equal-looking word lengths do not imply equal byte lengths. Both files also contain a final newline.

--printf interprets the \n escape and does not add its own newline. In contrast, --format adds one after each formatted result. These are GNU options, documented in the GNU stat manual packaged by Debian.

Logical byte length is not disk allocation. Sparse files and filesystem features can make those values differ. This example deliberately reports content length rather than claiming to measure storage consumption.

2. Distinguish a hard link from a copy

Save this as stat-file-identity.sh:

#!/usr/bin/env bash
set -eu
export LC_ALL=C
printf 'alpha\n' > identity-original.txt
ln identity-original.txt identity-alias.txt
cp identity-original.txt identity-copy.txt
original_id=$(stat --format='%d:%i' -- identity-original.txt)
alias_id=$(stat --format='%d:%i' -- identity-alias.txt)
copy_id=$(stat --format='%d:%i' -- identity-copy.txt)
test "$original_id" = "$alias_id"
test "$original_id" != "$copy_id"
printf 'Alias shares file identity; copy does not\n'
stat --format='Hard-link count: %h' -- identity-original.txt
printf 'beta\n' >> identity-alias.txt
stat --format='Original bytes after append: %s' -- identity-original.txt
stat --format='Copy bytes after append: %s' -- identity-copy.txt

Run it:

bash stat-file-identity.sh

Output:

Alias shares file identity; copy does not
Hard-link count: 2
Original bytes after append: 11
Copy bytes after append: 6

%d and %i give the device and inode numbers. Compare the pair: an inode number by itself is not unique across filesystems. The script compares the values instead of printing machine-specific identifiers.

The alias is another name for the same file, so appending through it also changes the original name’s reported size. The copied file remains independent. %h reports the number of hard links, which is two for this fixture.

A copied file can initially have identical contents without sharing identity. Conversely, file identity is not a permanent content fingerprint: this example changes the content without changing which file the names refer to. Use a content checksum when content equality is the question.

3. Inspect a symbolic link even when its target is missing

Save this as stat-symlink-target.sh:

#!/usr/bin/env bash
set -eu
export LC_ALL=C
printf 'ready\n' > stat-target.txt
ln -s stat-target.txt stat-link.txt
stat --format='Entry: %F' -- stat-link.txt
stat --dereference --format='Target: %F, %s bytes' -- stat-link.txt
rm -- stat-target.txt
stat --format='After target removal: %F' -- stat-link.txt
if stat --dereference --format='%s' -- stat-link.txt \
  > unavailable-size.txt 2> stat-target-error.txt; then
  printf 'Unexpected success\n' >&2
  exit 1
else
  status=$?
  test -s stat-target-error.txt
  printf 'Target lookup rejected: status %s\n' "$status"
fi

Run it:

bash stat-symlink-target.sh

Output:

Entry: symbolic link
Target: regular file, 6 bytes
After target removal: symbolic link
Target lookup rejected: status 1

By default, GNU stat describes the symbolic-link entry. --dereference, or -L, follows it and describes the target. Removing this example’s own target leaves the link entry intact, so the default lookup still succeeds while the dereferenced lookup fails.

The conditional keeps the expected failure separate from a valid size of zero. Do not substitute zero for every failed metadata lookup: an empty file, a missing target, and a permission error have different meanings.

Keep in mind

Metadata is a snapshot, not a lock. A file can change between a stat call and the next command, and inode numbers can later be reused. These checks describe the controlled fixtures at inspection time, not a durable identity system.

The |-separated report uses known filenames. Real filenames can contain delimiters or newlines, so do not turn this display format into an arbitrary-filename parser. GNU stat --printf can emit NUL separators when the receiving program supports them.

Clean up

The third script already removed its own target. Remove the remaining named files and links, then remove the empty scratch directory:

rm -- stat-size-report.sh stat-file-identity.sh stat-symlink-target.sh \
  ascii.txt utf8.txt identity-original.txt identity-alias.txt \
  identity-copy.txt stat-link.txt unavailable-size.txt stat-target-error.txt
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Acknowledgements

This post was created with assistance from AI.

Last updated on