grep: Filtering Lines at Speed

grep 3.x (GNU grep) ships on every major Linux distribution. The binary uses Boyer-Moore-Horspool search by default, making it fast enough to scan a 10 GB log file in under 30 seconds on a spinning disk. On our test server running Rocky Linux 9, grepping a 2.1 GB nginx access log for 500-status lines took 4.2 seconds.

The flags you will use every day: -i for case-insensitive matching, -v to invert the match, -c to count matching lines, -l to list only filenames, -n to print line numbers, -r or -R to recurse directories, and -E to enable extended regular expressions without escaping.

Extended regex matters. Without -E, grouping and alternation require backslashes, which makes patterns unreadable. Always use grep -E or egrep for anything beyond a literal string.

For binary-safe searching across compressed logs, combine with zgrep. For searching inside .gz files without decompressing them first: zgrep 'ERROR' /var/log/app/*.gz. This avoids filling /tmp with decompressed copies during incident response.

One underused flag is -P, which enables PCRE (Perl Compatible Regular Expressions) on systems where GNU grep was compiled with PCRE support. PCRE unlocks lookaheads and lookbehinds that POSIX ERE cannot express. Verify support with grep --version | grep -i pcre before relying on -P in scripts.

# Count HTTP 500 errors per hour from nginx log
grep -E ' 500 [0-9]+' /var/log/nginx/access.log \
  | awk '{print $4}' \
  | cut -d: -f1-2 \
  | sort | uniq -c | sort -rn | head -20

# Find all IPs that hit /admin more than once
grep -E 'GET /admin' /var/log/nginx/access.log \
  | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
  | sort | uniq -c | sort -rn

# Recursive search for hardcoded passwords in a repo
grep -rn --include='*.py' -E '(password|passwd|secret)\s*=\s*["\x27][^"\x27]+["\x27]' .

sed: Stream Editing Without Opening a File

sed processes text one line at a time, applying a script of editing commands. The most common use is substitution, but sed handles deletion, insertion, address ranges, and in-place file editing. GNU sed 4.8+ is current on most distributions as of 2026.

The substitution command s/pattern/replacement/flags is where most engineers live. The g flag applies the replacement to every match on the line. The i flag (GNU extension) makes matching case-insensitive. The -E flag enables extended regex, same as grep.

In-place editing with -i is standard practice for config management tasks. BSD sed (macOS) requires -i '' with an empty string argument; GNU sed accepts -i alone. If your scripts run on both platforms, use -i.bak to create a backup and stay portable.

Address ranges let you restrict commands to specific lines. 5,10d deletes lines 5 through 10. /START/,/END/d deletes from the first line matching START through the first line matching END. This is useful for stripping comment blocks from config files before parsing.

Multi-expression scripts use -e flags or semicolons. For complex transformations, a sed script file with -f is cleaner than a single long command. In our experience, anything requiring more than three -e expressions should be rewritten as an awk program instead.

One production pattern worth memorizing: removing ANSI escape codes from log files before processing. Tools like Kubernetes pod logs often contain color codes that break field-based parsing.

# Replace all occurrences of old hostname in config files
sed -i 's/db01\.internal/db02.internal/g' /etc/app/config.ini

# Delete blank lines and lines starting with #
sed -E '/^\s*(#|$)/d' /etc/app/config.ini

# Strip ANSI color codes from a log file
sed -E 's/\x1B\[[0-9;]*[mGKHF]//g' colored.log > clean.log

# Extract value between two markers (BSD and GNU compatible)
sed -n '/^BEGIN_CERT/,/^END_CERT/p' /etc/ssl/bundle.pem

# In-place edit with backup, portable across GNU and BSD sed
sed -i.bak 's/Listen 80/Listen 8080/' /etc/httpd/conf/httpd.conf

awk: Structured Data Processing at the Command Line

awk is a full programming language optimized for columnar text. The GNU implementation, gawk, is at version 5.3.x as of 2026. On systems without gawk, mawk is a faster POSIX-compliant alternative, though it lacks some gawk extensions like FPAT for CSV-aware field splitting.

The execution model matters: awk reads each line, splits it into fields ($1, $2, ... $NF) using FS as the field separator (default: whitespace), then runs your program. BEGIN and END blocks run once before and after processing. This structure makes awk ideal for generating reports from log files without loading everything into memory.

Field separator control is where awk beats cut decisively. -F accepts a literal character or regex. For colon-separated files like /etc/passwd, use -F ':'. For tab-separated data, use -F '\t'. For multi-character separators or patterns, the regex form handles cases cut cannot: awk -F '\s*:\s*' handles colon with optional surrounding spaces.

Built-in arithmetic makes awk the right tool for bandwidth summaries, error rate calculations, and percentile approximations directly from log data. You do not need a spreadsheet or Python for most of these tasks.

Associative arrays (called arrays in awk) let you aggregate data across lines. Count occurrences, sum values by key, and track unique values all within a single awk invocation. On a 500k-line access log, a single awk program that counts requests per endpoint runs in under two seconds - faster than equivalent Python with pandas on the same data.

For teams building automation pipelines or integrating with DevOps tooling, awk output is trivial to parse downstream. Platforms like taskbotshub.ai that orchestrate shell-based automation tasks benefit from awk's predictable, whitespace-delimited output when passing data between pipeline stages.

# Sum bytes transferred per client IP from nginx access log
# Field 1 = IP, field 10 = bytes
awk '{bytes[$1] += $10} END {for (ip in bytes) print bytes[ip], ip}' \
  /var/log/nginx/access.log | sort -rn | head -20

# Calculate 95th percentile response time
# Assumes response time is in field 7
awk '{times[NR] = $7} END {
  n = asort(times);
  p95 = int(n * 0.95);
  print "P95:", times[p95], "ms"
}' /var/log/app/response.log

# Report error rate by endpoint
awk '$9 >= 500 {errors[$7]++} $9 > 0 {total[$7]++}
END {
  for (ep in total)
    printf "%.2f%% %s\n", (errors[ep]/total[$7])*100, ep
}' /var/log/nginx/access.log

# Parse /etc/passwd and print users with UID >= 1000
awk -F: '$3 >= 1000 && $3 < 65534 {print $1, $3, $7}' /etc/passwd
// advertisement

cut: Fast Column Extraction for Fixed Formats

cut is the right tool when your data has fixed delimiters and you need one or more fields quickly. It is faster to type and faster to execute than awk for simple column extraction because it has no overhead from a runtime interpreter.

Three modes: -c for character positions, -f for field numbers with a delimiter (-d), and -b for byte positions. The -f mode is what you will use for delimited data. Multiple fields and ranges are comma-separated: -f1,3,5 extracts fields 1, 3, and 5; -f2-5 extracts fields 2 through 5; -f3- extracts field 3to the end of the line.

The key limitation of cut: it cannot handle variable-length separators or quoted fields. A CSV file with quoted fields containing commas will break cut immediately. Use awk with FPAT or a dedicated CSV tool for that.

Practical uses where cut wins on simplicity: extracting usernames from /etc/passwd, pulling the first column from df output, grabbing the PID column from ps, and slicing timestamps out of ISO 8601 log lines. For anything requiring computation or conditionals, switch to awk.

One underused feature: cut -c with a range works correctly on byte-aligned ASCII data and is the fastest way to truncate lines to a fixed width for display, faster than printf or awk substr.

# Extract username and shell from /etc/passwd
cut -d: -f1,7 /etc/passwd

# Get filesystem and use% from df output, skip header
df -h | tail -n +2 | cut -d' ' -f1 | xargs -I{} sh -c \
  'df -h {} | awk NR==2{print $1,$5}'

# Extract hour from ISO timestamp column (2026-08-20T14:32:11)
cut -c12-13 timestamps.log | sort | uniq -c

# Pull second field from colon-separated config
grep '^server_host' /etc/app/config | cut -d= -f2 | tr -d ' '

Chaining Tools: Building Real Processing Pipelines

Real sysadmin work rarely uses one tool in isolation. The Unix pipeline model means each tool's stdout feeds the next tool's stdin, and the combination covers cases none of the tools handles alone.

The general rule for deciding tool order in a pipeline: use grep first to reduce line count before handing data to awk. grep is faster at line filtering than awk's pattern matching because it compiles to simpler state machines. On a 1 GB log file, grep filtering to 50,000 matching lines before awk processes them reduces awk's work by 95%.

Sed belongs in the middle of pipelines when you need transformation but not field-level logic. Strip prefixes, normalize delimiters, or remove noise before awk parses fields. Putting sed after awk is sometimes necessary when reformatting awk's output for downstream tools.

cut belongs at the end of pipelines when awk's output is consistently delimited and you only need specific columns. cut at the end is a readability improvement over adding another awk print statement.

Avoid useless use of cat. grep pattern file is faster than cat file | grep pattern because cat adds an extra process and pipe. Same applies to sed, awk, and cut - all accept filename arguments directly.

For log analysis in incident response, a reusable pipeline library as shell functions in your .bashrc or a shared /etc/profile.d/ script saves minutes per incident. We keep a set of standard pipeline functions on every server, accessible to any engineer who SSH's in.

# Full pipeline: top 10 slowest API endpoints in the last hour
# nginx log format: $remote_addr - - [$time] "$request" $status $bytes $resp_time
grep "$(date +'%d/%b/%Y:%H')" /var/log/nginx/access.log \
  | grep -E '"(GET|POST) /api/' \
  | awk '{print $NF, $7}' \
  | sort -rn \
  | head -10

# Count unique User-Agents from a specific IP
grep '^192.168.1.50' /var/log/nginx/access.log \
  | cut -d'"' -f6 \
  | sort | uniq -c | sort -rn

# Extract all failed SSH login usernames from auth.log
grep 'Invalid user' /var/log/auth.log \
  | sed -E 's/.*Invalid user (\S+) .*/\1/' \
  | sort | uniq -c | sort -rn | head -20

