Filtering is the task of pulling relevant lines, records, or files out of a larger set based on some condition — log entries from the last hour, CSV rows matching a value, files above a certain size, text lines matching a pattern while excluding another. grep, awk, and find already do most of the heavy lifting individually; the value of a dedicated filtering tool is combining them into a consistent interface that covers the common filtering shapes people actually need.
This article builds a flexible Bash file filtering tool covering content filtering (grep/awk-based) and file-level filtering (find-based), with attention to performance and correctness on large inputs.
Two Kinds of Filtering
It’s worth separating this into two distinct problems up front:
- Content filtering — selecting lines within a file that match a condition.
- File filtering — selecting which files to operate on, based on metadata (size, age, name, type).
A capable tool handles both, since real tasks often combine them (“find all .log files modified in the last day, then filter for ERROR lines within them”).
Step 1: Basic Content Filtering with Grep
#!/usr/bin/env bash
set -euo pipefail
FILE="$1"
PATTERN="$2"
grep -n "$PATTERN" "$FILE"
-n prefixes matching lines with their line number, which is almost always useful context when filtering. Running:
./filter.sh app.log "ERROR"
42:2026-07-28 10:15:03 ERROR failed to connect to database
187:2026-07-28 10:22:41 ERROR timeout on request
Step 2: Inverting the Match (Exclusion)
Sometimes the goal is to filter out noise rather than filter in matches:
grep -v "$EXCLUDE_PATTERN" "$FILE"
-v inverts the match, printing every line that does not match. Combining inclusion and exclusion:
grep "$INCLUDE_PATTERN" "$FILE" | grep -v "$EXCLUDE_PATTERN"
This chains two grep calls: first keep lines matching the pattern of interest, then drop any of those that also match a known-noisy pattern (e.g., keep ERROR lines but exclude ones from a known-flaky health check).
Step 3: Case-Insensitive and Regex Filtering
grep -i "$PATTERN" "$FILE" # case-insensitive
grep -E "$EXTENDED_REGEX" "$FILE" # extended regex support
grep -P "$PERL_REGEX" "$FILE" # Perl-compatible regex (lookaheads, etc.)
-iignores case, useful when matching human-entered text with inconsistent capitalization.-Eenables extended regular expressions, supporting+,?,|, and{n,m}without needing to backslash-escape them.-Penables Perl-compatible regex, which supports lookaheads/lookbehinds — useful for more advanced conditional matching (e.g., “match ERROR but only if NOT immediately followed by ‘ignored'”).
Step 4: Field-Based Filtering with Awk
Grep matches whole lines; awk filters based on specific fields, which is essential for structured data like CSVs or space-delimited logs:
awk -F',' '$3 == "active"' users.csv
-F','sets the field separator to a comma.$3 == "active"filters to only rows where the third field equals"active"; matching rows are printed by default when no explicitprintaction is given, since a bare condition inawkimplicitly prints the whole line when true.
Filtering by numeric comparison:
awk -F',' '$4 > 100' sales.csv
This keeps only rows where the fourth column, interpreted numerically, exceeds 100.
Step 5: Filtering Files by Metadata with Find
find . -name "*.log" -size +10M
Finds every .log file larger than 10 megabytes. Common metadata filters:
find . -mtime -1 # modified in the last 1 day
find . -mtime +30 # modified more than 30 days ago
find . -size +100k # larger than 100 KB
find . -type f -empty # empty files
find . -newer reference.txt # modified more recently than reference.txt
-mtime -1uses a negative value to mean “less than,” so this matches files modified within the last day.-mtime +30uses a positive value to mean “more than,” matching files older than 30 days — useful for cleanup scripts.-newercompares modification time against another file directly, without needing to calculate a specific day count.
Step 6: A Unified Filtering Script
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $0 <mode> [options]
Modes:
content <file> -i <pattern> [-x exclude_pattern] [-c (case-insensitive)]
field <file> -d delimiter -f field_num -v value
files <dir> [-n name_glob] [-s size] [-m mtime_days]
EOF
exit 1
}
MODE="${1:-}"
[[ -z "$MODE" ]] && usage
shift
case "$MODE" in
content)
FILE="$1"; shift
INCLUDE=""; EXCLUDE=""; CASE_FLAG=""
while getopts ":i:x:c" opt; do
case "$opt" in
i) INCLUDE="$OPTARG" ;;
x) EXCLUDE="$OPTARG" ;;
c) CASE_FLAG="-i" ;;
*) usage ;;
esac
done
RESULT=$(grep $CASE_FLAG "$INCLUDE" "$FILE")
if [[ -n "$EXCLUDE" ]]; then
RESULT=$(echo "$RESULT" | grep -v "$EXCLUDE")
fi
echo "$RESULT"
;;
field)
FILE="$1"; shift
DELIM=","; FIELD=1; VALUE=""
while getopts ":d:f:v:" opt; do
case "$opt" in
d) DELIM="$OPTARG" ;;
f) FIELD="$OPTARG" ;;
v) VALUE="$OPTARG" ;;
*) usage ;;
esac
done
awk -F"$DELIM" -v field="$FIELD" -v val="$VALUE" '$field == val' "$FILE"
;;
files)
DIR="$1"; shift
NAME="*"; SIZE=""; MTIME=""
while getopts ":n:s:m:" opt; do
case "$opt" in
n) NAME="$OPTARG" ;;
s) SIZE="$OPTARG" ;;
m) MTIME="$OPTARG" ;;
*) usage ;;
esac
done
FIND_ARGS=(-name "$NAME")
[[ -n "$SIZE" ]] && FIND_ARGS+=(-size "$SIZE")
[[ -n "$MTIME" ]] && FIND_ARGS+=(-mtime "$MTIME")
find "$DIR" -type f "${FIND_ARGS[@]}"
;;
*)
usage
;;
esac
Example invocations:
./filter.sh content app.log -i "ERROR" -x "healthcheck"
./filter.sh field users.csv -d ',' -f 3 -v "active"
./filter.sh files ./logs -n "*.log" -s +10M -m -1
The field mode passes both the field number and comparison value into awk as external variables (-v field="$FIELD" -v val="$VALUE") rather than interpolating them directly into the awk program string — this avoids quoting headaches and keeps the awk script itself static and safe regardless of what values are passed in.
Real-World Use Cases
- Log triage: Pull all
ERROR/WARNlines from a day’s logs while excluding known noisy, non-actionable messages. - Data auditing: Filter a large CSV export down to just the rows matching a specific status or region before further analysis.
- Disk cleanup candidates: Find files above a certain size that haven’t been touched in months, as candidates for archival or deletion.
- Security review: Filter configuration files for lines matching risky patterns (e.g.,
password=,api_key=) across a codebase as a first-pass secret scan.
Automation Example: Daily Error Report
#!/usr/bin/env bash
set -euo pipefail
LOG="/var/log/app/current.log"
REPORT="/tmp/error-report-$(date +%F).txt"
grep -E "ERROR|CRITICAL" "$LOG" | grep -v "known-flaky-check" > "$REPORT"
if [[ -s "$REPORT" ]]; then
echo "Errors found, see $REPORT"
mail -s "Daily Error Report" [email protected] < "$REPORT"
else
echo "No errors today."
fi
[[ -s "$REPORT" ]] checks whether the report file has non-zero size — i.e., whether any errors were actually captured — before deciding to send a notification.
Best Practices
- Chain simple filters (
grep | grep -v) rather than writing one enormous regex — readability and maintainability matter more than a marginal performance difference for most file sizes. - Pass dynamic values into
awkvia-vrather than string interpolation, avoiding quoting and injection issues. - Use
find‘s built-in tests (-size,-mtime,-newer) instead of piping tolsand parsing output, sincelsoutput parsing is famously fragile and non-portable. - Prefer
-E(extended regex) over basic regex for anything beyond the simplest literal match, since basic regex’s escaping rules are more error-prone.
Security Considerations
- Regex denial-of-service (ReDoS): Certain pathological regex patterns can cause catastrophic backtracking in some regex engines, effectively hanging on specific input. Keep patterns simple and be cautious with nested quantifiers when a pattern is user-supplied.
- Never build a
grep/awkpattern directly from unsanitized user input without considering that special regex characters in that input can change the meaning of the match entirely — escape appropriately if literal matching is intended (grep -Ftreats the pattern as a fixed string rather than a regex). - Be mindful of what a filtering script surfaces: A log filter that’s supposed to find errors could inadvertently expose sensitive data (tokens, credentials) that end up in the log output — treat filtered output with the same sensitivity as the source.
Optimization Tips
grep -F(fixed-string mode) is meaningfully faster than regex mode when the pattern has no actual regex metacharacters — use it whenever the match is a literal string.- For very large files, prefer a single
awkpass with combined logic over multiple chainedgrepcalls, since each additional pipe stage adds process-spawning overhead. find‘s-prunecan skip entire directory subtrees (like.gitornode_modules) early, avoiding unnecessary traversal cost on large repositories.
Troubleshooting
- Grep returns nothing but the pattern should match: Check for case sensitivity (
-i), hidden control characters (\rfrom CRLF line endings), or an over-escaped regex. - Awk field filtering seems to skip rows: CSV fields containing quoted commas break naive
-F','splitting; a proper CSV-aware tool (orawk‘s more advanced FPAT feature) is needed for CSVs with quoted, comma-containing fields. find -mtimegives unexpected results:-mtimeoperates in whole-day increments based on the current time, which can be less precise than expected;-mminoffers minute-level granularity when finer control is needed.
Common Mistakes
- Using basic regex escaping rules where extended regex (
-E) would be clearer and less error-prone. - Parsing
ls -loutput to filter by size or date instead of usingfind‘s native tests, which breaks on filenames with spaces or unusual characters. - Forgetting
-Ffor fixed-string searches, incurring unnecessary regex engine overhead for a simple literal match.
FAQs
Can this filter binary files? grep can search binary files with -a (treat as text) or report matches without printing binary garbage using its default binary-detection behavior; generally, filtering is intended for text-based content.
How do I filter based on multiple conditions combined with AND/OR logic? Chain grep calls for AND logic (each stage narrows further), or use grep -E "pattern1|pattern2" for OR logic within a single pass.
Is awk overkill for simple line filtering? For simple whole-line pattern matching, grep is simpler and typically faster. awk earns its place once the filter depends on specific fields or requires arithmetic/conditional logic per line.
Summary
A filtering tool built around grep, awk, and find covers both major filtering needs — narrowing down file contents and narrowing down which files to look at in the first place — using nothing beyond standard, universally available Unix utilities. Structuring the wrapper around clear modes (content, field, files) keeps a small set of powerful primitives accessible through one consistent, memorable interface.