A backend service needs to load a configuration file, append records to a log, replace a generated report, and inspect the size of a database file. All four tasks operate on files, but they require different contracts.
Loading configuration must detect end of file correctly. Logging must avoid overwriting existing records. Replacing a report should not expose a half-written result. Inspecting a file should return metadata rather than application bytes.
Unix-like systems express these tasks with a small family of file operations:
Each operation has a precise return value and failure contract. Correct systems code depends less on memorizing function names than on understanding those contracts.
A file operation is a request to the kernel, and its return value reports exactly how much of that request the kernel completed.
That principle is especially important for read() and write(): a successful call can complete fewer bytes than the application requested.
open() asks the kernel to locate a file-system object, establish an open file description, install a descriptor-table entry, and return its file descriptor:
The pathname identifies what to open. The flags describe how the process intends to use it.
Every call selects one access mode:
O_RDONLY opens for reading.O_WRONLY opens for writing.O_RDWR opens for both reading and writing.The access mode is not a request to ignore the file's access controls. The kernel still checks whether the process is allowed to open the object that way. A read-only open can fail, and opening with O_RDWR does not grant permissions the process lacks.
On success, the returned descriptor is nonnegative. On failure, the result is -1, and errno provides the reason. Code must check for -1, not for a particular successful number:
O_CLOEXEC marks the new descriptor to close automatically across a successful exec(). Setting it as part of open() avoids a descriptor-inheritance race in multithreaded programs.
Once the open succeeds, later operations use fd. They do not resolve the original pathname again. The descriptor continues to identify the opened object even if its name changes afterward.
open() Behavioropen() can also create or replace file contents. Several flags control this behavior.
O_CREAT says to create a regular file if the final name does not already identify one. When this flag is present, open() receives an additional mode argument:
The mode requests initial access bits for a newly created file. System policy and the process's file-creation mask can remove some requested permissions. If the file already exists, this mode argument does not replace its existing permissions.
O_EXCL combined with O_CREAT means “create only if the name does not already exist”:
If the name already exists, the operation fails with EEXIST. The existence check and creation occur as one operation, so another process cannot create the same name in between a separate check and create.
This is safer than:
The gap between the check and the create is where another process acts. Closing it requires a single operation that both tests and creates.
O_TRUNC says that if an existing regular file is successfully opened for writing, its length should become zero. This is destructive:
By the time open() returns successfully, the previous contents are gone from the logical file. If the process crashes before writing the replacement, an empty or partially rewritten file can remain.
Flags should therefore express the application's intended policy:
These policies are meaningfully different even though all of them eventually return a file descriptor.
read() Outcomes: Bytes, EOF, or an Errorread() requests up to count bytes from an open object:
For a regular file that uses the current file offset, the kernel copies available bytes into buffer and advances the offset by the number of bytes returned.
The result has three categories:
Suppose an application supplies a 4 KiB buffer:
A result of 1200 is successful. Exactly 1,200 bytes are valid in buffer; the remaining bytes retain whatever values they had before the call. Treating all 4,096 bytes as new input would be a bug.
A positive result smaller than the requested count is a short read. For a regular file, this commonly happens near EOF. Other I/O objects can return short reads for additional reasons. A short read is not itself an error.
A result of 0 is how a regular-file read reports EOF. EOF is not reported as -1, and it is not represented by a special byte placed in the buffer.
An interrupted call can fail with EINTR before producing data. Code that should continue can retry that particular operation:
The use of ssize_t is deliberate. The return type must represent nonnegative byte counts and the negative error sentinel -1.
One call to read() does not mean “read the whole file.” It means “read up to this many bytes.”
A complete sequential scan repeats the operation until EOF:
The loop must preserve application-level boundaries itself. A JSON document might be split across many reads. One read might contain several newline-delimited log records plus the beginning of the next one.
System-call boundaries and application-record boundaries are independent. Parsers must carry incomplete data between iterations instead of assuming that each read returns one complete record.
The file can also change while it is being read. Reaching the size reported earlier by a metadata query does not necessarily mean the current scan saw an immutable snapshot. Another process may have appended, truncated, or replaced data depending on how the application coordinates access.
write() Completionwrite() requests that the kernel accept up to count bytes:
Its result has a similar structure:
A positive result smaller than count is a short write. The unwritten suffix remains the application's responsibility.
Incorrect code often assumes all bytes were accepted:
Correct code tracks progress:
This loop handles partial progress and interruption. Non-blocking objects require additional policy when an operation would block, but the accounting principle remains the same: advance the buffer only by the count actually reported.
For a regular file using the current offset, a successful positive write advances the offset by the number of bytes written. Writing can replace bytes already within the file or extend the file when it reaches beyond the previous end.
A successful write() means the kernel accepted the reported bytes according to that interface. It does not by itself mean that storage hardware has made them durable against sudden power loss.
An open file description for a seekable file maintains a current offset. Ordinary read() and write() operations begin there and advance it by the number of bytes transferred.
For a file containing ABCDE:
The resulting contents are:
The offset belongs to the open file description. Descriptors created through dup() or inherited across fork() can share it, while separate calls to open() normally create independent offsets.
Sequential access is convenient because callers do not have to provide a position for every operation. It is also stateful: code sharing an open file description must account for changes made through other descriptors.
lseek()lseek() repositions the current offset of a seekable open file description:
The origin determines how to interpret offset:
SEEK_SET measures from the beginning of the file.SEEK_CUR measures from the current offset.SEEK_END measures from the current logical end.Examples:
On success, lseek() returns the resulting offset. It transfers no file data.
Not every descriptor is seekable. Calling lseek() on a pipe commonly fails with ESPIPE, because a pipe is a flowing byte stream rather than stored data with random-access positions.
Seeking beyond EOF is allowed for a regular file. The seek alone does not change the file's size:
If the process then writes one byte, the file grows to 1,001 bytes. Reads from the unwritten gap return zero bytes. A file system may represent that gap without allocating physical storage for every logical zero, but the allocation mechanism is hidden behind the logical file view.
pread() and pwrite() operate at an explicit offset:
They do not use or change the open file description's current offset.
This is useful for database pages, indexes, and concurrent code in which each operation already knows its logical position.
Using lseek() followed by read() is not an equivalent replacement when multiple threads or processes share the open file description:
pread() combines the explicit position with the read operation, avoiding that shared-offset race. It can still return a short count, reach EOF, or fail, so callers must check its result just like read().
Loading simulation...
Opening with O_APPEND tells the kernel to position each write at the current end of the file as part of the write operation:
This is safer for concurrent appenders than manually seeking to EOF before every write:
With append mode, choosing the then-current EOF and performing one write() are treated as one append operation for a regular file under the filesystem's append semantics.
Append mode does not turn multiple calls into one transaction. If one logical log record is emitted with three separate write() calls, another writer may append between those calls:
Applications that rely on record integrity should form a complete record in memory and submit it with one appropriately sized write when the target's guarantees make that sufficient. Higher-level coordination may still be needed.
Append also says nothing about durability. It protects where a write is placed, not whether the update has reached persistent hardware.
Files can be resized explicitly with truncate() by pathname or ftruncate() through an open descriptor:
Shrinking discards bytes beyond the new logical end:
Growing extends the logical file. Reads from the newly added region return zeros until other data is written there.
Truncation changes the file object seen through every open description that refers to it. It does not reset all current offsets. A descriptor can retain an offset beyond the new EOF; a later ordinary read there returns EOF, while a later write can extend the file again.
O_TRUNC performs the resize to zero as part of a successful writable open. ftruncate() performs it explicitly after the file is already open. Both deserve care because discarded logical contents cannot be recovered through that file operation.
Applications often need facts about a file rather than its byte sequence. stat() queries by pathname, while fstat() queries the object reached through an open descriptor:
Commonly used results include the object type, logical size, ownership, access mode, and timestamps.
fstat() is valuable after opening because it asks about the exact object referenced by the descriptor. If a pathname is renamed or replaced after the open, a new stat(path) can describe a different object while fstat(fd) continues to describe the one already open.
This distinction is part of a broader systems-programming rule: once code has securely opened the intended object, descriptor-relative operations avoid repeating a name lookup that may now have a different result.
Metadata queries are observations, not permanent snapshots. Another process can modify the file immediately after stat() or fstat() returns.
rename(old_path, new_path) changes how a file-system object is named:
On POSIX file systems, a rename within one mounted file system is atomic with respect to namespace lookup. Other processes should observe the old name or the new name, not a partially renamed pathname.
If report.txt already exists and the operation's replacement rules allow it, the new name can replace the old destination association atomically. This supports a common update shape:
Readers that open by the destination name see either the previous object or the replacement object. They do not see a destination file being rewritten byte by byte.
Existing descriptors are unaffected. A process that already had the old report.txt open continues to refer to that old object. A later open by name reaches the replacement.
Atomic namespace replacement is not the same as crash durability. Whether the new contents and renamed directory entry survive an abrupt power failure requires stronger ordering and persistence guarantees.
A direct rename() across different mounted file systems normally fails with EXDEV. Copying bytes and then removing the source is a multi-operation sequence with different failure behavior.
unlink(path) removes one name from the file-system namespace:
On a Unix-like system, unlinking is not “find every process and invalidate its descriptors.” Open descriptors continue to reach the already opened object.
The file system can reclaim the object's storage only when no names and no open references require it to remain. This behavior allows programs to create temporary files that remain private after their names are removed, but it can also hide disk usage when a long-running service keeps a deleted log open.
Unlinking removes a namespace association. It is not a secure erasure guarantee for the physical storage media.
close()close(fd) removes the descriptor-table entry:
After the call, the program must treat that descriptor number as unusable. The kernel can reuse it for a different resource.
Closing one descriptor does not invalidate duplicates or inherited descriptors that still refer to the same open file description. The open state is released only after its final reference disappears.
Closing can report an error associated with pending work. Ignoring every close result can therefore hide a failure. At the same time, blindly retrying close() is unsafe on systems such as Linux because the descriptor number may already have been released and reused. Production code should follow the target operating system's close-error contract rather than treating close() like an idempotent request.
Process exit closes remaining descriptors, but long-running software must not rely on exit as routine cleanup. Descriptor lifetime should follow explicit ownership.
stdioC also provides FILE * streams through functions such as fopen(), fread(), fprintf(), and fclose().
A FILE * is a C library object, not a kernel file descriptor. The library commonly places a user-space buffer around an underlying descriptor:
The application calls fprintf(), which writes into a C stdio buffer in process memory. The library eventually calls write() to reach the kernel file interface.
Buffering can combine many small application writes into fewer system calls. It also means fprintf() can appear to succeed while bytes remain only in the process's stdio buffer.
fflush() asks the C library to pass buffered output toward the kernel. It does not by itself guarantee persistence on storage hardware.
POSIX provides fileno() to obtain a stream's underlying descriptor and fdopen() to build a stream around an existing descriptor. Mixing descriptor operations and stdio operations on the same open file requires careful coordination because the library may have buffered data and its own view of position.
Use one layer consistently unless there is a clear reason and a well-defined synchronization step between them.
The following Linux program combines the core operation contracts. It opens an existing source, creates a new destination, reads until EOF, handles interrupted calls, completes short writes, and closes both descriptors.
The destination uses O_EXCL, so the example refuses to overwrite an existing name.
Compile and run it:
cmp produces no output when the logical byte sequences match.
The program intentionally has a narrow contract. It copies bytes into a newly created regular file. It does not preserve all source metadata, replace an existing destination, make the result crash-durable, or remove a partial destination after a later error. Real copy and update tools must decide how to handle each of those policies.
That limitation illustrates an important lesson: file syscalls provide mechanisms, while the application must compose them into the desired failure behavior.
straceOn Linux, strace can reveal the system calls made by the copy program:
A simplified portion resembles:
The library's open() wrapper may appear as the openat system call on Linux. This is an implementation detail of the wrapper; the returned descriptor and operation contracts remain the same.
The trace makes several boundaries visible:
read() reports the exact number of bytes obtained.write() reports the exact number accepted.read() returning zero represents EOF.close() releases each descriptor-table entry.Real traces can also include dynamic-loader and runtime operations performed before main(). Filtering helps isolate the calls relevant to the example.
Assuming that read() filled the buffer or that write() accepted every byte can silently truncate or corrupt application data.
For read(), zero means EOF and -1 means failure. These outcomes require different control flow.
The kernel returns available bytes according to the I/O contract. Applications define and reconstruct message, line, or record boundaries.
Another process can change the namespace between separate operations. Atomic creation flags or descriptor-relative designs avoid decisions based on stale checks.
O_TRUNC before the new contents are readyThe old contents disappear as part of the successful open. A later failure can leave an empty or partial destination.
The seek and write are separate operations, so concurrent writers can race. O_APPEND combines EOF positioning with each write.
write() success as durabilityAcceptance by the kernel and persistence on physical storage are different milestones.
A FILE * can buffer bytes and maintain library state above the descriptor. Uncoordinated mixing can produce surprising positions or output ordering.
Those operations change namespace associations. Existing descriptors continue to identify the objects that were opened.
close() as safely repeatableThe descriptor number can be released and reused. Retrying without respecting the operating system's contract can close an unrelated resource.
open() establishes an access relationship and returns a file descriptor. Access modes describe reading and writing, while flags such as O_CREAT, O_EXCL, O_TRUNC, O_APPEND, and O_CLOEXEC define creation, replacement, append, and inheritance behavior.
read() and write() report byte counts, not all-or-nothing completion. Correct code processes only the bytes returned, loops through short operations, distinguishes EOF from failure, and handles interruption according to its policy.
Ordinary I/O uses the open file description's current offset. lseek() changes that offset, while pread() and pwrite() operate at explicit positions without changing it. Append mode places each write at the current EOF, and truncation changes the file's logical size.
Metadata queries inspect file properties without reading contents. rename() and unlink() change namespace associations but do not retarget existing descriptors. close() releases one descriptor entry, and C stdio streams add a separate user-space buffering layer above descriptor operations.
The central rule is:
Check every file operation's return value and reason about only the work that value says was completed.
5 quizzes