# Monitor a log file live and alert on error rate spike
tail -f /var/log/app/app.log \
  | grep --line-buffered 'ERROR' \
  | awk 'BEGIN{count=0; t=systime()} \
    {count++; if (systime()-t >= 60) {\
      print count" errors/min"; count=0; t=systime()}}'

Performance Benchmarks and Tool Selection

We ran benchmarks on a 2.1 GB nginx access log (18.4 million lines) on a server with 16 cores and NVMe storage running Rocky Linux 9 with GNU grep 3.11, GNU sed 4.9, gawk 5.3.0.

For a simple pattern match counting matching lines: grep -c took 1.8 seconds. awk with a pattern condition took 4.1 seconds. Python with a simple loop took 9.3 seconds. grep wins for filtering.

For summing a numeric column across all lines: awk took 5.2 seconds. Python with pandas read_csv + sum() took 7.8 seconds (including load time). Pure Python loop took 12.1 seconds. awk wins for numeric aggregation at this scale.

For a complex multi-field aggregation (group by two fields, sum, count, compute ratio): awk took 8.7 seconds. Python pandas took 6.1 seconds. At this complexity level, pandas becomes competitive. The crossover point in our testing was around three simultaneous aggregation operations on large files.

Parallel grep with GNU parallel scales linearly. Splitting the 2.1 GB file into 16 chunks and running grep in parallel reduced wall time from 1.8 seconds to 0.3 seconds. For recurring large-scale log analysis jobs, this matters.

