Skip to main content

System Calls and Low-Level Programming in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 5 min read

Most Go programs never need direct system calls — the standard library wraps the OS interface cleanly. But for performance-critical code, OS-specific features, or interfacing with hardware, going below the standard library is sometimes necessary.

Go provides two layers: the syscall package (frozen, platform-specific), and golang.org/x/sys (maintained, the recommended choice). The unsafe package lets you step outside Go’s type system when performance or interop with C requires it.

For higher-level process execution see Go shell integration and scripting.

The syscall Package vs golang.org/x/sys

The syscall package is in the standard library but frozen — no new APIs. For new code, use golang.org/x/sys:

go get golang.org/x/sys/unix    # Linux/macOS
go get golang.org/x/sys/windows # Windows

golang.org/x/sys has more complete API coverage and is actively maintained. Both provide direct access to the kernel’s system call interface.

File Descriptors

At the OS level, files are integer file descriptors (fd). Go’s os.File wraps an fd. Access the raw fd when you need to pass it to a syscall that isn’t wrapped:

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

// Get the fd from an os.File
f, err := os.Open("/etc/hostname")
if err != nil { log.Fatal(err) }
defer f.Close()

fd := int(f.Fd())  // get raw file descriptor

// Use directly with unix package
buf := make([]byte, 256)
n, err := unix.Read(fd, buf)
if err != nil { log.Fatal(err) }
fmt.Printf("Read %d bytes: %s\n", n, buf[:n])

Be careful: after calling f.Fd(), Go may block the goroutine rather than the thread — use f.SetDeadline or runtime.LockOSThread if mixing fd-level and higher-level operations.

Memory-Mapped Files

Memory mapping maps a file into the process’s address space. Reading and writing happens through memory operations rather than read/write syscalls — the kernel handles paging data in and out:

import (
    "golang.org/x/sys/unix"
    "os"
    "unsafe"
)

func mmapFile(path string) ([]byte, func(), error) {
    f, err := os.Open(path)
    if err != nil { return nil, nil, err }
    defer f.Close()

    fi, err := f.Stat()
    if err != nil { return nil, nil, err }

    size := int(fi.Size())
    if size == 0 { return nil, func() {}, nil }

    data, err := unix.Mmap(
        int(f.Fd()),
        0,                          // offset
        size,
        unix.PROT_READ,             // read-only
        unix.MAP_SHARED,            // share with other processes
    )
    if err != nil { return nil, nil, fmt.Errorf("mmap: %w", err) }

    cleanup := func() { unix.Munmap(data) }
    return data, cleanup, nil
}

// Usage
data, cleanup, err := mmapFile("/var/log/app.log")
if err != nil { log.Fatal(err) }
defer cleanup()

// data is a []byte — read directly, OS pages it in
fmt.Printf("First 100 bytes: %s\n", data[:100])

Memory mapping is significantly faster than read for random access to large files (databases, log parsers) because the kernel caches pages and you avoid user-kernel memory copies.

System Information

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

// System info (Linux)
var info unix.Sysinfo_t
if err := unix.Sysinfo(&info); err != nil { log.Fatal(err) }
fmt.Printf("Uptime: %d seconds\n", info.Uptime)
fmt.Printf("Total RAM: %d MB\n", info.Totalram/1<<20)
fmt.Printf("Free RAM: %d MB\n", info.Freeram/1<<20)
fmt.Printf("Loads (1m/5m/15m): %d %d %d\n", info.Loads[0]>>16, info.Loads[1]>>16, info.Loads[2]>>16)

// Process resource usage
var usage unix.Rusage
if err := unix.Getrusage(unix.RUSAGE_SELF, &usage); err == nil {
    fmt.Printf("User CPU: %d.%06d s\n", usage.Utime.Sec, usage.Utime.Usec)
    fmt.Printf("System CPU: %d.%06d s\n", usage.Stime.Sec, usage.Stime.Usec)
    fmt.Printf("Max RSS: %d KB\n", usage.Maxrss)
}

// Current process limits
var rlimit unix.Rlimit
unix.Getrlimit(unix.RLIMIT_NOFILE, &rlimit)
fmt.Printf("Open file limit: %d (hard: %d)\n", rlimit.Cur, rlimit.Max)

The unsafe Package

unsafe lets you bypass Go’s type system — interpret memory as a different type, compute pointer arithmetic, and pass pointers to C. Use it sparingly and with clear documentation:

import "unsafe"

// Convert between numeric types without allocation
// Useful for low-level bit manipulation
var x int64 = 0x0102030405060708
ptr := unsafe.Pointer(&x)
bytes := (*[8]byte)(ptr)  // view x as a byte array
fmt.Printf("%x\n", bytes)  // [01 02 03 04 05 06 07 08]

// Struct field offset — same as C offsetof()
type Point struct{ X, Y int32 }
p := Point{X: 10, Y: 20}
yOffset := unsafe.Offsetof(p.Y)  // 4 bytes (after X)
yPtr := (*int32)(unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + yOffset))
fmt.Println(*yPtr)  // 20

// unsafe.Slice: create a slice from a pointer and length
// (common when interfacing with C code that returns a pointer + length)
func byteSliceFromPtr(ptr *byte, n int) []byte {
    return unsafe.Slice(ptr, n)
}

The safety rules for unsafe.Pointer (from the Go spec):

  1. A *T pointer can be converted to unsafe.Pointer
  2. An unsafe.Pointer can be converted to any *T
  3. An unsafe.Pointer can be converted to a uintptr for arithmetic
  4. The uintptr must be converted back to unsafe.Pointer in the same expression — don’t store it in a variable, the GC may move the object

CGO for C Interop

When you need to call existing C libraries, cgo bridges Go and C:

/*
#include <stdlib.h>
#include <string.h>

char* duplicate(const char* s) {
    return strdup(s);
}
*/
import "C"
import "unsafe"

func duplicateString(s string) string {
    cs := C.CString(s)
    defer C.free(unsafe.Pointer(cs))  // always free C allocations

    dup := C.duplicate(cs)
    defer C.free(unsafe.Pointer(dup))

    return C.GoString(dup)
}

CGO has overhead per call (≈100ns for a simple call) and disables certain Go optimizations. Use it when C interop is genuinely required, not as a performance optimization.

When to Use System Calls Directly

Most of the time, you don’t need to. The standard library’s os, net, io, and syscall wrappers cover the common cases. Reach for direct syscalls when:

  • You need OS-specific features not in the standard library (Linux io_uring, inotify, perf_event_open)
  • You need memory mapping for large file access
  • You’re writing platform-specific performance code (CPU affinity, NUMA topology)
  • You’re building tooling that introspects the kernel (eBPF, strace-like tools)
  • You’re interfacing with hardware via device files

For everything else, the standard library is safer, more portable, and better maintained.

Summary

  • Use golang.org/x/sys/unix instead of syscall for new code — it’s actively maintained and more complete
  • f.Fd() extracts the raw file descriptor from an os.File; be careful when mixing fd-level and higher-level operations
  • unix.Mmap maps a file into memory — faster than read for random access to large files
  • unsafe.Pointer arithmetic must convert back to pointer in the same expression — storing uintptr in a variable is unsafe
  • CGO has ~100ns overhead per call — use it for C library interop, not performance optimization
  • Most programs don’t need direct syscalls; the standard library covers the common cases

Resources

Comments

👍 Was this article helpful?