When a hostname, API base URL, or feature flag must change in dozens of files, opening each one in an editor is the slow path. The durable CLI pattern is a pipeline: find selects the paths, xargs turns that list into command arguments, and sed rewrites the contents. This article owns that composition — dry-runs, null-safe names, and in-place edits with backups — not every flag on each tool.
You do not need a novel of options on day one. Build a playground, list the files you intend to touch, preview the sed rewrite on stdout, then only afterward write changes with .bak files. The sections below walk that path with enough context that each stage feels intentional, not magical.
Warm-up: build a small playground
Create a tiny project that looks like production noise: configs, source, a dependency folder you must skip, and at least one filename with a space.
rm -rf batch-demo
mkdir -p batch-demo/{src,config,docs,node_modules/pkg}
cat > batch-demo/src/app.js <<'EOF'
const API = "https://api.example.com/v1";
console.log("talking to api.example.com");
EOF
cat > batch-demo/config/app.conf <<'EOF'
host=api.example.com
env=prod
# DEPRECATED: remove in next release
legacy_mode=true
EOF
cat > batch-demo/config/staging.conf <<'EOF'
host=api.example.com
env=staging
EOF
echo 'notes about api.example.com' > 'batch-demo/docs/my notes.txt'
echo 'should stay untouched' > batch-demo/node_modules/pkg/index.js
echo 'api.example.com in vendor' >> batch-demo/node_modules/pkg/index.js
Everything interesting lives under batch-demo/. Recreate this tree if a later in-place edit goes sideways and you want a clean slate.
Select files with find
Batch edits fail first by touching the wrong files. Start by listing candidates — type, name, and prune rules — before any sed runs.
Regular files only, skipping node_modules:
find batch-demo -path '*/node_modules/*' -prune -o -type f -print
Typical list:
batch-demo/src/app.js
batch-demo/config/app.conf
batch-demo/config/staging.conf
batch-demo/docs/my notes.txt
Narrow to configs:
find batch-demo -path '*/node_modules/*' -prune -o -type f -name '*.conf' -print
For anything that will feed xargs, prefer NUL-delimited output so spaces in my notes.txt stay one path:
find batch-demo -path '*/node_modules/*' -prune -o -type f -print0 | xargs -0 -n 1 echo
Note: Deep find predicates live in the find guide. Here the rule is simple: if the path should never be edited, prune it before the print action, not after a careless sed -i.
Bridge the list with xargs
find … -print on stdout is not a file argument list. Piping paths into sed without xargs makes sed treat the names as stream text (or wait on stdin) — it will not open those files for in-place edit. xargs appends each path as an argv element to the command you name.
Dry-run the bridge with echo so you see the constructed command:
find batch-demo -path '*/node_modules/*' -prune -o -type f -name '*.conf' -print0 \
| xargs -0 echo sed -i.bak 's/api.example.com/api.internal.net/g'
sed -i.bak s/api.example.com/api.internal.net/g batch-demo/config/app.conf batch-demo/config/staging.conf
Force one file per sed invocation with -n 1 or -I{} when you want clearer failures or a mid-command placeholder:
find batch-demo -path '*/node_modules/*' -prune -o -type f -name '*.conf' -print0 \
| xargs -0 -I{} echo sed -i.bak 's/api.example.com/api.internal.net/g' {}
Note: Habit for trees you do not fully control: -print0 on find and -0 on xargs, always. Flag details and parallelism live in the xargs guide.
Preview and apply with sed
Never jump straight to -i on a whole tree. Preview the substitution on one file’s stdout first:
sed 's/api.example.com/api.internal.net/g' batch-demo/config/app.conf
host=api.internal.net
env=prod
# DEPRECATED: remove in next release
legacy_mode=true
When the preview looks right, run the same expression in place with a backup suffix, driven by the null-safe find|xargs pipeline:
find batch-demo -path '*/node_modules/*' -prune -o -type f \( -name '*.conf' -o -name '*.js' -o -name '*.txt' \) -print0 \
| xargs -0 sed -i.bak 's/api.example.com/api.internal.net/g'
Confirm content and backups:
grep -R 'api.internal.net' batch-demo --exclude-dir=node_modules
ls batch-demo/config/*.bak batch-demo/src/*.bak
Vendor code under node_modules should still mention api.example.com. Restore a single file from its backup if needed:
mv batch-demo/config/app.conf.bak batch-demo/config/app.conf
Use a different delimiter when the pattern itself contains slashes:
sed 's|https://api.example.com/v1|https://api.internal.net/v1|g' batch-demo/src/app.js
Note: Substitution grammar and BRE vs ERE live in the sed guide. On this pipeline, the non-negotiable habit is -i.bak (or a git commit) before bulk writes — bare -i across a tree is how one bad pattern rewrites a repo with no undo.
Full pipelines you will reuse
Recreate the playground if you already mutated it (rm -rf batch-demo and rerun the warm-up). Then try these complete recipes.
1. Rename a token across configs and source (null-safe, with backups):
find batch-demo -path '*/node_modules/*' -prune -o -type f \( -name '*.conf' -o -name '*.js' \) -print0 \
| xargs -0 sed -i.bak 's/api.example.com/api.internal.net/g'
2. Delete deprecated lines only in *.conf files:
Dry-run on one file:
sed '/DEPRECATED/d' batch-demo/config/app.conf
Apply across configs:
find batch-demo -type f -name '*.conf' -print0 \
| xargs -0 sed -i.bak '/DEPRECATED/d'
3. Address-limited rewrite — change host= only in staging configs:
find batch-demo -type f -name 'staging.conf' -print0 \
| xargs -0 sed -i.bak '/^host=/s/api.example.com/api.staging.internal.net/'
When find -exec is enough: a single action on a short list can skip xargs — find … -exec sed -i.bak 's/a/b/' {} +. Prefer the find|xargs form when you already dry-run with echo, need -I{}, or compose with other argv tools. Both are valid; pick the one the surrounding script reads more clearly.
Safety checklist
Run bulk edits in this order every time:
- List —
find … -print(or-print0 | xargs -0 -n 1 echo) and read the paths. - Preview —
sed 's/…/…/' one-fileon stdout; fix the expression before loops. - Dry-run the argv —
… | xargs -0 echo sed -i.bak '…'. - Apply with backups — drop the
echo, keep-i.bak(or commit to git first). - Verify —
grep -Rfor the new and old tokens; spot-check that pruned dirs were spared. - Rollback —
mv file.bak file, orgit checkout -- ., or an rsync copy you took beforehand.
Note: If the tree is already in git, a commit (or stash) before step 4 beats a pile of .bak files. Use backups anyway on machines where the files are not versioned.
Quick reference card
Keep these pipelines nearby until the composition is muscle memory:
| Goal | Pipeline |
|---|---|
| List edit targets | find dir -path '*/node_modules/*' -prune -o -type f -print |
| Null-safe echo dry-run | find … -print0 | xargs -0 echo sed -i.bak 's/a/b/' |
| Preview one file | sed 's/a/b/g' file |
| In-place + backup | find … -print0 | xargs -0 sed -i.bak 's/a/b/g' |
| One file per sed | … | xargs -0 -n 1 sed -i.bak 's/a/b/' |
| Placeholder form | … | xargs -0 -I{} sed -i.bak 's/a/b/' {} |
| Delete matching lines | … | xargs -0 sed -i.bak '/pat/d' |
| Slash-heavy pattern | sed 's|/old|/new|g' file |
| Verify | grep -R 'new-token' dir --exclude-dir=node_modules |
Practice drills
Use batch-demo (recreate from the warm-up if needed). Try these without peeking. The point is to choose selection, bridging, and edit steps with intent.
- List every regular file under
batch-demoexcept anything insidenode_modules. - Dry-run (with
echo) a sed in-place that would replaceprodwithproductionin*.conffiles only. - Preview on stdout a global replace of
api.example.com→api.internal.netinsrc/app.js. - Apply that replace across
*.conf,*.js, and*.txt, null-safe, with.bakbackups, skippingnode_modules. - Remove lines containing
DEPRECATEDfrom all*.conffiles (with backups).
When you are ready to compare, here are solid answers — not the only ones, but clear and portable:
find batch-demo -path '*/node_modules/*' -prune -o -type f -print
find batch-demo -type f -name '*.conf' -print0 \
| xargs -0 echo sed -i.bak 's/prod/production/g'
sed 's/api.example.com/api.internal.net/g' batch-demo/src/app.js
find batch-demo -path '*/node_modules/*' -prune -o -type f \
\( -name '*.conf' -o -name '*.js' -o -name '*.txt' \) -print0 \
| xargs -0 sed -i.bak 's/api.example.com/api.internal.net/g'
find batch-demo -type f -name '*.conf' -print0 \
| xargs -0 sed -i.bak '/DEPRECATED/d'
If you can work through those five comfortably, you already cover most real batch-edit work: select with find, bridge with xargs, rewrite with sed, and refuse to skip dry-runs and backups. Start with the file list, preview one substitute, then scale the same expression across the tree only when the preview matches the plan.