Linux and macOS Terminal Commands for Developers: GNU vs BSD Differences and Troubleshooting

Key takeaways

The terminal is where debugging actually happens — logs, ports, disk space, runaway processes. This cheatsheet covers the commands developers reach for every day, explains why each one matters, and flags the GNU/BSD differences that trip people up when they switch between a Linux server and a Mac laptop.

Why This Cheatsheet Exists

Most day-to-day debugging doesn’t happen in an IDE — it happens in a terminal, over SSH, at 2 a.m., staring at a log file that just started growing without explanation. The commands below aren’t an exhaustive man-page dump; they’re the subset that developers reach for constantly when something is broken and they need an answer in the next thirty seconds. The goal here is not just to list flags, but to explain why a particular flag matters, when to reach for one tool over a superficially similar one, and where Linux and macOS quietly diverge — because those divergences are exactly what breaks a script that “worked on my machine.”

A recurring theme worth calling out up front: macOS ships the BSD variants of core utilities (sed, awk, date, find), while virtually every Linux distribution ships the GNU variants. They look almost identical on the surface, share most flags, and then differ in exactly the corner case you need at the worst possible time. If you write shell scripts that need to run on both a Mac laptop and a Linux CI runner or production server, either test on both platforms or install GNU coreutils on macOS via Homebrew (brew install coreutils, brew install gnu-sed) so the behavior matches what you’ll see on the server.

Directory & File Management

Navigation and file operations are the commands you type without thinking — but a few of them have sharp edges. rm -rf in particular deserves a second look every single time before you hit enter, because there is no undo, no trash can, and no confirmation dialog. If you’re deleting something you’re not 100% sure about, rm -ri trades speed for a per-file confirmation prompt, which is a fair trade when the target directory contains anything you didn’t create in the last five minutes.

find is worth learning past the basic find . -name pattern — the ability to filter by modification time, size, or path exclusion turns it into a genuine triage tool. find . -newer reference.txt, for instance, answers “what changed since I last touched this file,” which is a surprisingly common question when debugging a build that suddenly behaves differently.

# Current directory
pwd

# Navigate
cd /var/log
cd ~              # Home directory
cd -              # Previous directory
cd ..             # Parent directory

# List files
ls -la            # All files, detailed (permissions, size, date)
ls -lh            # Human-readable sizes (KB, MB, GB)
ls -lt            # Sort by modification time, newest first
ls -lS            # Sort by size, largest first

# Tree view (install: brew install tree)
tree -L 2         # 2 levels deep
tree -d           # Directories only
tree -I 'node_modules|.git'  # Exclude patterns

# Create directories
mkdir -p parent/child/grandchild   # Create all intermediate dirs

# Copy, move, delete
cp -r src/ dest/          # Copy directory recursively
cp -p file backup         # Preserve permissions and timestamps
mv old.txt new.txt        # Rename / move
rm -rf dir/               # Force delete directory (⚠️ irreversible)
rm -ri dir/               # Interactive delete (confirm each file)

# Find files
find . -name "*.log"                        # By name
find . -name "*.js" -not -path "*/node_modules/*"  # Exclude path
find . -newer reference.txt                 # Modified after reference file
find . -size +100M                          # Files over 100 MB
find . -type f -mtime -7                    # Modified in last 7 days

GNU vs BSD note: find behaves consistently across both platforms for the flags above, but more advanced flags like -printf are GNU-only and simply don’t exist on macOS’s BSD find. If a find one-liner you copied from Stack Overflow errors out with “unknown option” on a Mac, check whether it’s using a GNU-only flag.

This is the cluster of tools you use to answer “what’s actually in this file, and where’s the part I care about.” cat, head, and tail handle the simple cases; tail -f in particular is the command you leave running in a terminal tab while you reproduce a bug, watching new log lines scroll in real time. less is worth defaulting to over cat for anything longer than a screen, since it doesn’t dump the whole file into your scrollback and lets you search with /pattern.

