The rename looked harmless: our config keys were moving from port to listen_port, and I had forty-one YAML files to update. One sed -i 's/port/listen_port/g' across the tree, commit, deploy to staging. Eleven minutes later staging was down and the deploy log was full of words that don't exist: suplisten_port, translisten_port, relisten_port. sed had done exactly what I asked — replaced the substring port everywhere it appeared, including inside support, transport, and report. The part I wasn't willing to repeat was explaining to the on-call why the incident channel was named #suplisten_port-escalations for an afternoon, because the rename had also walked through a JSON fixture that seeded our chat integration.
The problem is that sed has no idea what a word is unless you tell it. The s command matches substrings. That's the trap in almost every find-and-replace horror story: not the tool, the unanchored pattern, applied in place, with no dry run, across more files than you looked at.
Here is the same operation done so it can't bite.
The one-liner, done right
The basic form everyone knows:
s substitutes, g replaces every occurrence on the line instead of only the first, -i writes the change back to the file. Three refinements turn it from a loaded gun into a tool:
\b marks a word boundary on GNU sed, so the pattern only matches where port starts and ends as its own word. The delimiter after s is whatever character you put there — | and # are the usual picks when the pattern is a path, and they save you from the leaning-toothpick escape festival of s/\/var\/www\/.... And -i.bak leaves the original beside the edit, which costs nothing and has saved me more than once.
One portability landmine before the script: on macOS and BSD, -i requires an argument. sed -i 's/a/b/' file on a Mac doesn't edit in place — BSD sed consumes your s/// expression as the backup suffix and then fails on the filename with an error that mentions neither -i nor what you did wrong. The portable spelling is sed -i '' 's/a/b/' file on BSD, bare -i on GNU. A script that runs on both has to check.
A bulk replace that shows its work
The shape of the script is the lesson. grep -rl builds the list of files that genuinely contain the pattern, so sed never touches — never even rewrites the modification time of — a file with nothing to change. The default mode is the dry run: every affected file gets a diff of what would happen, and nothing happens until you come back with --apply. That two-pass habit is what catches the support problem while it's still a diff on your screen instead of a corrupted tree in git status.
Run it like this:
The replacement side has traps of its own. An unescaped & in the replacement expands to the entire matched pattern — s/error/& (fatal)/ turns error into error (fatal), which is occasionally what you want and usually a surprise. Escape it as \& when you mean a literal ampersand. Capture groups work the way you'd hope: sed -E 's/(user)_([0-9]+)/\2_\1/' swaps user_42 to 42_user.
Where sed stops
s/// operates line by line, so a pattern that spans a newline will never match — that's awk or perl -0pe territory. And if you're parsing structured formats (JSON especially), field-aware tools beat regex surgery: jq exists so you don't run sed on API responses. sed's home turf is exactly what this page covers: plain-text config, source trees, and logs, where line-oriented substitution is the natural unit of work.
The staging outage ended with git checkout -- . and the rename redone with \bport\b — total damage one afternoon and some dignity. The habit that stuck: sed edits are cheap, but reading the diff first is cheaper.
Finding the text before you replace it is half this job — searching files with grep covers the discovery side, and bash string manipulation handles the cases where the text is already in a variable and sed is overkill. The wider toolkit — awk, cut, sort, and where each one wins — is in the Bash Text Processing guide, and the Grep Pattern Builder will construct the anchored pattern for you interactively.
Run this script on a real Linux server
Get $200 free credit — DigitalOcean
Get $200 Free →Affiliate link · we earn a commission
Want a safe place to practice a tree-wide replace before doing it on anything real? A throwaway droplet with a cloned repo is the consequence-free sandbox. The rest of the library is at bashsnippets.xyz — start with error handling, since set -euo pipefail is the reason the script above stops on the first surprise instead of plowing through forty more files.