When a program misbehaves and logs tell you nothing, strace is the next step. It intercepts every system call a process makes — file opens, network reads, memory maps, signal deliveries — and prints them to your terminal in real time. You don’t need source code, debug symbols, or a recompile. You just attach to the process and watch.

What is a system call?

User-space programs can’t directly touch hardware or kernel data structures. They ask the kernel to do it via system callsopen(), read(), write(), connect(), mmap(), and so on. strace sits between the program and the kernel and logs every one of these calls along with its arguments and return value.

Basic usage

$ strace ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], 0x... /* 23 vars */) = 0
brk(NULL)                               = 0x55a3c1000000
...
openat(AT_FDCWD, "/tmp", O_RDONLY|O_DIRECTORY|O_CLOEXEC) = 3
getdents64(3, /* 12 entries */, 32768)  = 352
write(1, "file1.txt  file2.log\n", 21)  = 21
close(3)                                = 0
exit_group(0)                           = ?

Each line shows: syscall_name(args) = return_value. A return value of -1 means the call failed, and strace appends the errno and a short description:

openat(AT_FDCWD, "/etc/shadow", O_RDONLY) = -1 EACCES (Permission denied)

Tracing an already-running process

Attach to a running process with -p:

$ sudo strace -p 1234
strace: Process 1234 attached
read(5, "", 4096)                       = 0
epoll_wait(4, [], 1, 999)               = 0
...

Use Ctrl-C to detach without killing the process.

Filtering by syscall

The raw output is noisy. -e trace= filters to specific syscalls or groups:

# Only file-related calls
$ strace -e trace=file ls /tmp

# Only network calls
$ strace -e trace=network curl https://example.com

# Only read and write
$ strace -e trace=read,write cat /etc/hostname

Built-in groups: file, network, memory, process, signal, ipc, desc (file descriptors).

Saving output to a file

strace writes to stderr by default. Use -o to redirect to a file:

$ strace -o /tmp/trace.txt myapp

Then grep through it:

$ grep "ENOENT" /tmp/trace.txt
openat(AT_FDCWD, "/etc/myapp/config.toml", O_RDONLY) = -1 ENOENT (No such file or directory)

This is often how you discover an app is silently looking for a config file in the wrong path.

Timing syscalls

-T prints the time spent in each call (in seconds):

$ strace -T -e trace=read,write myapp
read(3, "...", 4096)  = 4096 <0.000023>
write(1, "...", 512)  = 512  <0.000011>
read(3, "...", 4096)  = 0    <2.134567>   # blocked for 2 seconds!

A single slow read() stands out immediately. This is far faster than profiling when the bottleneck is I/O or a blocking network call.

-c gives an aggregate summary instead:

$ strace -c myapp
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 87.42    0.234512          58      4032           read
  7.13    0.019123          19      1008           write
  3.21    0.008612         215        40         1 openat
...

Tracing child processes

By default, strace only follows the initial process. Use -f to follow fork() and clone() calls into child processes:

$ strace -f -o /tmp/full.txt myapp

The output includes the PID of each process:

[pid 5678] openat(AT_FDCWD, "config.json", O_RDONLY) = 3
[pid 5679] read(0, "", 4096)           = 0

Real debugging scenarios

Why is my app not finding a config file?

$ strace -e trace=openat ./myapp 2>&1 | grep -i config
openat(AT_FDCWD, "/etc/myapp/config.yml", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/mukul/.config/myapp/config.yml", O_RDONLY) = 3

The app tried /etc/myapp/config.yml first, failed, then found one at home. Now you know exactly which paths it checks — no source code needed.

Why is a process hanging?

$ sudo strace -p 9999 -e trace=read,write,select,epoll_wait
epoll_wait(7, [], 1, 30000)             = 0   # waiting on epoll with 30s timeout
epoll_wait(7, [], 1, 30000)             = 0
epoll_wait(7, [], 1, 30000)             = 0

It’s blocked in epoll_wait — waiting for I/O that never comes. The file descriptor involved (7 here) is the next thing to investigate with lsof -p 9999.

Why does this fail with permission denied?

$ strace -e trace=file ./deploy.sh 2>&1 | grep "EACCES\|EPERM"
openat(AT_FDCWD, "/var/run/myapp.pid", O_WRONLY|O_CREAT|O_TRUNC, 0666) = -1 EACCES (Permission denied)

Exactly the file, exactly the operation. No guessing.

Useful flags at a glance

Flag Effect
-p PID Attach to a running process
-f Follow child processes (fork/clone)
-e trace=X Filter to syscall group or list
-o FILE Write output to file
-T Show time spent in each call
-c Print aggregate summary on exit
-s N Show up to N bytes of string args (default 32)
-v Verbose: don’t abbreviate structures

-s 256 is often worth setting — the default truncates strings at 32 bytes, which hides long paths and payloads.

strace is Linux-only

strace uses Linux’s ptrace interface and doesn’t exist on macOS. The macOS equivalent is dtruss (built on DTrace) or ktrace/kdebug. On macOS with SIP enabled, dtruss requires disabling SIP or running as root in a VM, which makes it much less convenient. If you’re debugging on macOS, lldb and Instruments are usually more practical.

Conclusion

strace turns black-box debugging into a transparent one. When a process crashes with no useful log, silently ignores a config file, or hangs with no explanation, attaching strace gives you a complete record of every kernel interaction. Start with -e trace=file or -e trace=network to cut the noise, add -T to spot slow calls, and use -o to capture traces for offline analysis. It’s a tool that repeatedly pays for the five minutes it takes to learn.