grep, sed, and awk form a trio that’s easy to conflate but serve distinct purposes: grep finds lines, sed transforms lines, awk processes structured (column-oriented) data. A typical debugging pipeline chains all three — grep to narrow a huge log down to the relevant lines, then awk to pull out just the field you need, then maybe sort | uniq -c to count occurrences. If you only remember one distinction, remember this: reach for grep when the question is “does this line match,” reach for awk when the question is “give me column N.”

sed’s in-place edit flag is the single most common cross-platform gotcha in this whole list. On Linux (GNU sed), sed -i 's/old/new/g' file.txt works as written. On macOS (BSD sed), the same command errors out unless you supply an empty string argument immediately after -i: sed -i '' 's/old/new/g' file.txt. This is because BSD sed treats the argument after -i as a backup file suffix and requires it explicitly, while GNU sed treats it as optional. Scripts meant to be portable either branch on uname or just install GNU sed via Homebrew and invoke it as gsed.

# View file content
cat file.txt                     # Print entire file
head -20 file.txt                # First 20 lines
tail -50 file.txt                # Last 50 lines
tail -f /var/log/app.log         # Follow (live updates)
less file.txt                    # Paginated view (q to quit)

# Search with grep
grep "error" app.log             # Lines containing "error"
grep -i "error" app.log          # Case-insensitive
grep -r "TODO" ./src/            # Recursive search in directory
grep -n "error" app.log          # Show line numbers
grep -v "debug" app.log          # Exclude lines matching pattern
grep -A 3 "error" app.log        # 3 lines AFTER each match
grep -B 3 "error" app.log        # 3 lines BEFORE each match
grep -C 3 "error" app.log        # 3 lines before AND after
grep -l "error" *.log            # Only filenames that match

# Ripgrep (faster, install: brew install ripgrep)
rg "error" ./src/                # Same as grep -r but much faster
rg -i "todo" --type js           # Search only .js files

# Stream editing with sed
sed 's/old/new/g' file.txt       # Replace all occurrences
sed 's/old/new/' file.txt        # Replace first occurrence per line
sed -i 's/old/new/g' file.txt    # Edit file in place (Linux)
sed -i '' 's/old/new/g' file.txt # Edit file in place (macOS)
sed '/^#/d' file.txt             # Delete comment lines
sed -n '10,20p' file.txt         # Print lines 10 to 20

# Data extraction with awk
awk '{print $1}' file.txt        # Print first field (space-delimited)
awk -F',' '{print $2}' data.csv  # Print second column of CSV
awk 'NR > 1' file.txt            # Skip the first line (header)
awk '/error/ {print $0}' log.txt # Print lines matching pattern
awk '{sum += $3} END {print sum}' data.txt  # Sum third column

# Word count
wc -l file.txt                   # Line count
wc -w file.txt                   # Word count
wc -c file.txt                   # Byte count

If your codebase is large, grep -r starts to feel slow — that’s the point at which ripgrep (rg) earns its place. It respects .gitignore by default, skips binary files automatically, and is written in Rust with parallelized search, which in practice means searching a large monorepo goes from seconds to a fraction of a second. It’s not a drop-in replacement for every grep flag, but for “search this codebase for a string” it’s strictly better.

Network Debugging

Network commands answer two broad questions: “can I reach this host” and “what’s listening on this port.” ping and traceroute cover the first — ping confirms basic reachability and round-trip latency, while traceroute (or mtr for a continuously-updating version) shows you the hop-by-hop path, which is useful when a connection is slow rather than fully broken and you need to know which network segment is adding latency.

dig and nslookup both do DNS lookups, but dig gives you the full record detail (TTL, record type, authority section) while nslookup is more of a quick sanity check; dig +short trims the output down to just the answer when you don’t need the ceremony. For day-to-day API debugging, curl is the workhorse — the flag combination worth internalizing is -I for a quick headers-only check (does this endpoint even respond, what’s the status code), -X POST with -H and -d for actually exercising an API, and piping to jq for anything that returns JSON, since raw JSON without formatting is nearly unreadable at a glance.

