← Back to all posts

Bash vs Python for Small Linux Automation Tasks

Implement the same file-byte report in Bash and Python, then compare argument handling, empty inputs, failure behavior, and the dependencies each version needs.

A rounded shell window with a cream prompt and teal execution arrow

For a small Linux automation task, choose Bash or Python after defining the input and failure behavior. “Count bytes in text files” sounds trivial until a directory is missing, a filename contains a newline, or an empty directory must produce a nonzero result.

The two implementations below use the same contract. Bash stays close to a command-line loop; Python keeps path handling and error reporting in one program. Neither choice makes the contract unnecessary.

Agree on the task before comparing languages

The report reads regular files whose names match *.txt in one directory, without recursion. It reports each filename and byte count. A missing directory, no matching regular files, or a failed read must return nonzero. Names are displayed in an escaped form so a newline does not silently become a second report row.

The report is intended for a person. It is not a machine-readable interchange format, and a failure after earlier files were read can leave partial output. Both versions follow regular-file symlinks; neither is a sandbox for untrusted directories or a snapshot protected against concurrent changes.

Create an isolated fixture with Python 3:

practice_dir=$(mktemp -d)
cd "$practice_dir"
python3 - <<'PY'
from pathlib import Path
root = Path('inputs')
root.mkdir()
for name, content in {
    'plain.txt': b'a\n',
    'release notes.txt': b'alpha\n',
    'report*.txt': b'beta\n',
    '-draft.txt': b'gamma\n',
    'two\nlines.txt': b'z\n',
}.items():
    (root / name).write_bytes(content)
Path('empty').mkdir()
PY

The expected byte counts are 2, 6, 5, 6, and 2 for those named files. These examples were checked with Bash 3.2.57 and Python 3.14.7 locally, then with Bash and Python in Linux containers. The code uses established Python 3 path APIs rather than a version-specific new feature.

Bash keeps the job in a short command loop

Save report.sh:

#!/usr/bin/env bash
set -eu
if [ "$#" -ne 1 ] || [ ! -d "$1" ]; then
  printf 'Usage: bash report.sh EXISTING_DIRECTORY\n' >&2
  exit 2
fi
count=0
for file in "$1"/*.txt; do
  [ -f "$file" ] || continue
  if ! bytes=$(wc -c < "$file"); then
    printf 'Cannot read <%s>\n' "$file" >&2
    exit 1
  fi
  printf '%q\t%s\n' "$file" "$bytes"
  count=$((count + 1))
done
if [ "$count" -eq 0 ]; then
  printf 'No matching regular files\n' >&2
  exit 1
fi

Run bash report.sh inputs. The quoted directory forms the fixed part of the pattern, while *.txt remains a glob. Each resulting path is quoted again when opened. The input redirection keeps a leading dash in the filename away from wc’s option parser.

Bash’s %q prints a shell-escaped representation. It makes unusual characters visible, but it is Bash-specific and does not define a universal report format. wc may pad the count with spaces; compare numeric values rather than byte-identical rows.

The file test handles the default unmatched-glob case by skipping the literal pattern. The counter then enforces the agreed empty-input failure. Read the filename-expansion rules when changing shell options such as nullglob or failglob; those options change the loop’s behavior.

Python keeps the paths out of shell expansion

Save report.py:

from pathlib import Path
import sys

if len(sys.argv) != 2 or not Path(sys.argv[1]).is_dir():
    print('Usage: python3 report.py EXISTING_DIRECTORY', file=sys.stderr)
    raise SystemExit(2)

root = Path(sys.argv[1])
count = 0
try:
    for path in sorted(root.glob('*.txt')):
        if not path.is_file():
            continue
        size = 0
        with path.open('rb') as stream:
            for block in iter(lambda: stream.read(65536), b''):
                size += len(block)
        print(f'{str(path)!r}\t{size}')
        count += 1
except OSError as error:
    print(f'Cannot complete report: {error}', file=sys.stderr)
    raise SystemExit(1)

if count == 0:
    print('No matching regular files', file=sys.stderr)
    raise SystemExit(1)

Run python3 report.py inputs. Pathlib finds paths and opens files without asking a shell to split a constructed command. The code reads in blocks rather than loading a whole file merely to count it. The !r representation exposes newline characters; its quoting is different from Bash %q.

Python still needs validation. A path object does not make a missing directory valid or guarantee that a file remains unchanged between discovery and reading. Pathlib globbing can suppress some scanning errors, so this small report does not promise a complete inventory of an unreadable tree. If completeness matters, choose a traversal API with explicit scan-error handling and define that stronger contract first.

Test the failures as well as the normal directory

bash report.sh inputs
python3 report.py inputs
bash report.sh empty
python3 report.py empty
bash report.sh missing
python3 report.py missing

Run the commands individually in an interactive shell and inspect $? immediately after each one. Both normal runs should return 0, both empty runs 1, and both missing-directory runs 2. Check each named file’s count; row order and escaping are not required to be identical.

The filename argument walkthrough explains why a fix that works for plain.txt can still process the wrong files. Neither language gets credit merely because its simplest input passes.

Choose by the next change you expect

Next requirement What it changes
Chain a few installed command-line tools Bash keeps their invocation visible
Parse JSON or produce a structured report Python can represent fields and validation directly
Preserve exact argument boundaries when launching a tool Bash needs quoted expansions and arrays; Python needs an argument list
Run where Python is not installed The Python version adds a runtime prerequisite
Run as portable /bin/sh This Bash script needs changes; %q is not portable shell syntax
Guarantee a complete inventory or atomic report Both examples need a stronger design

When Python calls an external tool, prefer an argument list such as subprocess.run(['tool', str(path)], check=True) to a constructed shell string. The subprocess documentation explains execution and failure handling. Avoiding a shell does not remove the called tool’s own option syntax.

Choose Bash for a small, readable command pipeline whose edge cases you can still inspect. Choose Python when structured data, branching, reusable functions, or error policy make the shell version harder to review. Carry the same unusual-name and failure fixtures across the change; translating the syntax without preserving the contract is not a successful migration.

References