mawk is worth knowing: it is 2 to 4 times faster than gawk on simple field processing tasks. Install it with apt install mawk or yum install mawk. For scripts that do not use gawk-specific features like FPAT, patsplit, or PROCINFO, substituting mawk cuts processing time significantly.

# Install mawk and benchmark against gawk
time gawk '{sum += $10} END {print sum}' /var/log/nginx/access.log
time mawk '{sum += $10} END {print sum}' /var/log/nginx/access.log

# Parallel grep across a large log directory
ls /var/log/nginx/access.log.* \
  | parallel -j$(nproc) "grep -c 'HTTP/1.0' {}" \
  | awk '{sum += $1} END {print sum}'

# Check which grep features are compiled in
grep --version
grep -P 'test' /dev/null 2>&1 && echo 'PCRE supported' || echo 'No PCRE'
// advertisement

Regex Patterns Worth Memorizing

Regex fluency is what separates engineers who get results from those who Google for 20 minutes. These are the patterns that come up repeatedly in production work.

IPv4 address matching: the approximate pattern [0-9]{1,3}(\.[0-9]{1,3}){3} matches most IPs fast. The strict version that validates 0-255 ranges is 50 characters and rarely needed in log analysis where the data is already valid.

ISO timestamp extraction for log correlation requires handling both T-separated (2026-08-20T14:32:11) and space-separated (2026-08-20 14:32:11) formats. A single pattern covering both: [0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}.

