Every Unix user eventually learns this incantation:
ps aux | grep node | grep -v grep | awk '{print $2}' | xargs kill -9
It works. Most people copy-paste it, tweak the process name, and move on. But each piece is doing something specific, and understanding why the pipeline looks like this makes you better at Unix in general.
Let’s break it down.
ps aux – list every process
ps on its own shows only processes attached to your current terminal. The three flags expand that:
a– show processes from all users, not just yoursu– display in user-oriented format (adds columns like CPU%, MEM%, start time)x– include processes not attached to a terminal (daemons, background jobs)
The output looks like this:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 169376 13292 ? Ss Feb28 0:10 /sbin/init
zee 48291 2.1 1.3 985432 108764 pts/1 Sl+ 10:22 1:45 node server.js
zee 48350 0.0 0.0 12784 3340 pts/2 S+ 10:23 0:00 vim notes.txt
The columns that matter for process management:
| Column | Position | What it is |
|---|---|---|
| USER | $1 | Process owner |
| PID | $2 | Process ID (what you pass to kill) |
| %CPU | $3 | CPU usage percentage |
| %MEM | $4 | Memory usage percentage |
| STAT | $8 | Process state (S=sleeping, R=running, Z=zombie, T=stopped) |
| COMMAND | $11+ | The full command with arguments |
Note that COMMAND is $11 and beyond – it can contain spaces, so awk '{print $11}' won’t always give you the full command.
grep node – filter by name
This is straightforward text matching. grep node keeps only lines containing the string “node”. But it’s blunt:
ps aux | grep node
This matches:
node server.js– yes, you want thisnode worker.js– probably want this toovim /home/zee/node_modules/express/index.js– probably notgrep node– definitely not (more on this next)
Grep matches against the entire line, including the COMMAND column and all its arguments. If you’re looking for python, you’ll also match python-config, /usr/lib/python3.11/something, and any process whose arguments happen to contain the word “python”.
This is the fundamental fragility of ps | grep. You’re doing substring matching on unstructured text.
grep -v grep – the self-referential problem
This is the part that confuses people the first time they see it. Run this:
ps aux | grep node
You’ll get output like:
zee 48291 2.1 1.3 985432 108764 pts/1 Sl+ 10:22 1:45 node server.js
zee 49102 0.0 0.0 12340 2140 pts/2 S+ 10:25 0:00 grep node
The grep process itself shows up in the results, because grep node is a running process whose command line contains the word “node”. It matched itself.
grep -v grep inverts the match – it removes any line containing “grep”. This filters out the grep process from its own results.
An alternative trick you’ll sometimes see:
ps aux | grep [n]ode
The square brackets make grep search for the regex character class [n] followed by ode, which matches the string “node”. But the grep process’s own command line contains [n]ode literally, which is not the string “node”, so it doesn’t match itself. Clever, but cryptic.
awk '{print $2}' – extract the PID
awk splits each input line into fields by whitespace. $1 is the first field, $2 is the second, and so on. Since PID is the second column in ps aux output, awk '{print $2}' extracts just the process ID.
Before awk:
zee 48291 2.1 1.3 985432 108764 pts/1 Sl+ 10:22 1:45 node server.js
After awk '{print $2}':
48291
Other useful awk patterns for process management
Once you understand field splitting, awk becomes a powerful filter:
# Processes using more than 50% CPU
ps aux | awk '$3 > 50.0'
# Processes using more than 1GB of RSS memory (column 6, in KB)
ps aux | awk '$6 > 1048576'
# Zombie processes (status column contains Z)
ps aux | awk '$8 ~ /Z/'
# Processes owned by a specific user
ps aux | awk '$1 == "zee"'
# Print PID and command only (cleaner output)
ps aux | awk '{print $2, $11}'
# Sum total memory usage of all node processes
ps aux | grep node | grep -v grep | awk '{sum += $6} END {print sum/1024 " MB"}'
These are genuinely useful, and worth keeping in your toolkit even if you use other tools day-to-day.
xargs kill -9 – kill the processes
xargs reads items from stdin and passes them as arguments to the specified command. So if awk outputs:
48291
48350
Then xargs kill -9 runs kill -9 48291 48350.
Signals: -9 vs -15
kill -9 sends SIGKILL. kill -15 (or just kill with no signal) sends SIGTERM. The difference matters:
SIGTERM (-15) asks the process to shut down gracefully. The process can:
- Close database connections
- Flush write buffers
- Clean up temp files
- Release locks
SIGKILL (-9) terminates the process immediately. The kernel destroys it. The process gets no chance to clean up.
The convention in the copy-paste pipeline is -9 because people reach for this when a process is stuck and they want it gone now. But in most cases, you should try -15 first:
# Polite first
ps aux | grep node | grep -v grep | awk '{print $2}' | xargs kill -15
# Nuclear option if that didn't work
ps aux | grep node | grep -v grep | awk '{print $2}' | xargs kill -9
xargs gotcha: empty input
If awk produces no output (no matching processes), xargs kill -9 runs kill -9 with no arguments, which prints an error. Use xargs -r on Linux to skip execution when input is empty. On macOS, xargs does this by default.
Better alternatives: pgrep and pkill
The ps | grep | awk pipeline is so common that dedicated tools exist to replace it.
pgrep – find PIDs by name
pgrep node # PIDs of processes named "node"
pgrep -f "node.*server" # Match against full command line
pgrep -u zee node # Only processes owned by user zee
pgrep -l node # Show PID and process name
pgrep -a node # Show PID and full command line
pgrep matches against the process name by default, not the full command line. This avoids the “matching too broadly” problem. Use -f to match against the full command if you need it.
pkill – find and kill in one step
pkill node # Send SIGTERM to processes named "node"
pkill -9 node # Send SIGKILL
pkill -f "node server.js" # Match full command line
pkill is the entire pipeline collapsed into one command. No grep-matching-itself problem, no awk, no xargs.
kill -0 – check if a process exists
A lesser-known trick:
kill -0 48291 # Exit 0 if process exists, exit 1 if not
Signal 0 is a no-op. The kernel checks whether the process exists and whether you have permission to signal it, but doesn’t actually send anything. Useful in scripts:
if kill -0 "$PID" 2>/dev/null; then
echo "Process $PID is still running"
fi
Common gotchas
Matching too broadly. grep python matches everything with “python” in the command line, including editors with Python files open, Python-based tools, and scripts in /usr/lib/python3/. You kill things you didn’t intend to.
No confirmation. The pipeline runs silently. There’s no “are you sure?” step. If your grep was too broad, you find out by noticing that your other programs stopped working.
Race conditions. Between ps listing the PID and kill executing, the process could exit on its own and a new process could take that PID. This is unlikely but possible on busy systems.
Whitespace sensitivity. The pipeline assumes ps aux output has consistent whitespace formatting. This is true on modern systems, but older or non-standard ps implementations can break awk’s field splitting.
No tree awareness. Killing a parent process may leave child processes orphaned. Or the parent might respawn the child immediately. The pipeline has no concept of process trees.
Or skip the pipeline
proc is a CLI tool that handles the common cases without chaining five commands together:
proc kill node # Kill processes named "node" (confirms first)
proc kill :3000 # Kill whatever is on port 3000
proc kill node --dry-run # Preview what would be killed
proc on node # See what ports node is using (without killing)
It handles target resolution, deduplication, confirmation prompts, and signal management. The full pipeline is still there for you when you need something unusual – understanding it is worth your time regardless of what tools you use.
Install
brew install yazeed/proc/proc # macOS
cargo install proc-cli # Rust
npm install -g proc-cli # npm/bun
See the GitHub repo for all installation options.