Updated Jul 18, 2026

01 - The OS Interface & Syscalls

📋 Jump to Takeaways

🎁 Every time your Go program opens a file or sends a byte over the network, it stops running its own code and asks the kernel to do the work for it. What actually happens at that boundary, and why does it shape everything else in this course?

Go feels high-level. You call os.ReadFile and get bytes back. But under every one of those calls is a conversation with the operating system kernel. This lesson is about that conversation: what a system call is, the three ways Go lets you make one, and how to read the errors that come back.

What Is a Syscall

A system call (syscall) is how your program asks the kernel to do something it isn't allowed to do on its own: open a file, read from a socket, start a process, allocate memory. Your code runs in user space, walled off from the hardware. The kernel runs in kernel space, with full access. A syscall is the doorway between them.

Take this innocent line:

data, err := os.ReadFile("/etc/hostname")
fmt.Println(len(data), "bytes")
// 9 bytes

That single call triggers several syscalls behind the scenes: openat to open the file, read to pull the bytes in, close to release it. Go hides all of that behind one function.

You almost never make syscalls directly. The standard library wraps them in portable, friendly functions. But knowing they're there explains a lot: why some calls block, why errors look the way they do, and why Go's runtime is built the way it is.

Three Layers of OS Access

Go gives you three levels of access to the kernel. Reach for the highest one that does the job.

os is the top layer. Portable and ergonomic, it works the same on Linux, macOS, and Windows:

f, err := os.Open("data.txt")   // works everywhere

syscall is the raw layer, one thin step above the kernel. It's frozen: the Go team locked it down and won't extend it. You'll still use it for stable, everyday pieces like signal constants (syscall.SIGTERM) and long-standing calls such as syscall.Getrlimit, but for anything it doesn't already expose, the maintained package below is the place to go.

fd, err := syscall.Open("data.txt", syscall.O_RDONLY, 0)
// fd is a raw descriptor (an int), not an *os.File

golang.org/x/sys/unix is the maintained low-level layer. When you genuinely need a raw syscall, use this instead of syscall:

import "golang.org/x/sys/unix"

fd, err := unix.Open("data.txt", unix.O_RDONLY, 0)

The rule of thumb: use os about 95% of the time, and when you need a raw call os doesn't expose, reach for x/sys/unix rather than adding to the frozen syscall package yourself.

File Descriptors

When the kernel opens a file, socket, or pipe for you, it hands back a file descriptor: a small non-negative integer that identifies that open resource. In Unix, nearly everything is a file descriptor, sockets and pipes included.

Three are always open when your program starts:

fmt.Println(os.Stdin.Fd())   // 0
fmt.Println(os.Stdout.Fd())  // 1
fmt.Println(os.Stderr.Fd())  // 2

An *os.File is really a wrapper around one of these numbers. You can see the raw descriptor with .Fd():

f, _ := os.Open("data.txt")
fmt.Println(f.Fd())   // 3  (the next free descriptor)

Descriptors are a limited resource. The OS caps how many you can hold open at once (we read that limit in Lesson 10). That cap is exactly why every open needs a matching close, usually defer f.Close().

syscall.Errno

When a syscall fails, the kernel doesn't return a sentence. It returns a number, an errno. Go models that number as syscall.Errno, which implements the error interface, so it prints a readable message:

err := os.Remove("ghost.txt")
fmt.Println(err)
// remove ghost.txt: no such file or directory

That message came from an errno. Dig into the error chain and you can pull it out:

var errno syscall.Errno
if errors.As(err, &errno) {
    fmt.Println(errno)            // no such file or directory
    fmt.Println(uintptr(errno))  // 2   (the ENOENT code)
}

The ones you'll meet constantly: ENOENT (no such file), EACCES (permission denied), EEXIST (already exists), EINTR (interrupted), and EAGAIN (resource temporarily unavailable). If you want a refresher on errors.As and the error chain itself, that's covered in Go Essentials → Error Handling.

Errno as a Portable Sentinel

You could compare an error against syscall.ENOENT directly, but that's noisy and not portable. Instead, the os package maps the common errnos onto portable sentinel errors:

Errno Sentinel error
ENOENT os.ErrNotExist
EEXIST os.ErrExist
EACCES, EPERM os.ErrPermission

So you check what the error means, not which number it carries:

_, err := os.Open("/root/secret.txt")
if errors.Is(err, os.ErrPermission) {
    fmt.Println("not allowed to read that")
    // not allowed to read that
}

This works even though the real error is an *os.PathError wrapping a syscall.Errno. errors.Is walks the entire chain, and syscall.Errno knows how to match itself against the sentinel. It's the same errors.Is from Go Essentials, now reaching all the way down to the kernel. Prefer this form over comparing raw errno values.

Interrupted Syscalls

Here's a classic Unix gotcha. A slow syscall, like a read that's blocked waiting for data, can be interrupted by a signal before it finishes. A signal is a small notification the kernel or another process sends yours to interrupt it, like the SIGINT you get from Ctrl+C. When that happens, the syscall returns EINTR instead of a result. The fix is simply to call it again.

With raw syscalls, you write the retry loop yourself:

for {
    n, err := unix.Read(fd, buf)
    if err == unix.EINTR {
        continue   // interrupted by a signal, retry
    }
    // n and err are now the real result
    _ = n
    break
}

The good news is you rarely need that loop. Since Go 1.14, the runtime automatically retries EINTR for standard-library operations like os.File.Read and network calls, so os.ReadFile never bothers you with it. You only handle EINTR by hand when you're calling raw syscalls through x/sys yourself.

Key Takeaways

  • A syscall is the boundary between user space (your code) and the kernel; os.ReadFile and friends are thin wrappers over calls like openat, read, and close.
  • Go has three access layers: os (portable, use almost always), golang.org/x/sys/unix (maintained low-level, for syscalls os lacks), and syscall (frozen, avoid in new code).
  • A file descriptor is a small non-negative integer for an open resource. 0/1/2 are stdin/stdout/stderr; *os.File.Fd() exposes the raw number. Descriptors are limited, so always defer f.Close().
  • syscall.Errno is the kernel's numeric error code as a Go error. Extract it with errors.As. Common codes: ENOENT, EACCES, EEXIST, EINTR, EAGAIN.
  • Prefer portable sentinels: errors.Is(err, os.ErrNotExist) (also os.ErrExist, os.ErrPermission) over comparing raw errno values. errors.Is walks the wrapped chain.
  • EINTR means a syscall was interrupted by a signal; retry it. The runtime handles this for stdlib calls since Go 1.14, so you only retry manually around raw syscalls.

🎁 You know os.ReadFile slurps an entire file into memory. But what if the file is 40 GB, or you need to jump straight to byte 2,000,000 without reading the first two million? That's where the file descriptor stops being a number and starts being a tool.

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy