
How Linux File Descriptors Work: The Three-Layer Model
Start by reading /proc/<pid>/fd yourself. A file descriptor in Linux is a small non-negative integer your process uses as an index into its own private descriptor table. Each entry points to a kernel-wide open file description, which points to an inode. Once you have seen those three layers with your own eyes, "bad file descriptor" errors, leaks, and every shell redirection trick stop being mysterious. That three-layer structure is the thing nobody drew for you.
Last updated: 2026-07-27
The three-layer table-to-inode model behind fd 0/1/2, dup2 redirection, EBADF and EMFILE fixes, and reading /proc/
What are file descriptors in Linux, really?
A file descriptor is a per-process integer handle, nothing more. It is not the file, and you cannot dereference it like a pointer. It is an index. When your code opens something, the kernel hands back a number. Every later read, write, or close passes that number, so the kernel knows which open thing you mean.
Forget the "it's like an address" hand-waving. The number means something only inside your process. My descriptor 3 and your descriptor 3 have nothing to do with each other. The word "file" misleads too, because Unix treats terminals, pipes, sockets, and device nodes under /dev the same way. They all get descriptors, and the same read/write calls work on all of them.
People say "file handle" and "file descriptor" as if they differ. On Linux they mean the same integer. The buffered thing in C, a FILE * from fopen, wraps a descriptor you can pull out with fileno(). That is the only distinction worth keeping.
Why stdin, stdout, and stderr are always 0, 1, and 2
Because the kernel always hands out the lowest free number, and your shell opens those three first. Descriptor 0 is standard input, 1 is standard output, 2 is standard error. Nothing in the kernel hardcodes these meanings. It is convention plus allocation order, inherited from the parent.
| Descriptor | Name | Default target |
|---|---|---|
| 0 | standard input | your terminal |
| 1 | standard output | your terminal or a file |
| 2 | standard error | your terminal |
The split between 1 and 2 keeps errors visible when you pipe normal output somewhere else. Pipe a command into grep and stderr still lands on your screen, because only descriptor 1 got rewired.
You can prove all of this. Pick any running process and look:
ls -l /proc/self/fd
The left column is the descriptor number. The arrow shows what it points at. Check /proc/<pid>/fd/0, /proc/<pid>/fd/1, and /proc/<pid>/fd/2 on a real service and you see exactly where its input, output, and errors go. No guessing.
How the kernel connects a descriptor to a real file
Through three layers, and the layering is the whole point. Your descriptor table entry points to an open file description, which points to an inode. Learn these names, because they explain behavior that otherwise looks mysterious.
- The file descriptor table is per-process. Entry 3 in your process is a slot holding a reference.
- The open file description is kernel-wide and shared. It carries the current offset, the access mode, and status flags like append.
- The inode is the actual file object on disk or in memory. Many open file descriptions can reference one inode.
The offset lives in the middle layer, not in your descriptor and not in the inode. So two independent open() calls on the same file each get their own position, but a descriptor you copied keeps a shared position. Here is what happens when you forget this: two things write to the same log and clobber each other, and you blame the disk. The Linux man page for open() spells out which flags land in which layer.
Why two descriptors can point at the same open file
Because dup(), dup2(), and fork() copy the descriptor, not the open file description underneath it. The copy shares the middle layer, so it shares the offset and the flags. Write through one, and the other sees the position move.
This is why closing one descriptor does not close the file. The open file description survives as long as any descriptor still references it. The kernel keeps a reference count; the file closes for real when it hits zero. New engineers hit this when a leaked duplicate keeps a deleted file's disk space pinned, and df disagrees with du.
fork() gives the child a full copy of the parent's descriptor table, all pointing at the same shared descriptions. So a child and parent writing to the same inherited descriptor advance the same offset. That is usually what you want for a shared log, and a disaster when you didn't mean to share it. If you are shaky on what a fork copies, my walkthrough of the Linux process lifecycle covers the inheritance rules.
Where descriptors come from
Every descriptor is minted by a syscall, and knowing which one tells you what you are holding.
open()returns a descriptor for a file or device node.socket()returns one for a network endpoint before it is connected.pipe()returns two at once: a read end and a write end.accept()returns a fresh descriptor for each incoming connection on a listening socket.
Then there is inheritance. After fork(), the child owns copies of every open descriptor. After exec(), most stay open by default, which is how a shell wires up a command's input and output before the new program even starts. The exception is any descriptor marked close-on-exec with the FD_CLOEXEC flag. The kernel drops those during exec(). Set that flag on descriptors you don't want leaking into child programs, because a forgotten socket in a child is a real security problem.
What you can actually do with a descriptor
Read, write, seek, close, and adjust it. The core syscalls are short: read, write, lseek, close, fcntl, and ioctl. What each one is allowed to do depends on how the descriptor was opened.
The access mode is baked in at open time. A descriptor opened read-only rejects write with a permission error no matter what you try later. lseek moves the offset for a regular file but fails on a pipe or socket, because those have no seekable position. fcntl changes descriptor flags on the fly, like flipping a socket to non-blocking. ioctl handles device-specific commands that don't fit the read/write model.
You also lock files through descriptors. flock() and fcntl() locks tie to the open file description, so the same fork-and-share behavior decides who holds the lock. That trips people who expect a lock to follow the process. It follows the descriptor.
Redirecting and piping descriptors in the shell
Shell redirection just rewires which descriptor points where before the command runs. cmd > out.log sends descriptor 1 to a file, truncating it. cmd >> out.log appends instead. cmd 2> err.log sends descriptor 2 to its own file. That is all > and 2> do: point a descriptor at a target.
The one that bites everyone is 2>&1. It means "make descriptor 2 go wherever descriptor 1 currently goes," evaluated left to right. So cmd > out.log 2>&1 puts both in the file, because 1 was already the file when 2 copied it. Reverse the order to cmd 2>&1 > out.log and errors stay on the terminal, because 2 copied 1 while 1 still pointed at the screen. Order is everything here.
A pipe connects two processes by wiring the write end of a pipe() to one process's descriptor 1 and the read end to another's descriptor 0. Subshells and command substitutions inherit the parent's descriptor table, so redirection you set outside carries in. For the deeper mechanics of pipes between processes, see how I handle interprocess communication in Linux.
How do you inspect descriptors on a live process?
Go straight to /proc and strace, not a forum. Every running process exposes its open descriptors as symlinks under /proc/<pid>/fd. This is the fastest way to answer "where is this thing actually writing?"
- Find the pid with
pgrep myserviceorps. - Run
ls -l /proc/<pid>/fdand read the symlink targets. - Note where 1 and 2 point.
/proc/<pid>/fd/1is stdout and/proc/<pid>/fd/2is stderr. If 1 points at a logfile and 2 points at/dev/pts/0, you just learned why errors never made it into the log. - For a fuller picture including sockets and locks, run
lsof -p <pid>.
When behavior still surprises you, watch the syscalls directly:
strace -f -e trace=open,openat,dup2,close,read,write -p <pid>
That prints the real open(), dup2(), and close() calls with their return values. The return value is the descriptor number or an error. Stop assuming what a program does and check the output. It tells you the truth that a config file only implies.
Fixing a bad file descriptor error
"Bad file descriptor" is EBADF, and it means you handed a syscall a number that is not an open descriptor. Do not paste a random fix. Find which syscall failed and on which number.
Run the program under strace and look for the call that returns -1 EBADF. The line shows the exact descriptor. Now the usual causes:
- Closed twice or used after close. You called
close(fd), then read or wrote the same number. The kernel already freed it. - Never valid. An earlier
open()returned-1and you didn't check it, so you're using an error value as a descriptor. - A fork race. A child closed a descriptor the parent still expected, or a cleanup path closed it early. Concurrency turns "used after close" into an intermittent bug that only shows under load.
The fix is always the same shape: check every open(), socket(), and dup2() return value before you use it, and never close a descriptor twice. A close() you can't explain is a bug even when nothing complains yet. Make the failure reproducible under strace, fix the one bad call, and it stays fixed.
Raising file descriptor limits without hiding a leak
First decide whether you have a leak or real load, because raising the limit on a leak just delays the crash. The default cap is 1024 open file descriptors per process on many Linux systems. A busy server can legitimately need more. A leaking one will blow any number you set.
Watch the count over time. ls /proc/<pid>/fd | wc -l gives the current total. Run it every few seconds. Steady under load means real concurrency. Climbing forever while traffic is flat means a leak, and no limit will save you. Find the descriptors you open and never close first.
When the load is genuine, raise the ceiling in the right place:
ulimit -nshows and sets the soft limit for your shell and its children./proc/sys/fs/file-maxis the system-wide total across all processes.- For a service, set
LimitNOFILE=in its systemd unit, becauseulimitin a login shell never touches a daemon started by systemd.
When you cross the cap you get EMFILE, the "too many open files" error. Seeing it after a limit bump usually means the limit went in the wrong file. Confirm the running process actually inherited it by reading /proc/<pid>/limits. If you are wiring this into a long-running service, my notes on writing a Linux daemon in C cover closing descriptors on startup.
FAQ
How do I safely redirect output in a forked child before exec?
In the child, after fork() and before exec(), point the standard descriptors at your targets with dup2() and close(). Open the target file, then dup2(target, 1) to make it stdout, then close(target) since the duplicate now holds the reference. Do this in the child only, so the parent's descriptors stay intact.
Does closing a file descriptor always close the file?
No. It drops one reference to the shared open file description. The file object closes only when the last descriptor referencing it is gone. A duplicate from dup() or an inherited copy after fork() keeps it alive, which is how deleted-but-open files keep eating disk space until every holder closes.
Why does open() return the lowest available number?
POSIX requires it. The kernel scans the descriptor table and returns the first free slot. Since 0, 1, and 2 are normally taken, the first open() in a plain process usually returns 3. This predictability is useful, but never hardcode a number; capture whatever open() actually returns.
What is the difference between EBADF and EMFILE?
EBADF means the specific number you passed is not a valid open descriptor, usually from using one after close() or ignoring a failed open(). EMFILE means your process hit its open-descriptor limit and the kernel refused to create a new one. One is a logic bug, the other is a capacity or leak problem.
Can I see a descriptor's flags and offset from userspace?
Yes. Read /proc/<pid>/fdinfo/<n>. It lists the current offset (pos), the status flags (flags), and mount details for that descriptor. Pair it with ls -l /proc/<pid>/fd when a symlink target alone doesn't explain why writes land where they do.
