The Three Rules McIlroy Actually Wrote

Most summaries of the Unix philosophy quote Ken Thompson or cite Eric Raymond's seventeen rules from 'The Art of Unix Programming'. The original source is McIlroy's 1978 Bell System Technical Journal foreword, which stated three things:

1. Write programs that do one thing and do it well. 2. Write programs to work together. 3. Write programs to handle text streams, because that is a universal interface.

Rule three is the one practitioners forget. Text streams are the reason you can pipe `ps aux` into `grep`, into `awk`, into `sort`, into `head` without writing a single line of glue code. The programs never need to know about each other. They agree only on the format: newline-delimited text, fields separated by whitespace or a chosen delimiter.

In practice, this means any tool that produces machine-parseable text output can participate in pipelines. Any tool that demands a database connection, a GUI, or a proprietary binary format is opting out of composability. That is a cost you pay every single time you need to automate something.

# McIlroy's philosophy in 12 characters of actual shell:
ps aux | grep nginx | awk '{print $2}' | xargs kill -HUP

Why Single Responsibility Is an Operational Constraint, Not a Design Ideal

Single responsibility in software engineering is usually discussed as an aesthetic preference. In Unix tooling it is an operational constraint with measurable consequences.

Consider `sort`. It sorts text. That is all it does. The binary on a modern Linux system is from GNU coreutils 9.4 and the man page is 847 lines long. Eight hundred and forty-seven lines about sorting text. That depth exists because the authors had one job and spent all their time making it correct, fast, and composable instead of adding features.

`sort` handles locale-aware sorting, numeric sorting, human-readable suffix sorting (1K, 2M), random shuffling, merge sorting pre-sorted files, stable sorting, and parallel sorting across multiple CPU cores. It does all of this through flags, not through separate binaries. The interface stays simple. The implementation gets deep.

Contrast this with tools that try to be everything. Elasticsearch was introduced to many teams as a search tool. Within two years it was being used for logging, metrics, alerting, and dashboards. Each new use case added operational complexity. The failure modes multiplied. JVM heap tuning for search workloads is different from JVM heap tuning for ingestion workloads. Teams that used it as one thing kept it manageable. Teams that used it as everything eventually replaced it with three separate tools.

The operational lesson: a tool that does one thing has one failure mode. You know exactly what to check when it breaks.

# sort with parallel processing - 4 threads, numeric sort on field 3
sort --parallel=4 -t',' -k3 -n access.log > sorted_access.log