flowchart TD
    A["Something's not responding"] --> B{Can I reach the host at all?}
    B -->|ping fails| C["Network/DNS issue — check dig, traceroute"]
    B -->|ping works| D{Is the port open?}
    D -->|No| E["Service not running or wrong port —\ncheck lsof -i / ss -tlnp"]
    D -->|Yes| F{Does curl get a response?}
    F -->|Connection refused| E
    F -->|Times out| G["Firewall or upstream hang —\ncheck nmap, security groups"]
    F -->|Gets HTTP response| H["App-level bug —\ncheck logs, tail -f + grep"]

For “what’s listening on this machine,” lsof -i :PORT and ss -tlnp (Linux) answer the same question from different angles — lsof maps a specific port to a process, while ss lists everything listening at once so you can scan for surprises. netstat is the older tool that both predate; it still works on macOS and older Linux systems, but ss is the modern Linux replacement and is generally faster on machines with many open sockets.

# Test connectivity
ping google.com -c 4             # Send 4 pings
traceroute google.com            # Trace network path (Linux)
tracert google.com               # Windows equivalent
mtr google.com                   # Better traceroute (install: brew install mtr)

# DNS lookup
nslookup google.com              # DNS query
dig google.com                   # Detailed DNS info
dig google.com +short            # Just the IP address

# HTTP requests with curl
curl https://api.example.com/data             # GET request
curl -I https://example.com                   # Headers only (HEAD)
curl -X POST https://api.example.com/users \  # POST with JSON
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "[email protected]"}'
curl -H "Authorization: Bearer TOKEN" \       # With auth header
  https://api.example.com/me
curl -o output.html https://example.com       # Save to file
curl -L https://example.com                   # Follow redirects
curl -s https://api.example.com | jq .        # Pipe to jq for JSON formatting

# Check open ports and connections
ss -tlnp                         # All listening TCP ports + PIDs (Linux)
netstat -tlnp                    # Similar (older, but works on macOS too)
lsof -i :8080                    # What's using port 8080 (macOS/Linux)
lsof -i -P | grep LISTEN         # All listening sockets

# Port scanning (install: brew install nmap)
nmap -p 80,443,8080 hostname     # Check specific ports
nmap -p 1-1000 hostname          # Scan port range

# View network interfaces
ip addr show                     # Linux (modern)
ifconfig                         # Linux/macOS (older)

Platform note: ip addr show is the modern Linux way to inspect interfaces (part of iproute2), replacing the older ifconfig, which is deprecated on most Linux distros but still the default on macOS. If you’re writing a script that inspects network interfaces and needs to run on both, ifconfig is the safer common denominator, though its output format differs enough between the two that parsing it reliably is its own small project.

Process Management

When something is consuming CPU or memory it shouldn’t, or a process refuses to die, this is the toolbox. ps aux gives you a static snapshot; top (or the friendlier htop/btop) gives you a live, continuously refreshing view, which is what you actually want when you’re watching a number climb in real time. htop in particular is worth installing on any machine you SSH into regularly — the ability to sort by CPU or memory interactively, and to send signals to a process by selecting it rather than typing its PID, saves real time during an incident.

The distinction between kill and kill -9 matters more than it looks. Plain kill sends SIGTERM, a polite request that lets the process clean up open files, flush buffers, and close connections before exiting. kill -9 sends SIGKILL, which the OS enforces immediately and the process cannot intercept or ignore — there’s no cleanup, no chance to save state. The right habit is to always try plain kill first and only escalate to -9 if the process is genuinely hung and not responding after a few seconds; reaching for -9 by default can leave lock files, corrupted temp state, or orphaned child processes behind.

# View running processes
ps aux                           # All processes with details
ps aux | grep node               # Find node processes
ps -ef | grep python             # Alternative syntax

# Interactive process viewer
top                              # Basic (press q to quit)
htop                             # Better UI (install: brew install htop)
btop                             # Modern UI (install: brew install btop)

# Kill processes
kill 12345                       # Send SIGTERM (graceful stop) to PID
kill -9 12345                    # Send SIGKILL (force stop) — use when SIGTERM fails
pkill node                       # Kill all processes named "node"
pkill -f "python app.py"         # Kill by full command string

# Background and foreground
command &                        # Run in background
Ctrl+C                           # Kill foreground process
Ctrl+Z                           # Suspend foreground process
bg                               # Resume suspended process in background
fg                               # Bring background process to foreground
jobs                             # List background jobs

