How to Create a Bash File Filtering Tool

How to Create a Bash File Filtering Tool

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:

  1. Content filtering — selecting lines within a file that match a condition.
  2. 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.)
  • -i ignores case, useful when matching human-entered text with inconsistent capitalization.
  • -E enables extended regular expressions, supporting +, ?, |, and {n,m} without needing to backslash-escape them.
  • -P enables 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 explicit print action is given, since a bare condition in awk implicitly 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 -1 uses a negative value to mean “less than,” so this matches files modified within the last day.
  • -mtime +30 uses a positive value to mean “more than,” matching files older than 30 days — useful for cleanup scripts.
  • -newer compares 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/WARN lines 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 awk via -v rather than string interpolation, avoiding quoting and injection issues.
  • Use find‘s built-in tests (-size, -mtime, -newer) instead of piping to ls and parsing output, since ls output 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/awk pattern 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 -F treats 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 awk pass with combined logic over multiple chained grep calls, since each additional pipe stage adds process-spawning overhead.
  • find‘s -prune can skip entire directory subtrees (like .git or node_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 (\r from 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 (or awk‘s more advanced FPAT feature) is needed for CSVs with quoted, comma-containing fields.
  • find -mtime gives unexpected results: -mtime operates in whole-day increments based on the current time, which can be less precise than expected; -mmin offers 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 -l output to filter by size or date instead of using find‘s native tests, which breaks on filenames with spaces or unusual characters.
  • Forgetting -F for 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.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Create a Bash File Sorting Tool

How to Create a Bash File Sorting Tool

Next Post
How to Create a Bash File Editing Tool

How to Create a Bash File Editing Tool

Related Posts