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):
- A
*Tpointer can be converted tounsafe.Pointer - An
unsafe.Pointercan be converted to any*T - An
unsafe.Pointercan be converted to auintptrfor arithmetic - The
uintptrmust be converted back tounsafe.Pointerin 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/unixinstead ofsyscallfor new code — it’s actively maintained and more complete f.Fd()extracts the raw file descriptor from anos.File; be careful when mixing fd-level and higher-level operationsunix.Mmapmaps a file into memory — faster thanreadfor random access to large filesunsafe.Pointerarithmetic must convert back to pointer in the same expression — storinguintptrin 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
Comments