# Process priority
nice -n 10 command               # Run with lower priority (10 = less CPU)
renice -n -5 12345               # Change priority of running process

nice/renice are less commonly needed day-to-day, but they matter on shared machines — a background batch job or a local build you kicked off shouldn’t starve the interactive process you’re actually working in. Lower nice values mean higher priority (down to -20), and only root can lower a process’s niceness below zero.

Log Analysis

Logs are usually the first and last place you look when debugging a production issue, and the pattern that matters most here is combining tail -f with grep to turn an unmanageable firehose into a filtered, live stream of just the lines you care about. This is the command you leave running in a split terminal pane while you reproduce the bug in another window — seeing the error appear in real time, correlated with the action that triggered it, is often faster than any amount of static log searching after the fact.

For structured (JSON) logs, piping through jq with a filter expression lets you query by field rather than grepping for a substring, which matters once your logs carry enough metadata that plain text search returns too many false positives. On systemd-based Linux systems, journalctl replaces flat log files entirely for system services — it’s worth knowing --since and -u <service> even if you rarely touch it, because it’s the only way to see nginx or a systemd-managed app’s logs when they’re not writing to a conventional file at all.

# Real-time log following
tail -f /var/log/app.log                    # Follow log file
tail -f /var/log/app.log | grep "ERROR"     # Filter while following
tail -f /var/log/nginx/access.log | grep -v "health"  # Exclude health checks

# Search logs with date filtering
grep "2026-04-15" /var/log/app.log          # Specific date
grep "ERROR\|WARN" /var/log/app.log         # Multiple patterns (OR)
grep "ERROR" /var/log/app.log | tail -100   # Last 100 errors

# Count occurrences
grep -c "ERROR" app.log                     # Count matching lines
grep "ERROR" app.log | sort | uniq -c | sort -rn  # Count unique errors

# Extract fields from structured logs
# JSON logs: cat app.log | jq '.level == "error"'
# Apache access log: awk '{print $7}' access.log | sort | uniq -c | sort -rn

# journalctl (systemd systems)
journalctl -u nginx                          # Logs for nginx service
journalctl -u nginx --since "1 hour ago"    # Last hour
journalctl -u nginx -f                      # Follow
journalctl -p err                           # Only errors

The sort | uniq -c | sort -rn chain deserves a callout on its own — it’s a general-purpose pattern for “count how often each distinct value appears, ranked by frequency,” and it works on any line-oriented output, not just logs. Run it against grepped error messages to find the most common failure, or against a list of IPs from an access log to find the noisiest client.

Disk & System Information

“The disk is full” and “the server is slow” are two of the most common on-call pages, and both start with the same handful of commands. df -h tells you which filesystem is full; du -sh tells you what’s actually taking up the space within it. The combination du -sh * | sort -rh | head -10 — size each item in the current directory, sort by size descending, show the top 10 — is the fastest way to find what’s eating a disk, and it’s worth memorizing rather than looking up every time.

# Disk usage
df -h                            # All filesystems, human-readable
df -h /var                       # Specific path

