sed Cheatsheet

In-Place Editing

Use this sed reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

-i is not portable, and this is why

-i is not in POSIX and the two implementations disagree about its argument.

InvocationGNU sedBSD / macOS sed
sed -i 's/a/b/' fedits f, no backupreads s/a/b/ as the suffix, then tries to run f as the script
sed -i '' 's/a/b/' ftakes '' as the script, then cannot read the file s/a/b/edits f, no backup
sed -i.bak 's/a/b/' fedits f, keeps f.bakedits f, keeps f.bak
sed -i'' -e 's/a/b/' fedits f, no backupedits f, leaves a backup named f-e
sed --in-place ...worksnot recognized

The error message when you get it wrong is misleading. GNU reports a missing file and BSD reports an undefined label, neither of which mentions -i.

sed -i 's/a/b/' t1        # BSD: sed: 2: "t1": undefined label '1'
gsed -i '' 's/a/b/' t7    # GNU: can't read s/a/b/: No such file or directory

Worse, GNU can fail silently. gsed -i '' file reports nothing and exits 0, because '' is a valid empty script and file is the operand, so it rewrites the file with itself.

What to write instead

Four options, in order of how much you should trust them in a script that other people run.

# 1. a suffix both accept, then remove the backup
sed -i.bak 's/a/b/' file && rm -f file.bak

# 2. a temp file, explicit and reviewable
sed 's/a/b/' file > file.tmp && mv file.tmp file

# 3. perl, identical everywhere and no suffix dance
perl -i -pe 's/a/b/' file

# 4. detect once, reuse
if sed --version >/dev/null 2>&1; then SEDI=(-i); else SEDI=(-i ''); fi
sed "${SEDI[@]}" 's/a/b/' file

Never do this. The shell truncates the redirect target before sed opens it, so the file is empty before a single byte is read:

sed 's/a/b/' file > file        # file is now 0 bytes, content gone

sponge from moreutils is the safe version of that shape:

sed 's/a/b/' file | sponge file

Backup suffixes

The suffix is appended, not inserted, so it needs its own dot if you want one. -i.bak gives file.bak on both platforms and is the one portable spelling.

sed -i.bak    's/a/b/' file    # file.bak, both
sed -i.orig   's/a/b/' file    # file.orig, both
gsed -i'*.orig' 's/a/b/' file  # file.orig, GNU: * is the original name
mkdir -p bak
gsed -i'bak/*' 's/a/b/' file   # backups collected in bak/, GNU only

- A GNU suffix containing * is a template, and * expands to the file name. The target directory must already exist or the rename fails. - BSD treats the suffix as a literal string with no * expansion. - A suffix with no leading dot is legal but easy to mistype into a label error, as gsed -i bak 's/a/b/' f shows, because the detached bak becomes the script.

What -i does to the file itself

-i is not an edit in place. Both seds write a new temporary file and rename it over the original. That has consequences.

PropertyPreserved by -i
permissionsyes, both platforms
ownershipyes when you own the file, root-owned needs root
hard linksno, the link is broken and the twin keeps the old content
symlinksno, the symlink is replaced by a regular file
inode numberno, it changes
extended attributesnot reliably
printf 'a\n' > h1 && ln h1 h2
gsed -i 's/a/b/' h1
cat h2                       # still "a", the files are no longer linked

gsed -i --follow-symlinks 's/a/b/' link   # GNU only, edits the target

--follow-symlinks is GNU only. On macOS, resolve the path yourself before editing:

target=$(readlink link || printf '%s' link)
sed -i.bak 's/a/b/' "$target" && rm -f "$target.bak"

Editing many files

One -i call takes any number of files, and each is edited independently. With -i, $ means the last line of each file on both platforms. Without -i, $ is the last line of the last file, unless you pass GNU -s.

sed -i.bak 's/a/b/' *.txt                 # every file in the dir
sed -i.bak 's/a/b/' f1 f2 f3              # explicit list

# recursive, portable, one sed process per batch
find . -name '*.md' -exec sed -i.bak 's/old/new/g' {} +
find . -name '*.md.bak' -delete

# GNU, recursive, skip binaries and .git
grep -rlZ --exclude-dir=.git 'old' . | xargs -0 gsed -i 's/old/new/g'

# ripgrep, only the files that actually match
rg -l 'old' | xargs sed -i.bak 's/old/new/g'

- Filter with grep -rl or rg -l first. Running -i over every file rewrites timestamps on files that did not change, which pollutes a git status and a build cache. - -exec ... {} + batches files into as few sed invocations as possible. {} \; runs one process per file and is far slower on large trees. - xargs without -0 breaks on paths containing spaces. Pair grep -rlZ with xargs -0.

Dry run first

sed has no --dry-run, so the diff is the dry run. Do this before every bulk edit.

# see exactly what would change, file by file
for f in *.txt; do
  diff -u "$f" <(sed 's/old/new/g' "$f")
done

# one-liner, whole tree
git ls-files -z | xargs -0 -I{} sh -c \
  'diff -q "{}" <(sed "s/old/new/g" "{}") >/dev/null || echo "{}"'

# or just edit, then review with git
sed -i.bak 's/old/new/g' *.txt && rm -f *.bak && git diff

Under version control, git diff plus git checkout -- is a better safety net than any backup suffix. Commit or stash before a bulk -i, then the undo is one command instead of a find -name '*.bak' cleanup.