# human-readable sort (sorts 1K < 1M < 1G correctly)
du -sh /var/log/* | sort -h

Composability in Practice: Building Pipelines That Last

A pipeline is a contract between programs. Each program reads stdin, does its one thing, writes stdout. The contract is so simple that programs written forty years apart can interoperate without modification.

Here is a real example from log analysis. You have nginx access logs. You want the top ten IP addresses by request count, excluding your own monitoring systems.

This pipeline uses five programs. Each one exists independently. You can test each stage separately by breaking the pipe and examining intermediate output. If the result is wrong, you can isolate which stage introduced the error in under thirty seconds.

The alternative - a Python script that reads the log, parses it, filters it, counts it, sorts it, and formats it - is not inherently worse, but it is a single unit. When it fails, you debug the entire script. When requirements change, you edit code. When you need the same analysis on a different log format, you rewrite.

Pipelines change requirements by changing flags. Change `-n10` to `-n20`. Change the grep pattern. Add another filter stage. The components do not care.

This is why shell pipelines written in 2005 still run in 2026 on modern Linux systems. The programs changed internally. The interface did not.

# Top 10 IPs by request count, excluding monitoring subnet 10.0.0.0/8
awk '{print $1}' /var/log/nginx/access.log \
  | grep -v '^10\.' \
  | sort \
  | uniq -c \
  | sort -rn \
  | head -10

# Test individual stages:
awk '{print $1}' /var/log/nginx/access.log | head -5
awk '{print $1}' /var/log/nginx/access.log | grep -v '^10\.' | head -5
// advertisement

When to Write a New Tool vs. Compose Existing Ones

The philosophy does not say 'never write new programs'. It says write programs that do one thing well. The decision point is: does a composable pipeline already solve this, or is there genuine new functionality needed?

If you find yourself writing a Python script that does: read file, filter lines, transform format, count occurrences, sort results - stop. That is four programs you already have. Write the pipeline instead.

If you find yourself writing a pipeline that is 200 characters long with twelve stages and three temporary files, stop. The problem has outgrown the pipeline model. Write the program. But write it to read stdin, write stdout, accept flags, and exit with meaningful codes. Make it composable.

The practical threshold we use: if the pipeline needs more than one temporary file or more than one process substitution, it is probably time to write a small program. If that program needs to do more than one conceptually distinct thing, it should be two programs.

When naming that new tool, clarity matters as much as functionality. A binary called `logfilter` is self-documenting in ways that `logtool` or `logutil` are not. The same principle applies if you are open-sourcing it or building a public-facing service around it - a clear, memorable name is part of the tool's interface. Resources like nicename.me help teams check name availability across namespaces before committing to a name in package managers, container registries, or DNS.

Write the program in the language that is already installed on your target systems. A Go binary with no dependencies is better than a Python script that requires a specific virtualenv. A shell function is better than either if the logic is simple enough.

# Process substitution instead of temp files (still readable):
diff <(sort file1.txt) <(sort file2.txt)

# When it gets ugly, write a tool. Good tool signature:
# reads stdin, writes stdout, flag-driven, exits 0/1/2
cat access.log | ./logfilter --status=500 --after='2026-08-01' | sort | uniq -c

The Text Stream Interface and Its Limits

Text streams are universal, but they have costs. Parsing text is slower than reading binary formats. Floating-point numbers lose precision when serialized to decimal strings. Structured data like nested JSON requires tools like `jq` that are essentially parsers bolted onto the pipeline model.

`jq` is interesting because it extends the philosophy to JSON while respecting it. It reads a text stream of JSON, does one thing (query and transform JSON), writes a text stream of JSON or plain text. You can pipe `curl` into `jq` into `grep` into `awk`. The model holds.

Where the model breaks is with binary data, interactive applications, and stateful protocols. `vim` is not composable. `ssh` in interactive mode is not composable. `sqlite3` with a persistent database is only partially composable. These are not violations of the philosophy - they are different problem domains where the philosophy correctly does not apply.

The test is: does this tool need to maintain state between invocations? If yes, the text stream model is probably wrong for the core functionality. But even stateful tools can expose a composable interface. `sqlite3` in batch mode reads SQL from stdin and writes results to stdout. `git` produces text output for almost every command. The stateful core can be surrounded by a composable interface.

In 2026, the practical extension of text streams is newline-delimited JSON (ndjson). One JSON object per line. Tools like `jq`, `mlr` (Miller), and `fx` process it natively. `mlr` version 6.x in particular reads CSV, TSV, JSON, and ndjson and converts between them, which makes it the `awk` of structured data.

# ndjson pipeline: parse nginx logs as structured data
nginx_log_to_json access.log \
  | jq -c 'select(.status >= 500)' \
  | jq -r '[.remote_addr, .status, .request] | @tsv' \
  | sort | uniq -c | sort -rn

# Miller (mlr) processing CSV like awk:
mlr --csv filter '$status >= 500' then stats1 -a count -g remote_addr access.csv \
  | mlr --csv sort-within-records then sort -f count

Applying the Philosophy to Scripts and Automation

Shell scripts fail the philosophy more often than any other category of Unix tooling. A script that provisions a server, installs packages, configures services, creates users, sets firewall rules, and sends a notification is seven programs that someone decided to combine into one.

The correct structure: seven scripts, each doing one thing, orchestrated by a controller. Each script reads environment variables or flags for input, exits 0 on success, exits non-zero on failure, writes progress to stdout, writes errors to stderr. The controller calls them in sequence and halts on first failure.

This is directly testable. You can run `./install_packages.sh` on a fresh VM and verify it works before you ever wire it into the orchestration. You cannot do that with a monolithic provisioning script.

In DevOps automation, this maps directly to how good CI/CD pipelines are structured: discrete jobs, each with a single responsibility, connected by artifact passing or environment state. Tools like those at taskbotshub.ai that build on this model - where individual automation bots handle specific tasks and compose through defined interfaces - apply the Unix philosophy at the workflow level rather than just the command level. The abstraction changes, but the principle does not: one job, one responsibility, composable output.

For your own scripts, enforce this structurally:

#!/usr/bin/env bash
# Good: single responsibility, composable interface
# Usage: ./check_disk.sh [threshold_percent]
# Exit: 0=OK, 1=warning, 2=critical, 3=unknown

set -euo pipefail

THRESHOLD=${1:-85}
USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')

if [[ $USAGE -ge $THRESHOLD ]]; then
  echo "CRITICAL: disk usage ${USAGE}%" >&2
  exit 2
fi

echo "OK: disk usage ${USAGE}%"
exit 0
// advertisement

Evaluating Modern Tools Against the Philosophy

The philosophy gives you an objective framework for evaluating any tool before you adopt it. Apply four questions:

1. What is the one thing this tool does? If you cannot answer in one sentence, the tool is doing too much. 2. Does it read stdin and write stdout in a parseable format? 3. Does it exit with meaningful codes? 4. Can you use it without running a daemon?

Go through the tools in modern DevOps stacks:

`ripgrep` (rg 14.x): does one thing (search text with regex), writes text output, exits 0/1, works without a daemon. Passes all four.

`fd` (fd-find 10.x): does one thing (find files), writes text output, composable with xargs. Passes.

`fzf` 0.54: interactive selection from stdin. It is stateful and interactive, so questions 2 and 4 are context-dependent. But it accepts stdin and writes stdout, which means it composes. Pragmatic pass.

`Prometheus` + `alertmanager`: Prometheus does one thing (scrape metrics, store them, serve queries). Alertmanager does one thing (route alerts). Two programs. Passes when used as designed. Fails when teams start using Prometheus for log storage or long-term archival.

`Kubernetes`: fails questions 1 and 4 immediately. It does not do one thing. It is a platform, not a tool. This is not a criticism - platforms are legitimate - but do not evaluate Kubernetes using the Unix philosophy. It is a different class of system.

The failure mode for violated philosophy is always the same: the tool accumulates responsibilities over time, each new responsibility introduces new failure modes, operational complexity grows faster than value, and eventually you replace it.

# Testing composability of any new tool:
echo 'test input' | newtool --flag value | head -5
newtool --help 2>&1 | grep -E '(stdin|stdout|exit)'
newtool nonexistent_file; echo "Exit code: $?"

The Rule of Silence and What Your Tools Should Not Say

Eric Raymond's expansion of the philosophy includes the Rule of Silence: when a program has nothing surprising to say, it should say nothing. This is operationally critical and routinely violated.

A tool that prints 40 lines of startup logging, deprecation warnings, and progress indicators to stdout cannot be used in a pipeline without heavy filtering. Every unnecessary line is noise that downstream tools must ignore. Worse, if warnings go to stdout instead of stderr, they corrupt structured output.

The correct separation: useful output to stdout, diagnostics and errors to stderr, nothing at all on success unless the user asked for verbosity.

`rsync` gets this right. Silent on success by default, verbose with `-v`, progress with `--progress`, machine-readable with `--out-format`. You choose your verbosity level. The silent default means `rsync` is pipeline-friendly.

Many modern tools get this wrong. Terraform prints plan output to stdout and errors to stdout, mixing them. Docker used to print pull progress to stdout (it now uses stderr). npm prints warnings, lifecycle scripts, and timing information to stdout by default.

When writing your own tools: print results to stdout, print nothing else unless `-v` or `--verbose` is passed, print all diagnostic output to stderr. Test your tool in a pipeline before releasing it:

# Test stdout/stderr separation:
yourtool 2>/dev/null | wc -l   # only real output
yourtool 1>/dev/null            # only errors/warnings visible

# Check what a tool sends where:
yourtool > /tmp/stdout.txt 2> /tmp/stderr.txt
diff /dev/null /tmp/stderr.txt  # should be empty on success

Long-Running Processes and the Philosophy

Daemons seem to violate the philosophy - they run indefinitely, maintain state, and do not fit cleanly into pipelines. But the philosophy accommodates them through interface design.

A well-designed daemon does one thing (serve HTTP, manage processes, handle DNS) and exposes a composable interface through signals, control sockets, or HTTP endpoints that return text or JSON.

`nginx` does one thing (serve HTTP/HTTPS). It reloads config on SIGHUP. It exposes metrics via `stub_status` as plain text. Its logs go to files in text format. You can interact with it entirely through composable tools:

A daemon that requires a proprietary management CLI, outputs binary health status, and cannot be queried without an agent has opted out of composability. You will feel this pain every time you try to monitor it, automate it, or integrate it with other systems.

The practical test for daemon design: can I get the current status of this daemon with `curl` or a standard socket tool? Can I control it with `kill` signals or a simple socket protocol? If yes, it respects the philosophy at the interface level even if the internals are complex.

# nginx stub_status - plain text, grep-friendly:
curl -s http://localhost/nginx_status
# Output:
# Active connections: 42
# server accepts handled requests
#  1000 1000 5000
# Reading: 0 Writing: 1 Waiting: 41

# Parse with standard tools:
curl -s http://localhost/nginx_status \
  | awk '/Active/ {print $3}'

# Reload without restart:
kill -HUP $(cat /var/run/nginx.pid)
// advertisement