Why Does Go Always Close Things?
To understand this, we need to go down below and understand how the operating system works.
The Operating System and Processes
When you run any program — Go, Python, C, anything — the operating system creates a process: an isolated environment for your program to run in, with its own memory, stack, and resources.
Every process gets a file descriptor table — just an array of numbered slots. When your process starts, 3 are already filled:
FD 0 → stdin (input — comes in, from keyboard by default)
FD 1 → stdout (output — goes out, to screen by default)
FD 2 → stderr (errors — goes out, to screen by default)These aren’t system call names — they’re just human-readable aliases for the first three slots the OS sets up before your program even runs. Think of them as three doorways your program is born with: one for data coming in, two for data going out (normal results vs. error messages).
But how does the program know slot 0 is for input and slot 1 is for output? It doesn’t discover it — it’s a fixed convention everyone agrees on in advance. The numbers 0, 1, 2 always mean input, output, errors — on every Unix-like system, in every language. So when you write print(...), the language library is hardwired to send those bytes to slot 1, because "1 means output" is a rule baked into the whole ecosystem. The program doesn't inspect the slot to figure out its purpose, it just trusts the convention and writes to the number it knows is correct.
By default the shell wires those slots to your terminal, but they can be repointed to files or pipes (that’s what > and < do) — and the program never notices, because it's still just reading from 0 and writing to 1. The rest of the slots start empty, waiting to be used. We will touch on it more later, so don’t worry if you didn’t understand.
What Is a File Descriptor
Your program lives in user space — it has no direct access to files, network, or hardware. It has to ask the kernel to do it. That request is called a system call.
A system call is the controlled gateway between your program and the kernel. Your program runs in user mode, locked out of the hardware — it can’t touch the disk, the network card, or another program’s memory. The kernel runs in kernel mode, with full power. So your program asks: the CPU switches to kernel mode, the kernel checks you’re allowed and does the real work, then switches back and hands you the result. This gate exists for three reasons — safety (one program can’t wreck another), abstraction (you just say read() without caring what kind of disk it is), and arbitration (the kernel fairly shares hardware among many programs). open(), read(), write(), close() — every privileged thing your program does is a system call.
So when you open a file, make an HTTP request, or connect to a database, you’re asking the kernel: “do this for me.” It finds the next empty slot, fills it, and hands you back the index.
open("data.csv") → open() system call → FD 3
connect(google.com) → socket() system call → FD 4
connect(database) → socket() system call → FD 5That integer — 3, 4, 5 — is your file descriptor. No objects, no classes — just a number pointing to a kernel resource. Whatever created it (a file, a socket, a pipe), an FD always means the same thing: an open handle to a kernel resource that occupies a slot until you close it. The number itself is just an index — it doesn’t encode the kind of resource. But it points into the kernel’s tables, which do: follow the slot down and you’ll find exactly what it is. We’ll cover those tables next.
The Three Tables
When you open a file, it is not just one table involved. The kernel actually manages three separate tables.
Table 1 — File Descriptor Table (per process) Every process has its own. Just a table of pointers — each FD slot pointing to an entry in the next table.
Table 2 — Open File Table (system-wide) A single global table shared across all processes. Each entry contains:
- Current read/write position (offset)
- Access mode (read only, write only, both)
- Reference count
- Pointer to the inode or socket structure
Table 3 — Inode Table (for files) / Socket Structure (for network) Also global. For files — contains the actual metadata: file size, permissions, where the data lives on disk. For network connections — contains IP addresses, ports, send/receive buffers, and connection state (ESTABLISHED, CLOSE_WAIT…).
The full chain:
Your FD Table → Open File Table → Inode (for files)
→ Socket struct (for network)Why three tables? So two processes can open the same file independently — each with their own offset — but share the same underlying inode on disk.
And when you call Close() — it doesn't just free one thing. It cleans up across all three tables, decrementing reference counts at each level.
The Limit — Soft vs Hard
Every process has two limits on how many FD slots it can hold:
- Soft limit → the currently enforced limit
- Hard limit → the maximum the soft limit can be raised to
Think of it like a speed limit with two numbers. Soft is what you are allowed right now. Hard is the absolute ceiling you can ever reach. A regular process can raise its own soft limit up to the hard limit anytime — no root needed.
You have probably heard that the limit is 1024. Where does that come from?
It is just the default soft limit — and it has a historical reason. A very old system call called select() only worked with file descriptors in the range 0 to 1023. So Linux kept the soft limit at 1024 to protect old C programs that used select() from breaking. That is it. 1024 was never the real ceiling — it was a compatibility default.
On modern Linux with systemd v240+ (since 2019), the hard limit was bumped to 512K (commonly 512K to ~1M, depending on distro and config). And in Go 1.19, the Go team made an official decision: since Go does not use select() at all, Go programs now automatically raise the soft limit to match the hard limit before your main() even runs.
Official reference — Go 1.19 Release Notes (go.dev/doc/go1.19): “On Unix operating systems, Go programs that import package os now automatically increase the open file limit (RLIMIT_NOFILE) to the maximum allowed value. This corrects artificially low limits set on some systems for compatibility with very old C programs using the select system call.”
You can verify this yourself:
import "syscall"
var rLimit syscall.Rlimit
syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
fmt.Printf("Soft: %d\n", rLimit.Cur)
fmt.Printf("Hard: %d\n", rLimit.Max)
// Output:
// Soft: 1048576
// Hard: 1048576So your real FD limit as a Go program today is not 1024 — it is your hard limit, which is typically around 1 million on modern systems.
The Full Flow — Reading a File
Let’s walk through what actually happens when you read a file — all the way down to hardware and back.
Step 1 — your Go code calls os.Open().
Step 2 — Go calls the kernel via a system call. The CPU switches from user mode to kernel mode.
Step 3 — the kernel looks up the file in the filesystem, checks permissions, creates a file struct in kernel memory, finds the lowest free slot in your FD table, puts a reference there, and returns the slot number. Nothing from disk was read yet — just bookkeeping.
Step 4 — you call io.ReadAll(). Go calls the read() system call. Kernel mode again.
Step 5 — the kernel asks the filesystem driver for the data. The filesystem driver asks the disk driver. The disk controller reads the physical sectors. The data comes back via DMA (Direct Memory Access) directly into a kernel buffer in RAM. No CPU involved in the transfer.
Step 6 — the kernel copies data from its buffer into your program’s memory. CPU switches back to user mode. Your code now has the data.
Step 7 — you call f.Close(). The kernel removes the entry from the open file table, decrements reference counts, and if they hit zero — frees the inode reference too. The slot is freed. Available again.
Your Go code (user space)
↓ open()
Kernel — VFS layer (finds file, checks permissions)
↓
Filesystem driver (ext4, btrfs, etc.)
↓
Disk driver
↓
Hardware (disk controller reads physical sectors)
↓ DMA into kernel buffer
Kernel copies data → your program's buffer
↑
Your Go code gets the data backWhat Happens Without Close
Every time you open something, a slot gets filled. When you are done, you must tell the kernel: “I am done, free this slot.”
That is also a system call. It is called close().
close(3) → kernel frees slot 3
close(4) → kernel frees slot 4When you call close() — the slot is freed and the OS reuses it. The OS always picks the lowest available slot — so the same FD number gets reused every time.
When you don’t close — the slot stays reserved. Forever. Every new open() needs a brand new slot. They accumulate. Until you hit the hard limit — and your app crashes with:
too many open filesProving It — The FD Slot Reuse Test
Let’s prove that the OS actually reuses slots with a simple test.
With Close — same slot reused every iteration:
for i := 0; i < 5; i++ {
file, _ := os.Open("test.txt")
fd := file.Fd()
fmt.Printf("iteration %d → FD number: %d\n", i, fd)
file.Close() // free the slot
}Output:
iteration 0 → FD number: 3
iteration 1 → FD number: 3
iteration 2 → FD number: 3
iteration 3 → FD number: 3
iteration 4 → FD number: 3Same FD number every iteration. The OS freed slot 3 and reused it.
Get Zamir Babayev’s stories in your inbox
Join Medium for free to get updates from this writer.
Without Close — new slot every iteration:
for i := 0; i < 5; i++ {
file, _ := os.Open("test.txt")
fd := file.Fd()
fmt.Printf("iteration %d → FD number: %d\n", i, fd)
// no close
}Output:
iteration 0 → FD number: 3
iteration 1 → FD number: 4
iteration 2 → FD number: 5
iteration 3 → FD number: 6
iteration 4 → FD number: 7Different FD number every iteration. Slot never freed. OS keeps assigning new ones. This is your leak happening at the FD level in real time.
Proving It — Hitting The Wall
Now let’s push it to the actual limit with a 3 million iteration loop. You don’t actually need that many — anything past the hard limit (~1 million) is enough to prove the point. But let’s be thorough.
Test 1 — with Close, holding references:
package main
import (
"fmt"
"io"
"os"
)
func main() {
files := make([]*os.File, 0)
for i := 0; i < 3000000; i++ {
file, err := os.Open("test.txt")
if err != nil {
fmt.Printf("failed at iteration %d: %v\n", i, err)
return
}
content, err := io.ReadAll(file)
if err != nil {
fmt.Printf("read error at iteration %d: %v\n", i, err)
return
}
files = append(files, file)
file.Close() // close immediately after reading
fmt.Printf("iteration %d content: %s\n", i, content)
}
}Result:
...
iteration 2999998 content: hello I am file
iteration 2999999 content: hello I am fileRan all 3 million iterations. No crash. Because Close() freed the slot every iteration — the OS reused the same FD number every single time.
Test 2 — without Close, holding references:
package main
import (
"fmt"
"io"
"os"
)
func main() {
files := make([]*os.File, 0) // holding references - same as Test 1
for i := 0; i < 3000000; i++ {
file, err := os.Open("test.txt")
if err != nil {
fmt.Printf("failed at iteration %d: %v\n", i, err)
return
}
content, err := io.ReadAll(file)
if err != nil {
fmt.Printf("read error at iteration %d: %v\n", i, err)
return
}
files = append(files, file) // no Close() - slot never freed
fmt.Printf("iteration %d content: %s\n", i, content)
}
}Result:
...
iteration 1048569 content: hello I am file
failed at iteration 1048570: open test.txt: too many open filesCrashed on this machine 1048570 — right at the hard limit of the machine (1048576), minus the slots already taken by stdin, stdout, stderr, and the Go runtime.
Both tests hold references in the files slice. The only difference is file.Close(). That single line is the difference between running 3 million iterations and crashing at 1 million.
The numbers tell the whole story. The wall is real.
Why the GC Won’t Save You
You might think — the garbage collector will clean up for me.
No. The GC cleans up memory. It does NOT free file descriptors reliably.
The *os.File type does have a finalizer that may call Close() — but only whenever the GC decides to run, and there is no guarantee it runs at all before your program exits.
Official reference — Go runtime docs (pkg.go.dev/runtime#SetFinalizer): “There is no guarantee that finalizers will run before a program exits.”
Official reference — Go GC Guide (go.dev/doc/gc-guide): The GC is responsible for recycling memory — not OS resources like file descriptors. Those must be released explicitly by the program.
Under high load, many goroutines, or memory pressure — the GC won’t run fast enough. Slots pile up. You hit the wall. Your app crashes.
Only explicit Close() is deterministic.
Go — The Full Call Chain
In Go, when you write:
os.Open("data.csv")Go calls the kernel via a system call. The kernel fills a slot and returns the FD — Go wraps it in *os.File and gives it to you.
And when you call Close():
f.Close()You are not just calling a Go method. You are ultimately calling the Linux system call close(). The full chain:
f.Close()
↓
syscall.Close(fd) ← Go syscall package
↓
close(3) ← actual Linux system call
↓
Kernel frees slot 3 in your FD tableFor network connections it goes one step further:
resp.Body.Close()
↓
net.TCPConn.Close() ← closes the TCP connection
↓
syscall.Close(fd)
↓
close(4) ← kernel frees the socket descriptor slotYou can prove it with strace — a tool that shows every system call your program makes:
strace -e trace=openat,close ./your-programOutput:
openat(AT_FDCWD, "data.csv", O_RDONLY) = 3
close(3) = 0There it is. close(3). Called by the kernel the moment your Go code calls f.Close().
The Pattern
This is why the pattern in Go is always:
// File
f, err := os.Open("data.csv")
if err != nil {
return err
}
defer f.Close()
// HTTP response
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
// Database rows
rows, err := db.Query("SELECT ...")
if err != nil {
return err
}
defer rows.Close()Open → check error → immediately defer close.
Why immediately? Because if you return early on an error and Close() is at the bottom — it never runs. defer guarantees it runs when the function exits, no matter how.
One important trap — never use defer inside a loop:
// WRONG
for i := 0; i < 1000; i++ {
file, _ := os.Open("test.txt")
defer file.Close() // runs when function exits, NOT each iteration
}defer runs when the function exits — not when the loop iteration ends. So you are holding all 1000 FDs open the entire time.
The right way — wrap it in a function:
func readFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close() // safe — runs when readFile() returns
// read...
return nil
}
func main() {
for i := 0; i < 1000; i++ {
readFile("test.txt")
}
}All tests in this article were run on Go 1.25.6 on Linux amd64.
I hope this article helped you understand what is actually happening under the hood when you write defer f.Close(). It is not just a Go convention — it goes all the way down to the kernel, to system calls, to hardware.
Next time you open a file, make an HTTP request, or connect to a database — you will know exactly what slot is being filled, and why it needs to be returned.
If you have any questions or feedback — drop them in the comments. Thanks for reading.
References
- Go 1.19 Release Notes — RLIMIT_NOFILE change https://go.dev/doc/go1.19
- Go runtime — SetFinalizer documentation https://pkg.go.dev/runtime#SetFinalizer
- A Guide to the Go Garbage Collector https://go.dev/doc/gc-guide
- Lennart Poettering — File Descriptor Limits (systemd) https://0pointer.net/blog/file-descriptor-limits.html
- Linux man page — open(2) system call https://man7.org/linux/man-pages/man2/open.2.html
- Linux man page — close(2) system call https://man7.org/linux/man-pages/man2/close.2.html
- Linux man page — socket(2) system call https://man7.org/linux/man-pages/man2/socket.2.html
- Linux man page — getrlimit(2) — RLIMIT_NOFILE https://man7.org/linux/man-pages/man2/getrlimit.2.html







