File Handling · intermediate · ~10 min
- Read a file's metadata (size, type, timestamps, and permission bits) with `stat` without opening the file for I/O. - Decode `st_mode` using the type macros (`S_ISREG`, `S_ISDIR`, `S_ISLNK`) and the permission masks (`S_IRUSR`, `S_IWGRP`, `S_IWOTH`, ...). - Explain the difference between `stat` and `lstat` and pick the right one for symlinks. - Validate a file (type, ownership, mode) *before* opening it, and handle the case where the file does not exist. - Print POSIX permissions in the familiar `rwxr-xr-x` form by testing individual bits.
In the prerequisite lesson fopen and the stdio model you learned to open a file and read or write its contents through a FILE * stream. But a file is more than its bytes. The operating system also keeps a small record of facts about each file: how big it is, what kind of thing it is (an ordinary file, a directory, a symbolic link), when it was last modified, who owns it, and who is allowed to read, write, or run it. This record is called the file's metadata, and on POSIX systems (Linux, macOS, the BSDs) you read it with the stat family of functions.
Reading metadata is different from reading contents. fopen says "give me a stream so I can pull bytes out." stat says "tell me about this file — I may not even want its bytes." That distinction matters: you often want to check a file before committing to opening it. Is it actually a regular file and not a directory? Is it world-writable, which might mean someone tampered with it? How large is it, so you can size a buffer or refuse a 4 GB upload? stat answers all of these by filling a struct stat you hand it.
The permission information lives inside one field, st_mode, which packs two things together: the file type and the nine classic Unix permission bits (read/write/execute for the owner, the group, and everyone else). You decode st_mode with helper macros, never by guessing numbers. Once you can read it, the same vocabulary (rwx for user, group, other) is exactly what you see in ls -l and what you set with chmod.
This lesson builds directly on stdio file handling and prepares you for working with binary files next, where knowing a file's exact size and type up front becomes essential.
Almost every real program that touches the filesystem needs metadata, not just contents:
stat check turns a crash or garbage output into a clean, explainable failure.st_size lets you allocate a buffer once instead of growing it repeatedly, decide whether to memory-map a file, or reject an oversized input early.tar), and file managers must decide whether to follow a symlink or record the link itself. Getting stat vs lstat wrong here causes data loss or copying the wrong thing.In short, stat is how a program looks before it leaps — it inspects the world before changing or trusting it.
Definition. Metadata is data describing a file rather than the file's payload. The kernel stores it in an on-disk structure called an inode; stat copies the relevant parts of that inode into a struct stat in your program.
Why it is separate. Reading metadata does not require read permission on the file's contents — it requires permission to look up the path (execute permission on the containing directories). That is why a program can know a file's size without being allowed to read its bytes.
Your program Kernel
----------- ------
stat("a.txt", &st) --> looks up inode for a.txt
copies size, mode, times, owner...
st (struct stat) <-- filled in
When to use / not use. Use stat to inspect before acting. Do not use it as a substitute for handling open errors: a file can change between your stat and your fopen (this race is called TOCTOU — time-of-check to time-of-use). Always still check the result of fopen.
Pitfall. Forgetting that stat can fail (file missing, no directory permission). It returns 0 on success and -1 on error, setting errno. Treating an uninitialized struct stat as valid gives garbage.
Knowledge check: In your own words, why can a program sometimes read a file's size but not its contents?
struct stat and its key fieldsDefinition. struct stat (declared in <sys/stat.h>) is a structure the kernel fills with metadata. You do not create files with it; you read facts into it.
The fields you will use most:
| Field | Type | Meaning |
|---|---|---|
st_size |
off_t |
Size in bytes (for regular files) |
st_mode |
mode_t |
File type and permission bits packed |
st_mtime |
time_t |
Last modification time (seconds) |
st_uid |
uid_t |
Owner's user ID |
st_gid |
gid_t |
Owning group's ID |
st_nlink |
nlink_t |
Number of hard links |
off_t may be 64-bit even on 32-bit systems, so print it carefully (cast to long long and use %lld, or use intmax_t with %jd).
Pitfall. Assuming st_size is meaningful for every kind of file. For a directory it is implementation-defined; for a device file it is not a byte count. Only trust st_size once you have confirmed S_ISREG.
st_mode: file typeDefinition. The high bits of st_mode encode the file type. You test them with the S_IS* macros, which take the whole mode and return true/false:
st_mode bit layout (conceptual):
[ type bits ][ special ][ owner ][ group ][ other ]
d/-/l... suid... rwx rwx rwx
| Macro | True when the file is... |
|---|---|
S_ISREG(m) |
a regular file |
S_ISDIR(m) |
a directory |
S_ISLNK(m) |
a symbolic link (use with lstat) |
S_ISFIFO(m) |
a named pipe (FIFO) |
S_ISCHR(m) / S_ISBLK(m) |
a character / block device |
When to use. Branch on type before doing type-specific work — e.g. only read contents from a regular file.
Pitfall. Comparing st_mode directly to a number like 0100644. That couples your code to one platform's bit values and is unreadable. Always use the macros.
Knowledge check (predict the output): If path is a directory, what does S_ISREG(st.st_mode) evaluate to — true or false? What about S_ISDIR(st.st_mode)?
st_mode: permission bitsDefinition. The low nine bits of st_mode are the classic permissions, grouped into three sets of three:
owner (user) group other
r w x r w x r w x
| | | | | | | | |
S_IRUSR S_IXUSR S_IWGRP S_IROTH S_IXOTH
S_IWUSR S_IRGRP S_IXGRP S_IWOTH
You test a bit with bitwise AND:
if (st.st_mode & S_IWOTH) { /* world-writable */ }
The full mask set:
| Who | Read | Write | Execute |
|---|---|---|---|
| Owner | S_IRUSR |
S_IWUSR |
S_IXUSR |
| Group | S_IRGRP |
S_IWGRP |
S_IXGRP |
| Other | S_IROTH |
S_IWOTH |
S_IXOTH |
When to use. To audit or display permissions, or to refuse to trust a file that is more open than it should be (e.g. a private key that is group- or world-readable).
Pitfall. Writing if (st.st_mode == S_IWOTH). Equality is wrong — st_mode holds many bits at once. You must mask with & and test for non-zero.
stat vs lstat and symlinksDefinition. A symbolic link is a small file whose contents are a path pointing at another file. stat follows the link and reports on the target; lstat does not follow it and reports on the link itself.
link.txt ---> real.txt (1000 bytes)
stat("link.txt") reports type=regular, size=1000 (the target)
lstat("link.txt") reports type=symlink, size=8 (the link's own path text)
When to use which. Use stat when you care about the data the path ultimately reaches. Use lstat when you must know whether the path is a link — for example a backup tool that should archive the link, not duplicate the target, or a security check that must not be tricked into following a link to a sensitive file.
Pitfall. Using stat in a security check and getting fooled because a symlink redirected you somewhere unexpected. When the link-vs-target distinction matters for trust, use lstat.
Include the right headers and read the return value.
#include <sys/stat.h> /* struct stat, stat, lstat, S_* macros */
#include <sys/types.h> /* off_t, mode_t, etc. (POSIX) */
struct stat st; /* you provide the storage */
if (stat(path, &st) == -1) { /* 0 = success, -1 = error */
perror("stat"); /* prints a human message + errno */
/* handle the error; do NOT read st here */
}
/* type test (whole mode in, bool out) */
if (S_ISREG(st.st_mode)) { /* ... */ }
/* permission test (mask with &, check non-zero) */
if (st.st_mode & S_IRUSR) { /* owner can read */ }
/* printing a 64-bit-safe size */
printf("%lld bytes\n", (long long)st.st_size);
Key points:
stat/lstat take a path string and a pointer to your struct stat.S_IS*(mode) macros. Permissions → use & S_I*** masks.-1 before touching the struct.statThe stat function reads information about a file without opening it for reading or writing.
int stat(const char *path, struct stat *out);
Given a path, it fills a struct stat (the out argument) with details such as:
mtime): when the contents last changed.Call stat before you open a file. This lets you check the owner or the access mode first, so you can refuse to open a file that looks unsafe.
To test individual permission bits, combine the mode with mask constants such as S_IRUSR (owner can read) or S_IWOTH (others can write).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>
/* Build a 9-char rwx string (like ls -l) from the permission bits. */
static void perm_string(mode_t m, char out[10]) {
out[0] = (m & S_IRUSR) ? 'r' : '-';
out[1] = (m & S_IWUSR) ? 'w' : '-';
out[2] = (m & S_IXUSR) ? 'x' : '-';
out[3] = (m & S_IRGRP) ? 'r' : '-';
out[4] = (m & S_IWGRP) ? 'w' : '-';
out[5] = (m & S_IXGRP) ? 'x' : '-';
out[6] = (m & S_IROTH) ? 'r' : '-';
out[7] = (m & S_IWOTH) ? 'w' : '-';
out[8] = (m & S_IXOTH) ? 'x' : '-';
out[9] = '\0';
}
static const char *type_name(mode_t m) {
if (S_ISREG(m)) return "regular file";
if (S_ISDIR(m)) return "directory";
if (S_ISLNK(m)) return "symbolic link";
if (S_ISFIFO(m)) return "named pipe";
if (S_ISCHR(m)) return "character device";
if (S_ISBLK(m)) return "block device";
return "other";
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: %s <path>\n", argv[0]);
return EXIT_FAILURE;
}
const char *path = argv[1];
struct stat st;
/* lstat: report on the path itself, even if it is a symlink */
if (lstat(path, &st) == -1) {
fprintf(stderr, "cannot stat '%s': %s\n", path, strerror(errno));
return EXIT_FAILURE;
}
char perms[10];
perm_string(st.st_mode, perms);
printf("path : %s\n", path);
printf("type : %s\n", type_name(st.st_mode));
printf("size : %lld bytes\n", (long long)st.st_size);
printf("perms: %s\n", perms);
printf("owner: uid=%lld gid=%lld\n",
(long long)st.st_uid, (long long)st.st_gid);
/* A small safety check, the kind you would do before trusting a file. */
if (st.st_mode & S_IWOTH) {
printf("warning: this path is WORLD-WRITABLE\n");
}
/* Only treat it as readable content if it is a regular file. */
if (S_ISREG(st.st_mode)) {
printf("ok: safe to open as a regular file for reading\n");
} else {
printf("note: not a regular file; do not read it as text\n");
}
return EXIT_SUCCESS;
}
What it does. The program is a miniature stat(1)/ls -l for a single path passed on the command line. It calls lstat, then reports the file's type, size, permission string, and owner, and prints two checks: a warning if the path is world-writable, and a note about whether it is safe to open as a regular file.
Expected output (example — values depend on your file). For a normal text file created by you:
path : notes.txt
type : regular file
size : 42 bytes
perms: rw-r--r--
owner: uid=1000 gid=1000
ok: safe to open as a regular file for reading
For a directory you would instead see type : directory and the note: line.
Edge cases. If the path does not exist, lstat returns -1 with errno == ENOENT and the program prints a clean error and exits. If you lack search permission on a parent directory you get EACCES. Using lstat here means a symlink is reported as a symlink; switch to stat if you instead want the target's type and size.
We trace the program for the command ./meta notes.txt, where notes.txt is a 42-byte regular file owned by uid 1000.
argc != 2 is false (we passed one path), so we skip the usage message. path now points at "notes.txt".struct stat st; reserves uninitialized storage on the stack. Nothing is valid in it yet.lstat(path, &st) asks the kernel to look up notes.txt and copy its inode metadata into st. It returns 0 (success), so we do not enter the error branch. After this line, st is fully populated.perm_string(st.st_mode, perms) reads the low nine bits of st_mode. For mode rw-r--r-- the table fills as below; each & either yields a non-zero value (bit set → letter) or 0 (bit clear → -).| index | mask | bit set? | char |
|---|---|---|---|
| 0 | S_IRUSR | yes | r |
| 1 | S_IWUSR | yes | w |
| 2 | S_IXUSR | no | - |
| 3 | S_IRGRP | yes | r |
| 4 | S_IWGRP | no | - |
| 5 | S_IXGRP | no | - |
| 6 | S_IROTH | yes | r |
| 7 | S_IWOTH | no | - |
| 8 | S_IXOTH | no | - |
So perms becomes "rw-r--r--" with a terminating '\0' at index 9.
5. type_name(st.st_mode) tests the type bits top-to-bottom. S_ISREG is true, so it returns "regular file" immediately and the later branches never run.
6. The printf block prints path, type, size, perms, and owner. st_size is cast to long long so %lld is safe regardless of off_t's real width; it prints 42.
7. World-writable check. st.st_mode & S_IWOTH evaluates to 0 (the other-write bit is clear), so the warning is skipped.
8. Regular-file check. S_ISREG(st.st_mode) is true, so we print the ok: line and return EXIT_SUCCESS (0).
If instead the file were missing, step 3's lstat would return -1, errno would be ENOENT, we would print cannot stat 'notes.txt': No such file or directory, and return EXIT_FAILURE without ever touching st.
statstruct stat st;
stat(path, &st); /* WRONG: result ignored */
printf("%lld\n", (long long)st.st_size); /* st may be garbage */
Why wrong. If the file is missing or unreachable, stat returns -1 and leaves st uninitialized. You then print whatever happened to be on the stack. Fix:
if (stat(path, &st) == -1) { perror("stat"); return 1; }
printf("%lld\n", (long long)st.st_size);
Recognize it: random or zero sizes, no error on a name you mistyped. Always branch on == -1 first.
st_mode with ==if (st.st_mode == S_IWOTH) { /* WRONG */ }
Why wrong. st_mode packs the type and all nine permission bits together, so it almost never equals a single mask. Fix: mask, then test for non-zero:
if (st.st_mode & S_IWOTH) { /* world-writable */ }
if ((st.st_mode & 0170000) == 0040000) { /* directory? WRONG-ish */ }
Why wrong. Magic numbers are unreadable and not portable. Fix: if (S_ISDIR(st.st_mode)).
stat when you meant lstat (or vice-versa)Using stat on a symlink reports the target, so S_ISLNK is never true. If you are checking "is this a link?" you must use lstat. Conversely, if you want the target's size, lstat gives you the tiny link size instead. Recognize it: symlink checks that never fire, or sizes that are suspiciously small (like 8 or 12 bytes).
st_size with %d or %ldoff_t is often 64-bit. printf("%d", st.st_size) is undefined behavior and prints wrong values for large files. Fix: cast to long long and use %lld, or use %jd with (intmax_t).
Compiler errors
unknown type name 'mode_t' or implicit declaration of 'stat' → you forgot #include <sys/stat.h> (and on some systems <sys/types.h>).'S_ISREG' undeclared → same missing header.st_size (format '%d' expects 'int' but argument has type 'off_t') → cast to (long long) and use %lld. Compile with -Wall -Wextra so these surface.Runtime errors
stat returns -1: print the reason with perror("stat") or strerror(errno). Common errno values: ENOENT (no such file), EACCES (no search permission on a parent dir), ENOTDIR (a path component is not a directory), ELOOP (too many symlink hops).st after stat failed. Add the -1 check.Logic errors
== instead of &, or testing the wrong who/what mask (e.g. S_IWUSR instead of S_IWOTH).S_ISLNK never true → you used stat, which follows links; switch to lstat.Questions to ask when it doesn't work
stat's return value before reading the struct?& for permissions and using S_IS* for type?lstat or stat?ls -l <path> agree with what my program prints? (Great cross-check.)stat itself does not allocate memory, but a few correctness and undefined-behavior concerns apply to this topic:
struct stat fields after stat returned -1, you are reading indeterminate values — undefined behavior. Always gate field access on a successful return.struct stat (&st). Passing a wild or NULL pointer is undefined behavior; the kernel writes through it.st_size. Treating a 64-bit off_t as a 32-bit int (in a format string or an assignment) truncates large sizes silently. Use long long/%lld or intmax_t/%jd.stat and a later open/fopen. Never rely on the stat result as a guarantee — re-check errors when you actually open, and for security-sensitive code open first and then fstat the resulting descriptor so the check and use refer to the same file.lstat so an attacker cannot redirect your check through a symlink to a file you did not intend to touch.st_size for non-regular files. Only use it after confirming S_ISREG; for directories and devices the value is not a content byte count.Concrete uses
ls -l reads stat/lstat for every entry to print type, permissions, size, owner, and modification time — the exact fields covered here.rsync, tar) compare st_mtime and st_size to decide what changed, and use lstat to preserve symlinks instead of copying their targets.stat a requested file to send the correct Content-Length and to send 304 Not Modified based on modification time, and to refuse serving a directory.Professional best-practice habits
Beginner rules:
stat's return value before reading the struct.S_IS* macros for type and & S_I*** masks for permissions — never magic octals.%lld + cast, or %jd).perror/strerror(errno) so failures are diagnosable.Advanced rules:
stat vs lstat deliberately based on whether you mean the target or the link.open the file, then fstat the descriptor, and act on that descriptor.Beginner 1 — File size reporter. Write a program that takes a path on the command line, calls stat, and prints "<path>: <size> bytes". Requirements: check the return value; on failure print an error with strerror(errno) and exit non-zero. Constraints: print the size with %lld and a cast. Hint: this mirrors the file-permissions-stdio-ex1 exercise. Concepts: stat, st_size, error handling.
Beginner 2 — Type classifier. Extend the above so it also prints one of regular, directory, symlink, or other. Requirements: use lstat so symlinks are detected; branch with S_ISREG, S_ISDIR, S_ISLNK. Output example: report.txt: regular, 18 bytes. Hint: relates to file-permissions-stdio-ex2 (S_ISREG). Concepts: lstat, type macros.
Intermediate 1 — rwx printer. Print the nine-character permission string (rwxr-xr-x) for a path, matching the order in ls -l. Requirements: test each of the nine masks; build a 10-char buffer (9 chars + '\0'). Constraints: no magic numbers. Hint: reuse the perm_string pattern from the lesson code. Concepts: permission masks, bitwise &.
Intermediate 2 — World-writable auditor. Given a list of paths as command-line arguments, print only those that are world-writable, prefixed with WARN:. Requirements: loop over argv; stat each; skip (with an error line) any that fail. Input example: ./audit /etc/hosts ./shared.conf. Hint: test st.st_mode & S_IWOTH. Concepts: iteration, masks, robust error handling.
Challenge — Mini ls -l line. For a single path, print one line resembling -rw-r--r-- 1 1000 1000 42 notes.txt: a type char (-/d/l), the rwx string, the link count (st_nlink), uid, gid, size, and name. Requirements: use lstat; pick the type char from the S_IS* macros; format the size width-safely. Constraints: handle a missing file cleanly. Hint: combine the type-char, perm_string, and the numeric fields. Concepts: lstat, type + permission decoding, st_nlink, careful formatting.
stat(path, &st) fills a struct stat with facts about a file — size, type, times, owner, permissions — without opening it for I/O. It returns 0 on success and -1 (with errno) on failure; always check before reading the struct.st_size (byte size, trustworthy only for regular files), st_mode (type and permission bits packed together), st_mtime, st_uid/st_gid, st_nlink. Print st_size width-safely with %lld + a cast.st_mode. Use the S_IS*(mode) macros for type (S_ISREG, S_ISDIR, S_ISLNK, ...) and mode & S_I*** masks for permissions (S_IRUSR, S_IWGRP, S_IWOTH, ...). Never compare with == and never hard-code octal.stat vs lstat. stat follows symlinks (reports the target); lstat does not (reports the link). Choose by whether you mean the link or what it points to, and prefer lstat for security checks.== instead of &, magic octals, the wrong link function, and printing off_t with %d.