Interactive editors like Vim and Nano are great for hands-on work, but a lot of real editing needs are actually programmatic: replace this string across a hundred files, insert a line after every match of a pattern, delete a specific line number, comment out a block. Doing that by hand doesn’t scale. A Bash file editing tool built around sed, awk, and careful in-place handling covers this gap completely.
This article builds a general-purpose editing utility supporting find-and-replace, line insertion/deletion, and safe batch editing across many files at once.
Core Editing Operations
A practical editing tool needs to support at minimum:
- Find and replace a string or pattern.
- Insert a line at a specific position or after a pattern match.
- Delete a line by number or pattern match.
- Append/prepend content to a file.
Each of these maps cleanly onto sed, with awk as a fallback for more complex logic.
Step 1: Find and Replace
#!/usr/bin/env bash
set -euo pipefail
FILE="$1"
FIND="$2"
REPLACE="$3"
cp "$FILE" "${FILE}.bak"
sed -i "s/${FIND//\//\\/}/${REPLACE//\//\\/}/g" "$FILE"
echo "Replaced '$FIND' with '$REPLACE' in $FILE (backup: ${FILE}.bak)"
How It Works
sed -i "s/find/replace/g"performs a global (g) substitution in place.${FIND//\//\\/}is a parameter expansion that escapes any literal/characters in the search term, since/issed‘s default delimiter and would otherwise break the substitution syntax if present in the search or replace string.- A
.bakcopy is made before any modification, since find-and-replace is one of the easiest operations to get subtly wrong (over-matching, under-escaping).
Example:
./edit.sh config.txt "localhost" "127.0.0.1"
Step 2: Using a Different Delimiter for Paths
Search-and-replace involving file paths (which contain /) gets awkward with the default delimiter. Using an alternate delimiter like | avoids the escaping problem entirely:
sed -i "s|${FIND}|${REPLACE}|g" "$FILE"
This is simpler and more readable whenever the search or replace text is likely to contain slashes, such as paths or URLs.
Step 3: Inserting a Line
Inserting after a specific line number:
insert_after_line() {
local file="$1" line_num="$2" text="$3"
sed -i "${line_num}a\\${text}" "$file"
}
Inserting after a pattern match:
insert_after_pattern() {
local file="$1" pattern="$2" text="$3"
sed -i "/${pattern}/a\\${text}" "$file"
}
sed‘sa\command appends text after the addressed line — either a literal line number or a/pattern/address.- This is useful for tasks like adding a new environment variable after a specific line in a config file, or inserting a new entry into a structured list.
Step 4: Deleting Lines
By line number:
sed -i "${LINE_NUM}d" "$FILE"
By pattern match:
sed -i "/${PATTERN}/d" "$FILE"
By line range:
sed -i "${START},${END}d" "$FILE"
Each variant addresses sed‘s d (delete) command differently — a single line number, a pattern, or an inclusive numeric range.
Step 5: Appending and Prepending Content
append_to_file() {
local file="$1" text="$2"
echo "$text" >> "$file"
}
prepend_to_file() {
local file="$1" text="$2"
echo "$text" | cat - "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
}
Appending is trivial with >>. Prepending requires more care: there’s no native “insert at the top” redirect, so the new content is piped through cat - (which reads stdin first) followed by the original file, writing the combined result to a temp file before replacing the original.
Step 6: A Unified Editing Script with Subcommands
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $0 <command> <file> [args]
Commands:
replace <file> <find> <replace>
insert-after-line <file> <line_num> <text>
insert-after-pattern <file> <pattern> <text>
delete-line <file> <line_num>
delete-pattern <file> <pattern>
append <file> <text>
prepend <file> <text>
EOF
exit 1
}
CMD="${1:-}"
[[ -z "$CMD" ]] && usage
FILE="${2:-}"
[[ -z "$FILE" || ! -f "$FILE" ]] && { echo "File not found: $FILE"; exit 1; }
cp "$FILE" "${FILE}.bak"
case "$CMD" in
replace)
FIND="$3"; REPLACE="$4"
sed -i "s|${FIND}|${REPLACE}|g" "$FILE"
;;
insert-after-line)
LINE_NUM="$3"; TEXT="$4"
sed -i "${LINE_NUM}a\\${TEXT}" "$FILE"
;;
insert-after-pattern)
PATTERN="$3"; TEXT="$4"
sed -i "/${PATTERN}/a\\${TEXT}" "$FILE"
;;
delete-line)
LINE_NUM="$3"
sed -i "${LINE_NUM}d" "$FILE"
;;
delete-pattern)
PATTERN="$3"
sed -i "/${PATTERN}/d" "$FILE"
;;
append)
TEXT="$3"
echo "$TEXT" >> "$FILE"
;;
prepend)
TEXT="$3"
echo "$TEXT" | cat - "$FILE" > "${FILE}.tmp" && mv "${FILE}.tmp" "$FILE"
;;
*)
usage
;;
esac
echo "Done: $CMD applied to $FILE (backup: ${FILE}.bak)"
Example usage:
./edit.sh replace app.conf "debug=false" "debug=true"
./edit.sh insert-after-pattern app.conf "^\[server\]" "timeout=30"
./edit.sh delete-pattern app.conf "^#.*deprecated"
Step 7: Batch Editing Across Multiple Files
Wrapping the above into a batch loop extends it to entire directories:
#!/usr/bin/env bash
set -euo pipefail
DIR="$1"
FIND="$2"
REPLACE="$3"
PATTERN_GLOB="${4:-*}"
find "$DIR" -type f -name "$PATTERN_GLOB" | while read -r file; do
cp "$file" "${file}.bak"
sed -i "s|${FIND}|${REPLACE}|g" "$file"
echo "Updated: $file"
done
./batch-edit.sh ./src "old-api.example.com" "new-api.example.com" "*.js"
This finds every .js file under ./src and applies the same replacement, backing up each file individually before modifying it.
Real-World Use Cases
- Config migrations: Update a setting across every environment config file when a service endpoint changes.
- Bulk refactoring support: Rename a deprecated function call across a codebase as a first pass before manual review (paired with something like
grep -rlto find affected files). - Log template updates: Insert a new required field into structured log format strings across multiple services.
- License header updates: Prepend an updated copyright header to every source file in a project.
Automation Example: Version Bump Across Files
#!/usr/bin/env bash
set -euo pipefail
OLD_VERSION="$1"
NEW_VERSION="$2"
for file in package.json setup.py Cargo.toml VERSION; do
[[ -f "$file" ]] || continue
./edit.sh replace "$file" "$OLD_VERSION" "$NEW_VERSION"
done
echo "Version bumped from $OLD_VERSION to $NEW_VERSION"
This is a common release-automation pattern: bump a version string consistently across every file that declares it, in one script invocation.
Best Practices
- Always create a
.bakcopy before any in-place edit — this is the single most important habit for a tool like this. - Prefer
|as theseddelimiter when search/replace text might contain/(paths, URLs), rather than manually escaping every slash. - Test replacement patterns against a copy or with
grepfirst to confirm the expected matches before running the destructive edit. - Keep batch operations scoped tightly (specific file patterns, specific directories) rather than running broad edits across an entire filesystem.
Security Considerations
- Command injection through interpolated variables: Always quote
"$FILE","$FIND","$REPLACE"— unquoted variables in asedcommand line can be exploited if any of them originate from untrusted input (e.g., a web form feeding into this script). - Regex metacharacters in “plain” strings: If
$FINDor$REPLACEcome from user input rather than a hardcoded value, specialsedregex characters (.,*,[,],\) can cause unexpected matches; consider escaping them programmatically if literal-string matching is intended rather than regex matching. - Backup file exposure:
.bakfiles can retain sensitive content after the “real” file has been updated to remove it — clean these up if the edit is removing something sensitive (like a credential).
Optimization Tips
- For very large files,
sedstreams line-by-line and doesn’t require loading the entire file into memory, so it scales well without special handling. - Batch edits across many files benefit from
find ... -print0 | xargs -0to safely handle filenames containing spaces or newlines, rather than a plainwhile readloop, which can mishandle unusual filenames. - Combine
grep -rl "pattern" .withxargs sed -ifor a faster “find files containing X, then edit them” pipeline without a separate discovery step.
Troubleshooting
- “unterminated `s’ command” from sed: Usually means an unescaped delimiter character snuck into the find or replace string; switch delimiters or escape it explicitly.
- No changes applied but no error shown: The pattern likely didn’t match anything; verify with
grep "$PATTERN" "$FILE"before assuming the edit script is broken. - macOS
sed -ibehaves differently: BSDsed(default on macOS) requires an explicit backup suffix argument even when empty (sed -i '' ...), unlike GNUsed; scripts intended to be portable should detect this.
Common Mistakes
- Forgetting to escape
/in search/replace strings when using the defaultseddelimiter, silently corrupting the substitution. - Running a batch edit across an entire project without first confirming the match set with a dry-run
grep, resulting in unintended matches. - Not backing up before an edit, then discovering the pattern was broader than expected.
FAQs
Can this tool do a “dry run” to preview changes before applying them? Yes — swap sed -i for plain sed (without -i) to print the result to stdout without modifying the file, letting you review the diff first: diff <(sed 's|find|replace|g' file) file.
Does this handle multi-line replacements? Basic sed operates line-by-line by default; multi-line pattern matching requires more advanced sed scripting (the “hold space” pattern) or switching to perl -0777 -pe for cross-line regex support.
What’s the difference between using sed here versus awk? sed is ideal for line-oriented substitution and deletion. awk is better suited when the edit logic depends on field-based parsing (columns) or requires more complex conditional logic per line.
Summary
A general-purpose Bash editing tool built on sed covers the overwhelming majority of programmatic text-editing needs: substitution, insertion, deletion, and content prepending/appending — all with safe backup-before-edit discipline. Wrapped in subcommands and extended to batch operation across directories, it turns what would otherwise be one-off, error-prone manual edits into a repeatable, auditable process.