du -sh *                         # Size of each item in current dir
du -sh /var/log/*                # Size of each log file
du -sh * | sort -h               # Sort by size
du -sh * | sort -rh | head -10   # Top 10 largest

# Find large files
find / -size +1G 2>/dev/null     # Files over 1 GB
find /var/log -name "*.log" -size +100M  # Large log files

# System info
uname -a                         # OS and kernel info
uptime                           # System uptime and load averages
free -h                          # Memory usage (Linux)
vm_stat                          # Memory stats (macOS)
nproc                            # CPU core count (Linux)
sysctl -n hw.ncpu                # CPU core count (macOS)

# Load averages (from uptime or top)
# Format: 1min 5min 15min
# If value > CPU cores → overloaded

Memory inspection is a genuine platform split, not just a flag difference: free -h doesn’t exist on macOS at all, because macOS manages memory differently (heavy use of compressed memory and purgeable pages that Linux doesn’t have in the same form), so you use vm_stat instead, which reports in raw page counts rather than the human-readable summary free gives you on Linux. Similarly, nproc (Linux) and sysctl -n hw.ncpu (macOS) both answer “how many CPU cores do I have,” which matters when setting -j flags for parallel builds or sizing a thread pool.

The load average figures from uptime are easy to misread — the three numbers are 1-, 5-, and 15-minute averages of the number of processes wanting CPU time, not a percentage. A load average of 4.0 is fine on an 8-core machine and a sign of serious trouble on a 2-core one, so always read it relative to nproc/sysctl -n hw.ncpu, never in isolation.

Permissions & Ownership

Unix permissions confuse people mostly because the numeric (octal) form isn’t self-explanatory until you’ve internalized it. Each digit in chmod 755 represents one of owner/group/other, and each digit is a sum of read (4), write (2), and execute (1) — so 7 is all three, 5 is read+execute, 4 is read-only. 755 on a script means the owner can edit and run it, and everyone else can only run it, which is the standard permission set for an executable you want other users to be able to invoke but not modify.

# View permissions
ls -la file.txt
# -rw-r--r-- 1 alice staff 1234 Apr 15 10:00 file.txt
# ↑ permissions  ↑owner ↑group

# chmod — change permissions
chmod 755 script.sh    # rwxr-xr-x (owner: rwx, group: r-x, other: r-x)
chmod 644 file.txt     # rw-r--r-- (owner: rw, group: r, other: r)
chmod +x script.sh     # Add execute permission for all
chmod -R 755 ./dir/    # Recursive

# Common permission values
# 7 = rwx  (read + write + execute)
# 6 = rw-  (read + write)
# 5 = r-x  (read + execute)
# 4 = r--  (read only)

# chown — change ownership
chown alice file.txt             # Change owner
chown alice:staff file.txt       # Change owner and group
chown -R alice:staff ./dir/      # Recursive

# sudo — run as root
sudo command                     # Run with root privileges
sudo -u alice command            # Run as specific user

A common mistake is reaching for chmod -R 777 to make a permissions error go away — it works, but it also means every user on the system can read, write, and execute everything under that directory, which is rarely what you actually want and can turn a minor inconvenience into a real security issue on a shared or internet-facing machine. Prefer the narrowest fix: chmod +x for a single script that needs to be executable, or chown when the actual problem is that a file is owned by the wrong user (common after copying files in as root, e.g. via sudo).

Compression & Archives

tar is the default for anything Linux/Mac-native — -czf creates a gzip-compressed archive, -xzf extracts one, and the flag order matters less than people think but the letters themselves are worth knowing individually: c create, x extract, z gzip, f “the next argument is the filename.” zip/unzip exist mainly for cross-platform compatibility with Windows users or tools that expect the .zip format specifically; reach for it when you know the archive needs to open cleanly on a machine that isn’t Unix-like. gzip compresses a single file in place (and removes the original by default — use -k to keep it), which makes it the right tool for shrinking an individual log file rather than bundling a whole directory.

# tar — most common on Linux/Mac
tar -czf archive.tar.gz ./dir/   # Create compressed archive
tar -xzf archive.tar.gz          # Extract
tar -tzf archive.tar.gz          # List contents without extracting
tar -xzf archive.tar.gz -C /tmp/ # Extract to specific directory

# zip/unzip
zip -r archive.zip ./dir/        # Create zip
unzip archive.zip                # Extract zip
unzip -l archive.zip             # List contents

# gzip — single file compression
gzip large-file.log              # Compress (removes original, creates .gz)
gunzip large-file.log.gz         # Decompress
gzip -k file.log                 # Keep original while compressing
zcat file.log.gz                 # View compressed file without extracting

tar -tzf before extracting anything you didn’t create yourself is a habit worth building — it lists the archive’s contents without writing anything to disk, which matters because a malformed or malicious archive can otherwise dump files anywhere in the filesystem depending on the paths stored inside it.

Real-World Troubleshooting Scenarios

The sections above cover individual commands; this section is about the sequences you actually run when something breaks. Each of these follows the same shape: identify what’s wrong, gather more information, then act.

”Port already in use”

This almost always means a previous instance of your dev server or app didn’t shut down cleanly. The fix is a two-step: find the PID holding the port, then kill it. The $(...) substitution below is what makes this a one-liner instead of a copy-paste-the-PID dance.

# Find what's using port 8080
lsof -i :8080                    # macOS / Linux
ss -tlnp | grep 8080             # Linux

# Kill it
kill -9 $(lsof -t -i :8080)     # macOS / Linux

”Disk is full”

Start broad (df -h to find which filesystem is full — it’s not always /), then narrow (du -sh to find what). Logs are the most common culprit on a long-running server, since they grow unbounded without log rotation configured. Deleting old logs on a schedule (or configuring logrotate) prevents this from recurring rather than just fixing it once.

df -h                                        # Find which filesystem is full
du -sh /var/log/* | sort -rh | head -20      # Find largest log files
find /var/log -name "*.log" -mtime +30 -delete  # Delete logs older than 30 days

”Server is slow — what’s eating CPU/memory?”

top gives you the live picture; pressing M while it’s running sorts by memory instead of the default CPU sort, which is useful when the symptom is swapping rather than a pegged CPU. The ps aux --sort variants are useful when you want a static, scriptable snapshot instead of an interactive view — for instance, to log the top consumers to a file every minute via cron.

top                              # Interactive view, press M for memory sort
ps aux --sort=-%cpu | head -10   # Top CPU consumers (Linux)
ps aux -r | head -10             # Top CPU consumers (macOS)

”API returns unexpected error — check logs”

The tail -f | grep pattern from earlier applied to a live incident: keep this running in one pane while you retry the failing request in another, so you see the exact log lines the failure produces without having to correlate timestamps after the fact.

tail -f /var/log/app.log | grep "ERROR"
# Or for JSON logs:
tail -f /var/log/app.json | jq 'select(.level == "error")'

”Environment variable not set”

A surprisingly common source of “works locally, breaks in production” bugs. env dumps everything, which is useful for a quick visual scan; echo $VAR or printenv VAR checks one specific variable, which is what you actually want once you know which one you’re chasing. Remember that export only persists for the current shell session — anything set this way disappears when the terminal closes unless it’s also written to a shell startup file.

env                              # All environment variables
echo $PATH                       # Specific variable
printenv HOME                    # Alternative
export MY_VAR="value"            # Set for current session
echo 'export MY_VAR="value"' >> ~/.zshrc  # Persist across sessions

Quick Reference

TaskCommand
Follow logtail -f app.log
Search in filesgrep -r "pattern" ./src/
Who owns port 8080lsof -i :8080
Disk usagedf -h && du -sh *
Kill process by namepkill -f "my-app"
Find large filesfind / -size +1G 2>/dev/null
HTTP requestcurl -X POST url -H "Content-Type: application/json" -d '{}'
Replace in filesed -i 's/old/new/g' file.txt
Count lines matchinggrep -c "ERROR" app.log
Extract columnawk -F',' '{print $2}' data.csv

Related posts:


Frequently Asked Questions (FAQ)

Q. Are Linux and macOS commands different?

A. Most commands are identical, but some options differ between GNU (Linux) and BSD (macOS) versions of tools like sed, awk, date, and find. This guide notes differences where they exist. On macOS, install GNU coreutils via Homebrew (brew install coreutils) to get consistent behavior with servers you SSH into.

Q. What’s the difference between grep, sed, and awk?

A. grep searches for patterns and returns matching lines. sed is a stream editor for find-and-replace or deleting lines. awk is a data extraction tool that splits by delimiter, computes, and formats output. Use grep to filter, sed to transform lines, and awk to process structured data — they compose well in a single pipeline.

Q. How do I find which process is using a port?

A. Run lsof -i :8080 (macOS/Linux) or ss -tlnp | grep 8080 (Linux). These show the process ID and name holding the port, which you can then pass to kill -9 to free it.

Q. Why do my sed commands work on Linux but fail on my Mac?

A. macOS ships BSD sed, which requires an explicit (even if empty) argument after -i for in-place editing: sed -i '' 's/old/new/g' file.txt. Linux ships GNU sed, where sed -i 's/old/new/g' file.txt works without the extra argument. Portable scripts should either detect the OS or install and call GNU sed via Homebrew (gsed).