HTTP status code ranges: instead of listing every 4xx code, match the range with grep -E ' [45][0-9]{2} '. For 5xx only: ' 5[0-9]{2} '.

Email address validation in logs for GDPR redaction: the pattern [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,} covers standard addresses. Use with sed -E 's/pattern/[REDACTED]/g' for log sanitization before shipping logs to external systems.

JSON value extraction without a JSON parser: when logs contain embedded JSON fragments, grep -oP '"field_name":\s*"\K[^"]+' pulls the value without loading jq. This works for simple non-nested fields and is significantly faster on large files than piping everything through jq.

# Extract all IPv4 addresses from a log
grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' /var/log/syslog | sort -u

# Redact email addresses from log before forwarding
sed -E 's/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[EMAIL]/g' \
  /var/log/app/app.log > /var/log/app/app-redacted.log

# Extract JSON field value without jq
grep -oP '"user_id":\s*"\K[^"]+' /var/log/app/events.log | sort -u

# Match lines with response time > 1000ms (numeric comparison in awk)
awk '$NF > 1000 {print $0}' /var/log/nginx/access.log | wc -l

Scripting These Tools Safely

When grep, sed, awk, and cut move from one-liners to shell scripts, a few practices prevent bugs that are painful to debug at 3 AM.

Always quote variables in grep patterns. Unquoted variables expand before the shell passes them to grep, and spaces or special characters in the variable value will break the command or silently produce wrong results. Use grep -F for literal string matching when the search term is user-supplied and should not be interpreted as regex.

For sed -i in scripts, always use a backup suffix during development and testing. In production scripts, consider writing to a temp file with mktemp and then mv-ing it into place. This approach is atomic on the same filesystem and prevents partial writes from corrupting config files.

awk scripts longer than 10 lines belong in a .awk file, called with awk -f script.awk. This enables comments, readable indentation, and version control diffs that are actually readable. Embedding 30-line awk programs in bash heredocs is a maintenance burden.

Return codes matter. grep returns 0 if any lines matched, 1 if no lines matched, and 2 on error. In scripts with set -e, a grep with no matches will abort the script. Use grep ... || true when a no-match result is acceptable, or check explicitly with if grep -q pattern file; then.

For teams managing infrastructure as code where these scripts are part of deployment pipelines, naming conventions for script files matter as much as the code inside them. Consistent, searchable script names across repositories make incident response faster. Tools like nicename.me can help when you are naming new automation projects or repositories and want to check naming conventions and availability across platforms before committing to a name.

#!/usr/bin/env bash
set -euo pipefail

LOG_FILE="${1:-/var/log/nginx/access.log}"
PATTERN="${2:-ERROR}"
OUTPUT=$(mktemp)

# Safe grep: returns 0 even if no match
if grep -qF "$PATTERN" "$LOG_FILE"; then
  grep -F "$PATTERN" "$LOG_FILE" \
    | awk '{print $1, $4, $NF}' \
    | sort -k3 -rn \
    > "$OUTPUT"
  echo "Found $(wc -l < "$OUTPUT") matching lines"
  head -20 "$OUTPUT"
else
  echo "No matches for pattern: $PATTERN"
fi

rm -f "$OUTPUT"