How to Use syscall Package in Go

Use the syscall package in Go to perform low-level OS operations like file locking with syscall.Flock.

The maintenance closet of Go

You are building a daemon that must run as a single instance. You tried creating a file and checking if it exists, but a race condition lets two processes start simultaneously. You need a real file lock. The os package gives you high-level file operations, but it deliberately leaves low-level primitives like flock or fcntl out of the standard API. That is where syscall lives.

The syscall package is Go's direct line to the operating system kernel. Every function in this package maps to a system call: a request from user space to kernel space. When you call syscall.Flock, the CPU switches modes, the kernel checks the lock table, and returns a result. This package exists because Go cannot anticipate every OS feature. The standard library wraps the most common needs, but syscall exposes the raw machinery for the rest.

Think of os as a hotel concierge. They can handle room service, reservations, and luggage. If you need to rewire the building's electrical panel, the concierge cannot help. You go to the maintenance closet. syscall is the maintenance closet. It has the tools, but you are responsible for not electrocuting yourself.

How system calls work

A system call is a controlled trap into the kernel. User programs run with restricted permissions. The kernel runs with full control. When a program needs to read a file, allocate memory, or lock a resource, it cannot do it directly. It invokes a system call, which pauses the program, saves its state, and transfers execution to the kernel. The kernel performs the operation and returns a result.

Go abstracts this away in packages like os and net. Those packages use syscall internally but hide the complexity. They also handle platform differences so your code runs on Linux, macOS, and Windows without changes. When you use syscall directly, you opt out of that safety net. You get the power, but you also get the responsibility.

The syscall package provides named functions for common operations like Flock, Stat, and Socket. It also provides a generic Syscall function for operations that do not have a named wrapper. This generic function takes a system call number and arguments as uintptr values. It is the escape hatch for new kernel features or obscure primitives.

Minimal example: acquiring a file lock

Here is the pattern for a blocking exclusive lock: open the file, cast the file descriptor, call Flock, and handle the error.

package main

import (
	"fmt"
	"os"
	"syscall"
)

func main() {
	// O_RDWR is required for flock on Linux. O_CREATE ensures the file exists.
	// Permissions 0644 allow the owner to read/write and others to read.
	f, err := os.OpenFile("/tmp/app.lock", os.O_CREATE|os.O_RDWR, 0644)
	if err != nil {
		fmt.Println("open failed:", err)
		return
	}
	// Defer close to prevent file descriptor leaks.
	defer f.Close()

	// LOCK_EX acquires an exclusive lock. The call blocks until the lock is available.
	// LOCK_SH would acquire a shared lock, allowing multiple readers.
	err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX)
	if err != nil {
		fmt.Println("lock failed:", err)
		return
	}
	// Defer unlock so the lock releases even if the program panics.
	defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)

	fmt.Println("Locked. Critical section.")
}

The code opens a file with read-write access because flock requires write access on most systems. It calls syscall.Flock with LOCK_EX to block until the lock is acquired. The defer statements ensure the file closes and the lock releases when the function returns.

Walkthrough: what happens at runtime

When the program runs, os.OpenFile creates a file descriptor in the kernel. The syscall.Flock call takes that integer descriptor and passes it to the kernel's locking subsystem. The kernel marks the file as locked by this process. If another process calls Flock with LOCK_EX on the same file, the kernel puts that process to sleep until the first one releases the lock.

File locks are per-process, not per-file-descriptor. If you duplicate the file descriptor using dup, the lock applies to the process, not the specific descriptor. Closing one descriptor does not release the lock if another descriptor refers to the same file. This behavior catches developers who assume locks are tied to the file object.

The syscall package is a thin wrapper. It does not hide OS differences. Constants like LOCK_EX exist on POSIX systems. On Windows, the package provides different functions or panics if you call a POSIX-only function. This is why the Go team recommends golang.org/x/sys for new code. The x/sys package abstracts the platform differences while keeping the low-level access. It defines constants and functions that work across Unix-like systems and provides build tags to exclude Windows-specific code.

Realistic example: singleton daemon pattern

A production-ready pattern wraps the lock in a function that returns a cleanup callback, ensuring the lock releases even if the program exits unexpectedly.

// AcquireSingletonLock ensures only one instance runs.
// Returns a cleanup function to release the lock and close the file.
func AcquireSingletonLock(path string) (func(), error) {
	// O_RDWR is required for flock. O_CREATE creates the file if missing.
	// 0600 restricts access to the owner for security.
	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
	if err != nil {
		return nil, fmt.Errorf("open lock file: %w", err)
	}

	// LOCK_NB makes the call non-blocking. Returns EWOULDBLOCK if locked.
	// This allows the program to exit gracefully instead of hanging.
	err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
	if err != nil {
		f.Close()
		if err == syscall.EWOULDBLOCK {
			return nil, fmt.Errorf("another instance is running")
		}
		return nil, fmt.Errorf("lock failed: %w", err)
	}

	// Return a closure that releases the lock and closes the file.
	// The caller must invoke this function when shutting down.
	return func() {
		syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
		f.Close()
	}, nil
}

The function attempts a non-blocking lock using LOCK_NB. If the lock is already held, Flock returns EWOULDBLOCK immediately. The code checks for this error and returns a clear message. If the lock succeeds, it returns a cleanup function that releases the lock and closes the file. This pattern prevents deadlocks if the program crashes without calling the cleanup function, because the kernel releases locks when the process terminates.

The generic Syscall function

Named functions like Flock cover common operations. When you need a primitive that lacks a wrapper, use syscall.Syscall. This function invokes a system call by number. It takes the call number and up to three arguments as uintptr values. It returns two result values and an error.

// InvokeSyscall demonstrates the generic Syscall function.
// This is rarely needed; prefer named functions or x/sys.
func InvokeSyscall(fd uintptr, op uintptr) error {
	// Syscall takes the call number and up to three arguments.
	// Arguments must be uintptr values. Pointers must be converted carefully.
	r1, _, err := syscall.Syscall(syscall.SYS_FLOCK, fd, op, 0)
	if err != 0 {
		// Syscall returns an Errno error on failure.
		// The error value is non-nil and implements Error() returning the OS message.
		return err
	}
	// Check r1 for success if the syscall returns a value other than error.
	if r1 == 0 {
		return nil
	}
	return fmt.Errorf("syscall returned unexpected value %d", r1)
}

The Syscall function is dangerous. Passing the wrong arguments can crash the kernel or corrupt memory. The compiler cannot check the types or count of arguments. You must consult the OS documentation to get the call number and argument layout correct. Use this function only when no named wrapper exists and you have verified the behavior on the target platform.

Pitfalls and compiler errors

Portability is the enemy of syscall. Code using syscall often breaks on Windows or BSD without changes. If you try to use a constant that does not exist on the target OS, the compiler rejects the build with undefined: syscall.LOCK_NB. This happens when you write code for Linux and try to build for Windows. The solution is to use build tags or switch to golang.org/x/sys/unix, which handles platform differences.

The compiler complains with cannot use f.Fd() (value of type uintptr) as int value in argument if you forget the cast. The cast is necessary because file descriptors are integers in the kernel, but Go's os.File exposes them as uintptr to match pointer-sized addresses on all architectures. The conversion int(f.Fd()) is safe on 64-bit systems where int is 64 bits. On 32-bit systems, file descriptors fit in 32 bits, so the cast is also safe.

File descriptor leaks happen when you open a file and do not close it. The lock persists until the process dies. Always use defer f.Close() or a cleanup function. The worst goroutine bug is the one that never logs. If your lock acquisition fails silently, the program may proceed with inconsistent state. Check every error.

Blocking syscalls can starve the Go scheduler. When a goroutine blocks on a syscall, the runtime creates a new OS thread to keep other goroutines running. If many goroutines block simultaneously, the process consumes too many threads. Batch operations where possible. Use os wrappers when they provide buffering or asynchronous behavior.

When to use syscall

Use syscall when you need a primitive that os or net does not expose, like specific socket options or file locking. Use golang.org/x/sys/unix when you want low-level access with better cross-platform support and type safety. Use os and net for 99% of file and network operations; they handle platform differences automatically. Use cgo when you need to call existing C libraries that have no Go equivalent. Use runtime.LockOSThread when you must pin a goroutine to an OS thread for thread-local storage or C API requirements.

The syscall package is the maintenance closet. You have the tools, but you are responsible for the wiring. Prefer higher-level packages unless you have a specific reason to go low. When you do use syscall, test on every target platform and handle errors explicitly.

x/sys is the modern syscall. Use it unless you have a reason not to.

Where to go next