✂️ sed Guide

A how-to manual: how sed works, substitute, addresses, regular expressions, editing files in place, multi-line scripts, config files, safe use in scripts, portability and speed, with every example run for real.

Video: sed (Command Line Tips video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
sed: the stream editor. It reads text a line at a time, changes it, and writes it out.
sed 's/old/new/g' file.txt
sed -i 's/^PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -n '/ERROR/p' app.log

sed is for the changes you make a hundred times: replace this word, delete these lines, comment that setting out, pull one field from a log. It has been in every Linux install since the 1970s, it needs no setup, and it works on pipes as well as on files, so it sits in the middle of shell scripts. Its weakness is the same as its strength: a script is a few characters of dense code, and a wrong character quietly does something else.

This guide goes from the one command everyone uses (s///) to the parts that cause incidents: -i on a symbolic link, a shell variable pasted into a pattern, a range that never ends, an edit that is applied twice. For structured files use the right tool instead: jq for JSON and yq for YAML (both have guides here); the last section says when to stop using sed.

Every example below ran for real, on Ubuntu 24.04 with GNU sed 4.9 (package sed 4.9-2ubuntu0.24.04.1), against the sample files shown in the next section. BusyBox's sed 1.36.1 was run as well where the guide compares them. The sed on macOS and the BSDs is a different program; the points where it differs are marked as not run.

What sed is, which one you have, and the sample data

sed reads its input one line at a time, runs a small script of commands on that line, prints the result, and moves on. It never loads the whole file (unless you ask it to), so it works on a log of any size. It does not change a file unless you say -i, and without -i it prints to the screen, which makes it safe to try.

$ sed --version | head -1
sed (GNU sed) 4.9
$ dpkg -s sed | grep ^Version
Version: 4.9-2ubuntu0.24.04.1

Ubuntu has GNU sed, which has many extensions beyond the POSIX standard: -i, -E, -z, --debug, the \U and \L case changes in the replacement, the first~step and addr,+N addresses, and special characters such as \t, \n and \b. Most examples online assume it. Two others you will meet:

BusyBox sed     a small version for containers and embedded systems (Alpine, initramfs). It has -i, -E, \t and \b, but not the extensions listed above that need the GNU code (see the portability section).
BSD sed         the sed on macOS and FreeBSD. -i takes a mandatory suffix argument there, and several GNU extensions are missing. Not run in the lab.

The examples use these files. fruit.txt is six short lines:

$ cat fruit.txt
apple pie
banana split
cherry tart
apple crumble
date cake
elderberry wine

app.log is a small application log:

$ cat app.log
2026-10-17 02:00:01 INFO  api  request ok user=ann ip=10.0.1.11
2026-10-17 02:00:02 INFO  api  request ok user=ed ip=10.0.1.12
2026-10-17 02:00:03 WARN  api  slow request 910ms user=ann ip=10.0.1.11
2026-10-17 02:00:04 ERROR pay  card declined user=kim ip=10.0.2.31
2026-10-17 02:00:05 INFO  web  GET /index.html 200 ip=10.0.3.5
2026-10-17 02:00:06 ERROR pay  upstream timeout user=ed ip=10.0.1.12
2026-10-17 02:00:07 INFO  web  GET /missing 404 ip=10.0.3.6

sshd_config is a small settings file (shown where it is used), and n.txt holds the numbers 1 to 10 (made with seq 1 10).

How sed works: the cycle and the pattern space

Everything else follows from one idea. For each input line sed does this:

1. Read the line into a buffer called the pattern space (without its newline).
2. Run the script on it, command by command.
3. At the end of the script, print the pattern space (unless you used -n), then start again with the next line.

So sed 's/apple/APPLE/' fruit.txt reads six lines, changes the matching ones, and prints all six. It prints every line because auto-print is on:

$ sed 's/apple/APPLE/' fruit.txt | head -2
APPLE pie
banana split

-n turns auto-print off. Then only the lines you print yourself with p come out:

$ sed -n '2p' fruit.txt
banana split
$ sed '2p' fruit.txt | head -3                  (without -n, line 2 appears twice)
apple pie
banana split
banana split

The two most common mistakes are in these two lines: forgetting -n when you use p (every line printed, the wanted one twice), and using -n and forgetting p (nothing printed):

$ sed -n 's/apple/X/' fruit.txt | wc -l
0
$ sed -n 's/apple/APPLE/p' fruit.txt              (the p flag of s prints lines where it replaced)
APPLE pie
APPLE crumble

A second buffer, the hold space, is where a script can put things aside between lines. It starts empty and is used by h, H, g, G and x, shown in the multi-line section. A script is also not limited to one line: N, n, D and P let it look at more than one at a time.

The line number counts across all the input files, unless you use -s or -i (see the options and the in-place sections).

The options you will use

-n, --quiet            do not print the pattern space at the end of the cycle
-e SCRIPT              add a command; repeat it for several (or separate commands with ; or a newline)
-f FILE                read commands from a file
-E, -r                 extended regular expressions: ( ) { } + ? | work without backslashes
-i[SUFFIX]             edit files in place, with an optional backup suffix
-s, --separate         treat each file separately (line numbers restart, $ is each file's last line)
-z, --null-data        lines end with NUL instead of newline, so a whole text file is one "line"
--debug                print the parsed script and show every command as it runs
--posix                turn off the GNU extensions
--sandbox              refuse the e, r and w commands (use this when the script is not yours)
--follow-symlinks      with -i, edit the target of a symbolic link instead of replacing the link
-u                     unbuffered: read and write as little as possible (for live pipes)
-l N                   line length for the l command

Several commands can be given in these ways, and all of them gave the same result:

$ sed -e 's/a/b/' -e 's/b/c/' <<< 'a'
c
$ sed 's/a/b/;s/b/c/' <<< 'a'
c
$ printf 's/apple/X/\ns/pie/Y/\n' > s.sed; sed -f s.sed fruit.txt | head -2
X Y
banana split

Use single quotes around the script so the shell does not touch $, ` and \. Double quotes are only for the cases where you want the shell to put a variable in, which is dangerous (see the scripts section).

-i with a suffix keeps a copy. The suffix follows the -i with no space: -i.bak. A common mistake is -ie, meaning "-i and -e". sed reads the e as the suffix:

$ sed -ie 's/a=9/a=0/' f2; ls
f1  f1.bak  f2  f2e  f3                     (a stray backup called f2e)
$ sed -i -e 's/a=0/a=5/' f2                   (this is the right way to write it)

-s and the line number. By default the addresses count across all the files, and $ means the end of the last one. With -s each file is separate:

$ sed -n '$p' f1 f2 | tr '\n' ' '                (the last line of the last file)
b=2
$ sed -s -n '$p' f1 f2 | tr '\n' ' '             (the last line of each)
b=7 b=2

F prints the current file name, and = prints the line number, which helps when you debug.

Substitute, in depth

s/regex/replacement/flags is the command you will use ninety percent of the time. The first match on each line is replaced; add flags to change that:

g        every match on the line
N        only the Nth match (2 is the second)
Ng       the Nth and all after it
i or I   ignore case (GNU)
p        print the line if a replacement happened (use with -n)
w FILE   write the changed lines to a file
m or M   multi-line mode: ^ and $ match at each embedded newline (GNU)
e        run the result as a shell command (GNU; dangerous, see the scripts section)

$ echo 'a a a a' | sed 's/a/X/2'
a X a a
$ echo 'a a a a' | sed 's/a/X/2g'
a X X X
$ echo 'Hello hello' | sed 's/hello/X/Ig'
X X

Delimiters. The character after the s is the separator, and it can be anything. When the text has slashes (paths, URLs) pick a different one, and you will not have to put a backslash before every slash:

$ echo '/usr/local/bin' | sed 's|/usr/local|/opt|'
/opt/bin
$ echo '/usr/local/bin' | sed 's#/usr/local#/opt#'
/opt/bin
$ echo '/usr/local/bin' | sed 's/\/usr\/local/\/opt/'          (the hard way)
/opt/bin

Groups, & and back references. In the replacement, & is the whole match and \1 to \9 are the captured groups. In basic regex (the default) a group is written \( \); with -E it is ( ):

$ echo 'one two' | sed 's/\(one\) \(two\)/\2 \1/'
two one
$ echo 'one two' | sed -E 's/(one) (two)/\2 \1/'
two one
$ echo 'one two' | sed 's/.*/[&]/'
[one two]
$ echo 20261017 | sed -E 's/(....)(..)(..)/\1-\2-\3/'
2026-10-17

To write a literal &, put a backslash before it: s/x/R\&D/. A bare & in a replacement is the match, which surprised the lab when a shell variable held R&D:

$ NEW='R&D'; echo 'x' | sed "s/x/$NEW/"
RxD

Changing case (GNU). \U and \L change the rest to upper or lower case, \u and \l change only the next character, \E ends it:

$ echo 'hello world' | sed 's/\w\+/\u&/g'
Hello World
$ echo 'hello world' | sed 's/.*/\U&/'
HELLO WORLD
$ echo 'hello world' | sed -E 's/(\w+) (\w+)/\U\1\E \2/'
HELLO world
$ echo 'hello world' | sed -E 's/(\w)(\w*)/\U\1\L\2/g'
Hello World

Newlines and tabs in the replacement work in GNU sed:

$ echo 'a b' | sed 's/ /\n/'
a
b
$ echo 'x' | sed 's/x/a\tb/' | cat -A
a^Ib$

Empty matches. A regex that can match nothing matches everywhere, between the characters, and the lab shows what that does:

$ echo 'abc' | sed 's/x*/-/g'
-a-b-c-
$ echo 'abc' | sed 's/b*/X/g'
XaXcX

The empty regex s//new/ reuses the last regex that was used, which is useful in an address-then-substitute:

$ sed '/apple/s//X/' fruit.txt | head -1
X pie

Counting what changed. The p flag with -n shows only the changed lines. w /dev/stdout does the same and also works inside a block:

$ sed -n 's/apple/APPLE/w /dev/stdout' fruit.txt
APPLE pie
APPLE crumble
$ sed 's/a/A/g' fruit.txt | diff fruit.txt - | grep -c '^>'
5

Addresses: choosing the lines

A command can have an address in front of it. Without one it runs on every line; with one it runs only on the lines that match. The forms:

N                a line number (counts across all files)
$                the last line
/regex/          lines that match (add I to ignore case: /error/I)
first~step       GNU: every step-th line from first (0~3 is 3, 6, 9)
addr1,addr2      a range, from the first address to the second
addr1,+N         the line and the N lines after it
addr1,~N         the line up to the next multiple of N
0,/regex/        GNU: a range that can end on the very first line
addr!            invert: lines that do NOT match

$ sed -n '2,4p' fruit.txt
banana split
cherry tart
apple crumble
$ sed -n '0~3p' n.txt | tr '\n' ' '
3 6 9
$ sed -n '2~4p' n.txt | tr '\n' ' '
2 6 10
$ sed -n '3,+2p' n.txt | tr '\n' ' '
3 4 5
$ sed -n '4,~4p' n.txt | tr '\n' ' '
4 5 6 7 8
$ sed -n '/error/Ip' app.log | cut -c1-60
2026-10-17 02:00:04 ERROR pay  card declined user=kim ip=10.
2026-10-17 02:00:06 ERROR pay  upstream timeout user=ed ip=1
$ sed -n '2!p' n.txt | tr '\n' ' '
1 3 4 5 6 7 8 9 10
$ sed -n '2,4!p' n.txt | tr '\n' ' '
1 5 6 7 8 9 10

Rules for ranges that matter in practice (all run in the lab):

1. A range is checked line by line. When the first address matches, the range is on, and it ends at the first line that matches the second address. After that it can start again. With regexes this means every start gets its own range:

$ sed -n '/[13]/,/[24]/p' n.txt | tr '\n' ' '
1 2 3 4 10

Here 1 starts a range that ends at 2, 3 starts one that ends at 4, and 10 contains a 1 and starts a range that never ends (the file is over).

2. If the second address is a line number that is not after the first, only the one line is selected: sed -n '5,2p' printed just 5.

3. The end regex is first checked on the line after the start, not on the start line itself. So /INFO/,/INFO/ is not a one-line range. To have a range that can end on line 1, use 0,/regex/ (GNU):

$ sed -n '1,/INFO/p' app.log | cut -c1-30          (the end is looked for from line 2)
2026-10-17 02:00:01 INFO  api
2026-10-17 02:00:02 INFO  api
$ sed -n '0,/INFO/p' app.log | cut -c1-30          (it can end on line 1)
2026-10-17 02:00:01 INFO  api

0,/regex/ is how you change only the first match in a file:

$ sed '0,/apple/s//APPLE/' fruit.txt | head -4
APPLE pie
banana split
cherry tart
apple crumble

4. A range whose end never matches runs to the end of the file. If the marker may be missing, test with grep first.

Combine an address and a block with braces to run several commands on those lines:

$ sed '2,3{s/a/A/;s/e/E/}' fruit.txt | head -4
apple pie
bAnana split
chErry tArt
apple crumble
$ sed -n '/apple/{=;p}' fruit.txt
1
apple pie
4
apple crumble

Stop early. q quits after printing the current line and Q quits without printing it. A long log is not read to the end:

$ sed '3q' n.txt | tr '\n' ' '
1 2 3
$ sed '3Q' n.txt | tr '\n' ' '
1 2
$ sed -n '/5/{p;q}' n.txt
5
p       print            d       delete the line and start the next cycle
q, Q    quit (Q without printing)     =       print the line number
a, i, c append after, insert before, change (replace) the line
y/abc/xyz/   transliterate, like tr   l       print "unambiguously" (shows tabs and line ends)
r FILE  append the contents of a file   w FILE   write the line to a file
n, N    next line: replace, or append, the pattern space      D, P   delete or print up to the first newline
h, H, g, G, x   hold space commands      b, t, T   branch to a label (loops and conditions)

Deleting.

$ sed '/apple/d' fruit.txt
banana split
cherry tart
date cake
elderberry wine
$ sed '2,9d' n.txt | tr '\n' ' '
1 10
$ sed '/^$/d;/^#/d' sshd_config                   (drop blank lines and comment lines)
Port 22
PermitRootLogin yes
PasswordAuthentication yes
UsePAM yes

Append, insert and change. In GNU sed the text can follow the command on the same line (the old style puts it after a backslash and newline, and that form is the portable one):

$ sed '2a\--- after ---' fruit.txt | head -4
apple pie
banana split
--- after ---
cherry tart
$ sed '2i\--- before ---' fruit.txt | head -4
apple pie
--- before ---
banana split
cherry tart
$ sed '2c\REPLACED' fruit.txt | head -4
apple pie
REPLACED
cherry tart
apple crumble
$ sed '1i\header' fruit.txt | head -2
header
apple pie
$ sed '$a\footer' fruit.txt | tail -2
elderberry wine
footer

Two details. c on a range replaces the whole range with one copy of the text, not one per line, unlike a block with s:

$ sed '2,3c\ONE' fruit.txt
apple pie
ONE
apple crumble
date cake
elderberry wine

And a leading space or tab in the text of a/i/c is removed unless the first character is a backslash: 2a\  indented keeps the two spaces.

Line numbers, transliteration and visible characters:

$ sed = fruit.txt | sed 'N;s/\n/\t/' | head -3         (number the lines)
1	apple pie
2	banana split
3	cherry tart
$ sed 'y/abc/ABC/' fruit.txt | head -2
Apple pie
BAnAnA split
$ sed -n 'l' <<< 'tab	here\ end'                     (l shows the tab as \t and the backslash doubled)
tab\there\\ end$
$ sed -n 'l 10' <<< 'a long line of text here'          (wrap at 10 characters)
a long li\
ne of tex\
t here$

Reading and writing files. r appends a file after the line (the example is inserted after line 2); w writes the lines that reach it to a file:

$ printf 'one\ntwo\nthree\n' | sed '2r fruit.txt' | head -5
one
two
apple pie
banana split
cherry tart
$ sed -n '/apple/w apples.txt' fruit.txt; cat apples.txt
apple pie
apple crumble

Next line. n prints the pattern space, then reads the next line into it. It lets a script work on pairs of lines:

$ sed 'n;d' n.txt | tr '\n' ' '                      (delete every second line)
1 3 5 7 9
$ sed -n 'n;p' n.txt | tr '\n' ' '                   (print every second line)
2 4 6 8 10

Regular expressions in sed

sed uses POSIX regular expressions in two flavours. The default is the basic one (BRE), where the characters ? + | ( ) { } are literal and the special versions have a backslash. With -E (or -r) it is the extended one (ERE), where it is the other way round:

BRE (default)                    ERE (-E)
\( \)   group                    ( )
\{2,4\} repeat                   {2,4}
\+  \?  \|                       +  ?  |
a literal +                      \+

$ echo 'aab' | sed 's/a\+b/X/'          BRE with a backslash
X
$ echo 'aab' | sed -E 's/a+b/X/'        ERE without
X
$ echo 'aab' | sed -E 's/a\+b/X/'       ERE: \+ is now a literal plus, so nothing matches
aab
$ echo 'a+b' | sed 's/a+b/X/'           BRE: a plain + is a literal
X

Use -E whenever a pattern has groups or alternatives: the lines are shorter and the mistakes are fewer. It makes no other difference.

The parts of a regex that you will use:

.            any one character (and so a dot that must be a dot needs \. or [.])
*            zero or more of the previous item
^ $          start and end of line (anchors only where they stand at the start and the end of the pattern)
[abc] [^abc] [a-z] [0-9]    a set, a negated set, ranges
[[:space:]] [[:digit:]] [[:alpha:]] [[:upper:]]    named sets (they work in the locale)
\w \W \s \S  word and space characters (GNU)
\b \< \>     word boundary, start of word, end of word (GNU)
\n \t        newline, tab (GNU; in a set, put the real character)
.* is greedy: it takes as much as it can

$ echo 'a.b.c' | sed 's/./-/g'
-----
$ echo 'a.b.c' | sed 's/\./-/g'
a-b-c
$ echo 'a.b.c' | sed 's/[.]/-/g'
a-b-c
$ echo 'foobar bar' | sed 's/\bbar/X/g'
foobar X
$ echo 'foo bar' | sed 's/\<bar\>/X/'
foo X
$ echo 'foo123bar' | sed -E 's/[0-9]{2,3}/#/'
foo#bar

Greedy matching. .* matches as much as possible, so it runs from the first < to the last >. Use a negated set to stop at the first one:

$ echo 'greedy <b>bold</b> <i>it</i>' | sed 's/<.*>//'
greedy
$ echo 'greedy <b>bold</b> <i>it</i>' | sed 's/<[^>]*>//g'
greedy bold it

sed has no non-greedy .*? and no lookahead. When you need them use perl -pe (see the last section).

Anchors and trimming. ^ and $ match at the start and the end of the pattern space, so they also trim whitespace:

$ echo '  trim me  ' | sed 's/^[ \t]*//;s/[ \t]*$//' | cat -A
trim me$
$ echo 'a   b' | sed 's/[[:space:]]\+/ /g'
a b

Special characters in the middle of a BRE are plain. A $ that is not at the end is a literal dollar, which is why s/$5/$6/ worked on the text price: $5 in the lab.

Locale. In a UTF-8 locale a . matches one character; in the C locale it matches one byte. Accented text proves the difference:

$ printf 'caf\xc3\xa9\n' | LC_ALL=C sed 's/./X/g'
XXXXX
$ printf 'caf\xc3\xa9\n' | LC_ALL=C.UTF-8 sed 's/./X/g'
XXXX

The Ubuntu container the lab used had no locale set, so it ran in the C locale. A desktop or server with LANG=en_US.UTF-8 behaves like the second line. The locale also changes the speed (see the last section).

Editing files in place with -i

-i makes sed write the result back to the file instead of the screen. Each file named is edited by itself (unlike yq's -i): the lab edited three files with one command and each got the change.

$ sed -i 's/a=1/a=9/' f1 f2 f3; cat f1 f2 f3 | tr '\n' ' '
a=9 b=2 a=9 b=2 a=9 b=2

How -i works matters, because it explains the surprises. sed writes a new temporary file next to the original, copies the permissions, and renames it over the original. Three consequences were measured in the lab:

1. The permissions are kept: a 640 file stayed 640. (The lab ran as an ordinary user, so keeping the owner was not tested; sed tries to keep it.)

2. A symbolic link is replaced by a regular file. The target is left unchanged, and the link is gone:

$ ln -s g1 glink; sed -i 's/a=1/a=2/' glink
$ ls -l glink g1 | cut -c1-20,40-
-rw-r--r-- 1 ubuntu 18 g1                   (the old target, still a=1)
-rw-r--r-- 1 ubuntu 18 glink                (now a plain file, with a=2)

With --follow-symlinks sed edits the target and the link stays:

$ sed -i --follow-symlinks 's/a=1/a=2/' hlink; ls -l hlink h1 | cut -c1-12,40-
-rw-r--r-- 118 h1
lrwxrwxrwx 118 hlink -> h1
$ cat h1
a=2

3. A hard link is broken. The other name keeps the old content, and the two names now have different inodes:

$ ln k1 k2; sed -i 's/a=1/a=2/' k1; cat k1 k2 | tr '\n' ' '
a=2 a=1

If the file you edit is a symlink or has another hard link (many files under /etc/alternatives and some package-managed files are), say so on purpose.

Backups. -i.bak keeps the original as file.bak. The suffix can also contain a *, which stands for the file name (-i'bak/*' puts backups in a folder that must exist; this form was not run).

$ sed -i.bak 's/b=2/b=7/' f1; ls; diff f1.bak f1
f1  f1.bak  f2  f3
2c2
< b=2
---
> b=7

-n with -i empties the file unless you print. The lab lost a file this way. The pattern did not match, so nothing reached p, and the new file was empty:

$ sed -n -i 's/a=5/A/p' f3; wc -c < f3
0

Only use -n -i when you mean to keep just the printed lines, and check the result.

Other points:
- sed -i on standard input is an error: echo hi | sed -i 's/h/H/' gives "sed: no input files" and exit status 4.
- A missing file is an error with status 2, but sed carries on to the next file.
- A file that does not end in a newline keeps that (GNU sed): it did not add one in the lab.
- Windows line ends: remove the carriage return with sed 's/\r$//' (it printed a, b with no ^M), and add it with sed 's/$/\r/'.
- sudo and redirection. sudo sed ... > file does the redirection as you, not as root. Use sudo sed -i, or sudo tee, to write a protected file (not run, it needs a real sudo setup).

Working safely. Test without -i first, look at the diff, then write. With a backup you can compare and restore:

$ diff <(cat fruit.txt) <(sed 's/apple/APPLE/g' fruit.txt)
1c1
< apple pie
---
> APPLE pie
4c4
< apple crumble
---
> APPLE crumble
$ sed -i.bak 's/^PermitRootLogin .*/PermitRootLogin no/' sshd_config && diff sshd_config.bak sshd_config
4c4
< PermitRootLogin yes
---
> PermitRootLogin no

Multi-line scripts: N, D, P and the hold space

A script that works on one line at a time cannot join lines, remove duplicates or reverse a file. Three commands fix that:

N       add the next line to the pattern space, with a newline between them
D       delete up to the first newline, and restart the cycle without reading new input if some text is left
P       print up to the first newline

The hold space is a second buffer for storing text across lines:

h  copy the pattern space to the hold space      H  append it
g  copy the hold space to the pattern space      G  append it
x  exchange the two

Joining lines in pairs. $!N means "append the next line, unless this is the last line":

$ sed '$!N;s/\n/ /' fruit.txt
apple pie banana split
cherry tart apple crumble
date cake elderberry wine

Joining all lines. A label is made with :name and jumped to with b. This loop appends lines until the last, then replaces all the newlines:

$ sed ':a;N;$!ba;s/\n/,/g' fruit.txt
apple pie,banana split,cherry tart,apple crumble,date cake,elderberry wine

The same result in GNU sed with -z, where the whole file is one "line" (its last newline became a comma too):

$ sed -z 's/\n/,/g' fruit.txt
apple pie,banana split,cherry tart,apple crumble,date cake,elderberry wine,

Reverse a file (like tac). Each line is added to the front of what was held, and only the last output is printed:

$ sed '1!G;h;$!d' fruit.txt
elderberry wine
date cake
apple crumble
cherry tart
banana split
apple pie

Double-space a file (G appends the empty hold space, so a blank line follows each line):

$ sed 'G' fruit.txt | head -4 | cat -A
apple pie$
$
banana split$
$

Remove consecutive duplicate lines (like uniq):

$ sed '$!N;/^\(.*\)\n\1$/!P;D' <<< $'a\na\nb\nb\nc'
a
b
c

Squeeze runs of blank lines to one:

$ printf 'a\n\n\n\nb\n' | sed '/^$/N;/\n$/D'
a

b

Print the last lines (like tail). The loop keeps a window of lines and drops the oldest:

$ sed -e :a -e '$q;N;3,$D;ba' fruit.txt | tr '\n' '|'
date cake|elderberry wine|

(The numbers must match: with 3,$D the window is two lines.)

Print what is between two markers, without the markers. The empty regex // reuses the last one used, so //!p means "not a marker":

$ sed -n '/start/,/end/{//!p}' <<< $'a\nstart\nb\nc\nend\nd'
b
c

Loops with t. t jumps when a substitution succeeded since the last input line or the last t. This adds thousands separators:

$ echo 1234567 | sed -E ':a;s/([0-9])([0-9]{3})(,|$)/\1,\2\3/;ta'
1,234,567

Always check that a loop ends. The s must eventually stop matching. A loop like :a;s/a/aa/;ta would run forever.

The M flag. In multi-line mode ^ and $ match at the embedded newlines, so a join followed by an anchored substitute works:

$ printf 'a\nb\n' | sed 'N;s/^b/X/M'
a
X
$ printf 'a\nb\n' | sed 'N;s/^b/X/'               (without M, ^ is only the start of the whole buffer)
a
b

These scripts are exactly why people reach for awk or perl. For anything harder than a pair of lines, consider whether a three-line awk program is easier to read.

Editing configuration files so that it can be repeated

The classic use. Settings files such as /etc/ssh/sshd_config have commented defaults and active lines, and a script has to be safe to run twice. The lab file:

$ cat sshd_config
# sshd settings
Port 22
#PermitRootLogin prohibit-password
PermitRootLogin yes
PasswordAuthentication yes
#X11Forwarding yes
UsePAM yes

Change a value. Anchor the pattern at the start of the line so a comment does not match, and keep the key:

$ sed -i 's/^PermitRootLogin .*/PermitRootLogin no/' sshd_config
$ grep -n PermitRoot sshd_config
3:#PermitRootLogin prohibit-password
4:PermitRootLogin no

Uncomment and set in one step. The pattern ^#\? means "an optional #". Here it showed the trap: the commented line and the real line both matched, and both became active, so the file has the setting twice:

$ sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' sshd_config
$ grep -n PermitRoot sshd_config
3:PermitRootLogin no
4:PermitRootLogin no

sshd_config(5) says the first value found is used for most keywords, so a duplicate with a different value can silently win or lose. Two ways to do it properly: change only the first match (on a file with just the commented and the real line, the first is the comment, and it became the active one), or delete the commented line and keep the real one:

$ sed -i '0,/^#\?PermitRootLogin.*/s//PermitRootLogin no/' sshd_config        (first match only)
$ sed -i '/^#PermitRootLogin/d' sshd_config                                  (drop the comment)

Comment a line out, and uncomment one:

$ sed -i '/^PasswordAuthentication/s/^/#/' sshd_config && grep Password sshd_config
#PasswordAuthentication yes
$ sed -i 's/^#\(X11Forwarding\) .*/\1 no/' sshd_config && grep X11 sshd_config
X11Forwarding no

Add a line if it is missing, change it if it is there. This is the pattern for a repeatable script: test with grep, then edit or append:

$ grep -q '^MaxAuthTries' sshd_config && sed -i 's/^MaxAuthTries.*/MaxAuthTries 3/' sshd_config || echo 'MaxAuthTries 3' >> sshd_config
$ tail -1 sshd_config
MaxAuthTries 3

Insert after a line, before a line, or delete:

$ sed -i '/^Port /a Banner /etc/issue.net' sshd_config; sed -n '2,3p' sshd_config
Port 22
Banner /etc/issue.net
$ sed -i '/^# sshd settings/i # managed by script' sshd_config; head -2 sshd_config
# managed by script
# sshd settings
$ sed -i '/^UsePAM/s/yes/no/;/^Banner/d' sshd_config

A managed block between markers is the safest way to own a part of a file. Replace what is between BEGIN and END, and leave the rest:

$ cat blk.conf
before
# BEGIN managed
old1
old2
# END managed
after
$ sed '/# BEGIN managed/,/# END managed/{//!d}' blk.conf         (empty the block, keep the markers)
before
# BEGIN managed
# END managed
after
$ sed -i '/# BEGIN managed/,/# END managed/{//!d};/# BEGIN managed/a new1\nnew2' blk.conf
$ cat blk.conf | tr '\n' '|'
before|# BEGIN managed|new1|new2|# END managed|after|

Append to the end of a line (for example a host name in /etc/hosts):

$ echo '127.0.0.1 localhost' | sed '/localhost/s/$/ myhost/'
127.0.0.1 localhost myhost

Three habits for a config edit: (1) copy the file or use -i.bak; (2) check with the program's own test, such as sshd -t before reloading sshd (not run in the lab, the container has no sshd configured); (3) diff the result. And make the script safe to run twice, so a retry never doubles a line.

Log and text recipes

Pull one field out of a line. -n with s///p prints only the lines that matched, and the group is the part you keep:

$ echo 'user=ann ip=10.0.1.11 x' | sed -n 's/.*ip=\([0-9.]*\).*/\1/p'
10.0.1.11
$ sed -n '/ERROR/{s/.*user=\([a-z]*\).*/\1/p}' app.log
kim
ed

Reorder fields with -E and groups. The first form takes space-separated words and drops the date and time:

$ sed -E 's/^(\S+) (\S+) (\S+) +(\S+) +(.*)$/\3 \4 \5/' app.log | head -2
INFO api request ok user=ann ip=10.0.1.11
INFO api request ok user=ed ip=10.0.1.12
$ echo 'ann,admin,10' | sed -E 's/^([^,]*),([^,]*)/\2,\1/'
admin,ann,10

sed does not understand quoting, so it cannot handle a CSV field that has a comma inside quotes. The lab shows the failure:

$ echo '"a,b",c' | sed -E 's/^([^,]*),([^,]*)/\2,\1/'
b","a,c

Use awk, Python's csv module or a CSV tool for real CSV.

Mask data before you share a log:

$ echo 'ip=10.0.1.11' | sed -E 's/([0-9]+\.[0-9]+)\.[0-9]+\.[0-9]+/\1.x.x/'
ip=10.0.x.x

Strip tags (fine for simple cases, wrong for real HTML):

$ echo '<p>Hi <b>there</b></p>' | sed 's/<[^>]*>//g'
Hi there

Cut characters by position:

$ echo hello | sed 's/.$//;s/^.//'                (drop the last and the first)
ell
$ echo hello | sed 's/^.\{2\}//'                   (drop the first two)
llo
$ echo hello | sed 's/./&-/3'                       (insert a dash after the third character)
hel-lo

Show the lines around a match, or from a match to the end of the file:

$ sed -n '2,/cake/p' fruit.txt | tr '\n' '|'
banana split|cherry tart|apple crumble|date cake|
$ sed -n '3,$p' fruit.txt | head -1                (from line 3 to the end)
cherry tart
$ sed 1d fruit.txt | head -1                        (skip a header line)
banana split

Replace in many files. Find the files first, then give them to sed (see the safety section about the names):

$ grep -rl 'oldname' ./src | xargs sed -i 's/oldname/newname/g'

For file names with spaces use grep -rlZ and xargs -0.

Print the line numbers of the matches, with the text:

$ sed -n '/apple/{=;p}' fruit.txt | head -2 | tr '\n' ' '
1 apple pie

Delete trailing white space, convert tabs, and fix line ends:

$ printf 'x  \ny\t\n' | sed 's/[[:space:]]*$//' | cat -A
x$
y$

Using sed safely in shell scripts

Most sed trouble in scripts is about putting a shell variable into the script. The shell builds the text of the script, so whatever is in the variable becomes code. There are three problems, all of which ran in the lab.

1. The delimiter. A variable with a slash breaks s/.../.../:

$ OLD='/usr/local'; NEW='/opt'; echo '/usr/local/bin' | sed "s/$OLD/$NEW/"
sed: -e expression #1, char 8: unknown option to `s'
$ echo '/usr/local/bin' | sed "s|$OLD|$NEW|"
/opt/bin

Another delimiter fixes it only until the value contains that one: NEW='a|b' broke s|x|$NEW| with the same "unknown option to `s'" error.

2. Characters that are special in the pattern or in the replacement. A dot matches any character, and & in the replacement is the match:

$ OLD='a.b'; echo 'a.b axb' | sed "s/$OLD/X/g"
X X                                          (the dot also matched the x)
$ NEW='R&D'; echo 'x' | sed "s/x/$NEW/"
RxD

The safe way is to escape the value before it goes in. Two small functions did it in the lab. In the pattern, escape ] [ \ . * ^ $ and the delimiter; in the replacement, escape \ & and the delimiter:

esc_re()  { printf '%s' "$1" | sed 's/[][\.*^$/]/\\&/g'; }
esc_rep() { printf '%s' "$1" | sed 's/[\&/]/\\&/g'; }

$ OLD='a.b/c[1]'; NEW='R&D/x'
$ esc_re "$OLD"
a\.b\/c\[1\]
$ esc_rep "$NEW"
R\&D\/x
$ echo 'x a.b/c[1] axb/c1' | sed "s/$(esc_re "$OLD")/$(esc_rep "$NEW")/g"
x R&D/x axb/c1
$ OLD='*.log'; NEW='$HOME & more'; echo 'rm *.log now' | sed "s/$(esc_re "$OLD")/$(esc_rep "$NEW")/"
rm $HOME & more now

A value with a newline needs its newlines changed to the two characters \n first (it worked in the lab, but it is easy to get wrong). For anything beyond simple text, do the replacement in a language that has a literal replace. perl does, with \Q...\E and the value taken from the environment, so there is no shell quoting at all:

$ OLD='a.b/c[1]' NEW='R&D/x' perl -pe 's/\Q$ENV{OLD}\E/$ENV{NEW}/g' <<< 'x a.b/c[1] axb/c1'
x R&D/x axb/c1

3. Code in the value. In GNU sed a few commands run programs or write files, and a variable can contain them. The lab pasted a value that closed the substitute and added an e command (a value like that can come from user input, a file name or a web form):

$ BAD='x/;e echo INJECTED;s/y'
$ echo 'abc' | sed "s/b/$BAD/"
sh: 1: s/y/: not found
INJECTED
axc

The e command ran echo INJECTED. The w command writes to any file the user can write. Never build a script from untrusted text. If you must run a script you do not control, turn the dangerous commands off with --sandbox:

$ echo 'abc' | sed --sandbox 's/b/X/;e echo INJECTED'
sed: -e expression #1, char 8: e/r/w commands disabled in sandbox mode
$ echo 'abc' | sed --sandbox 's/b/X/'
aXc

Variables in an address work the same way. A user name with a dot in it matches more than it should. When you only need to check whether a line contains a fixed text, grep -F does not have the problem:

$ grep -Fx 'apple pie' fruit.txt
apple pie

More script rules:

- Use single quotes for the script and pass values through the pattern only when you must. If you need a variable, use double quotes only around the parts that need it, and escape the value.
- Do not edit files with names taken from untrusted input without --: sed -i -- 's/a/b/' "$file" (the -- ends the options, so a name starting with a dash is not an option).
- Check the exit status. sed exits 0 even when nothing matched. To know whether a change happened, compare the files (cmp) or use grep first.
- With xargs or find use -print0 and -0, or find -exec sed -i ... {} +, so a space in a name does not split it.
- Test the script without -i first. Keep -i.bak while you are developing a script, and take it out when you have a better way to restore (a backup, version control).

Portability: GNU, BusyBox, POSIX and the BSDs

A sed one-liner copied from the web may not run on the machine in front of you. The lab ran the same lines on GNU sed 4.9 and on BusyBox 1.36.1 (the sed in Alpine and many containers):

feature                              GNU sed 4.9     BusyBox sed 1.36.1
s/a\+/X/ (BRE with \+)               works           works
-E, -r                               works           works
\t in the replacement                works           works
\b word boundary                     works           works
-i, -i.bak                           works           works
2a\hello (append)                    works           works
0~3 (first~step address)             works           error: unsupported command ~
\U \L \u \l \E in the replacement    works           not supported: it printed the letter U
--debug, --sandbox, --follow-symlinks works           not supported ("unrecognized option")
-z                                   works           not supported ("invalid option -- 'z'")

$ busybox sed '0~3d' n.txt
sed: unsupported command ~
$ busybox sed 's/.*/\U&/' <<< 'abc'
Uabc
$ busybox sed --debug 's/a/b/' <<< a
sed: unrecognized option '--debug'

The case change failing quietly (it printed Uabc) is the worst kind of difference: no error, wrong output. In a script that must run on Alpine, use tr for case, or install GNU sed (apk add sed).

POSIX mode. --posix turns off the GNU extensions so you can see what a strict sed would do. The lab: s/a\+/X/ left aaa unchanged under --posix (the \+ is a GNU extension), though \t in the replacement still worked.

$ sed --posix 's/a\+/X/' <<< 'aaa'
aaa
$ sed 's/a\+/X/' <<< 'aaa'
X

BSD and macOS sed (not run in the lab). The differences that come up most: -i needs an argument, so sed -i '' 's/a/b/' file for no backup; \n in a replacement and \t are not understood, so the portable way is a real newline with a backslash before it; -E works; \+ and \| in a basic regex do not; \b and \< are written [[:<:]] and [[:>:]]; and there is no -z, no --debug, no first~step. If a script has to run on both, write to POSIX (a basic regex, a/i/c with a backslash and a real newline, no extensions), use -E and the character classes, or install GNU sed (gsed on a Mac) and call that.

Which sed am I running? sed --version prints a GNU banner on GNU sed and fails on the BSD one; busybox sed prints a usage error. For a portable script, test for the features you need, or pin the tool.

Errors and debugging

sed's messages are short, and the character number counts from the start of the script. The ones you will see (with the exit status that the lab gave):

$ sed 's/a/b' fruit.txt
sed: -e expression #1, char 5: unterminated `s' command            (missing the last / or an unescaped / inside)
$ sed 's/a/b/z' fruit.txt
sed: -e expression #1, char 7: unknown option to `s'               (a bad flag, or a delimiter in the text)
$ sed 'k' fruit.txt
sed: -e expression #1, char 1: unknown command: `k'
$ sed -n '/x/' fruit.txt
sed: -e expression #1, char 3: missing command                       (an address with no command)
$ sed -n '2{p' fruit.txt
sed: -e expression #1, char 0: unmatched `{'
$ sed 's/\(a/b/' fruit.txt
sed: -e expression #1, char 8: Unmatched ( or \(
$ sed 's/a/\1/' fruit.txt
sed: -e expression #1, char 7: invalid reference \1 on `s' command's RHS    (no group in the pattern)
$ sed -E 's/a{2,1}/b/' fruit.txt
sed: -e expression #1, char 11: Invalid content of \{\}
$ sed 'b nolabel' fruit.txt
sed: can't find label for jump to `nolabel'                            (exit status 4)
$ sed -n 'p' nofile.txt
sed: can't read nofile.txt: No such file or directory                   (exit status 2)

Exit statuses: 0 success (even when nothing matched), 1 invalid command or syntax, 2 an input file could not be read (sed carries on with the others), 4 an I/O error or a runtime problem such as a missing label or no input files with -i.

Most "it does nothing" cases are about what sed matched, not about errors. Work from the inside out:

1. Print the lines the address selects: sed -n '/pattern/p' file. If it prints nothing the pattern is wrong.
2. Print what the substitute does to one line: echo 'a sample line' | sed 's/.../.../'.
3. Add -n and the p flag to see only the changed lines.
4. Use l to see hidden characters (tabs, carriage returns, trailing spaces), and = to see line numbers.
5. Use --debug. It prints the parsed script in a canonical form and then each command as it runs, with the pattern space and the registers of the match:

$ sed --debug 's/a/b/;/x/d' <<< 'abc'
SED PROGRAM:
  s/a/b/
  /x/ d
INPUT:   'STDIN' line 1
PATTERN: abc
COMMAND: s/a/b/
MATCHED REGEX REGISTERS
  regex[0] = 0-1 'a'
PATTERN: bbc
COMMAND: /x/ d
END-OF-CYCLE:
bbc

The canonical form is useful in itself: it shows how sed parsed your script. s/a/b/;/x/d came back with the commands on separate lines.

One portability note about scripts: GNU sed accepts a label followed by a semicolon (:a;N;$!ba worked), but other seds may read the semicolon as part of the label name (BSD sed, not run). In a longer script put each command on its own line, in a file (-f).

Speed and big files

sed streams: it reads a line, processes it and writes it, so its memory use does not depend on the file size. The lab used a log of 1,001,000 lines (65 MB), and timed several jobs. The times are indicative.

$ sed 's/ERROR/E/' big.log                                         0.23 s  (peak memory about 2 MB)
$ sed -n '/ERROR/p' big.log                                        0.19 s
$ sed -E 's/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/x/g' big.log            3.32 s  (UTF-8 locale)
$ LC_ALL=C sed -E 's/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/x/g' big.log   0.94 s  (C locale)
$ sed -n '5p' big.log                                              0.07 s  (reads the whole file)
$ sed '5q' big.log                                                 0.01 s  (quits at line 5)
$ sed -n '/ERROR/{p;q}' big.log                                    0.01 s
$ sed ':a;N;$!ba;s/\n/,/g' big.log                                 0.38 s  (the whole file in memory: 123 MB)
$ sed -i 's/ERROR/E/' big2.log                                     0.34 s

What the numbers say:

1. The locale matters for regular expressions with character classes and repetition. In a UTF-8 locale the same expression took 3.5 times as long as in the C locale. If the data is plain ASCII, LC_ALL=C sed ... is faster. Do not do that when the data has accented characters you need sed to treat as one character.
2. Stop early with q whenever you want only the first match. Print one line with 5q, not -n 5p, which reads on to the end.
3. A script that collects the file in the pattern space (the :a;N;$!ba join) holds all of it in memory: 123 MB for a 65 MB file here. That is fine for a configuration file, not for a multi-gigabyte log. Use awk or a streaming approach for those.
4. For plain "show matching lines" grep is the fastest (grep -c ERROR took 0.05 s, awk 0.10 s, sed -n /ERROR/p 0.20 s). For a replacement on every line (C locale) sed took 0.26 s, awk with sub() 0.17 s and perl -pe 0.39 s, so the three are close and none of them is slow.
5. sed -i rewrites the whole file, so editing a big file in a loop is slow. Put all the edits in one script: sed -i -e 's/a/b/' -e 's/c/d/' file does it in one pass.
6. Starting sed once per line in a shell loop is by far the slowest thing you can do. Pipe the whole input through one sed.

When not to use sed

sed is the right tool for line-based text changes. It is the wrong one when the text has structure, and the lab showed the failure each time:

- JSON: use jq. A sed pattern cannot tell a key from a value, or an escaped quote from the end of a string.
- YAML: use yq. Indentation is the structure, so a sed edit that works on one file corrupts another.
- CSV with quoted fields: awk (with a field separator that knows about quotes), Python csv, or a CSV tool. See the failing example in the recipes.
- HTML and XML: a parser (xmlstarlet, python's lxml or BeautifulSoup). Stripping tags with sed works only for the simplest text.
- Non-greedy matches and lookahead: perl -pe 's/<.*?>//g'. perl is installed on most Ubuntu machines, and the same \Q...\E trick gives literal replacements.
- Calculations, state and fields: awk. If you find yourself writing N, D, P, h and G together, a short awk program is easier to read and to change.
- Replacing text across a whole project: use a tool that knows about the files (git grep -l with xargs sed, a refactoring tool for code, a search-and-replace in your editor with a preview).

Keep the sed one-liners for the things it is best at: s/old/new/, delete or print lines by pattern or number, comment or uncomment a setting, and a quick fix in a pipe.

Related guides on this site: jq for JSON and yq for YAML, grep for searching, awk for fields and columns, and the regular expression cheat sheet for the pattern syntax.

One-liners

All of these ran in the lab on GNU sed 4.9.

# Trim leading, trailing, both
sed 's/^[ \t]*//' file.txt
sed 's/[ \t]*$//' file.txt
sed 's/^[ \t]*//;s/[ \t]*$//' file.txt
# Double-space, number the lines, reverse the file
sed G file.txt
sed = file.txt | sed 'N;s/\n/\t/'
sed '1!G;h;$!d' file.txt
# Join all lines with commas
sed ':a;N;$!ba;s/\n/,/g' file.txt
# Join every two lines
sed '$!N;s/\n/ /' file.txt
# Remove consecutive duplicate lines
sed '$!N;/^\(.*\)\n\1$/!P;D' file.txt
# Print lines between two markers, without the markers
sed -n '/start/,/end/{//!p}' file.txt
# First N lines, last N lines
sed 10q file.txt
sed -e :a -e '$q;N;11,$D;ba' file.txt
# Print line 10, lines 10 to 20, the last line, the number of lines
sed -n '10p' file.txt
sed -n '10,20p' file.txt
sed -n '$p' file.txt
sed -n '$=' file.txt
# Delete the first line, the last line, empty lines, comments
sed 1d file.txt
sed '$d' file.txt
sed '/^$/d' file.txt
sed '/^\s*#/d;/^\s*$/d' config.conf
# Every other line
sed -n 'p;n' file.txt
sed 'n;d' file.txt
# Change a setting, comment out, uncomment
sed -i 's/^PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
sed -i '/pattern/s/^/#/' file.txt
sed -i 's/^#\(X11Forwarding\) .*/\1 no/' sshd_config
# Change only the first match in the file
sed '0,/old/s//new/' file.txt
# Windows line ends to Unix, and back
sed 's/\r$//' dos.txt
sed 's/$/\r/' unix.txt
# Thousands separators
echo 1234567 | sed -E ':a;s/([0-9])([0-9]{3})(,|$)/\1,\2\3/;ta'
# Replace in many files, and keep backups
grep -rl 'oldname' ./src | xargs sed -i.bak 's/oldname/newname/g'

Cheat sheet

Print / delete lines       sed -n '5p'    sed -n '5,9p'    sed -n '$p'    sed '5d'    sed '/re/d'    sed -n '/re/p'    sed '5q'
Substitute                 sed 's/old/new/'    s/old/new/g    s/old/new/2    s/old/new/Ig    s/old/new/p    s|a/b|c/d|    s/\(x\)/\1\1/    -E 's/(x)/\1\1/'
In the replacement         &  whole match      \1..\9  groups      \n \t  newline, tab      \U \L \u \l \E  case (GNU)      \&  literal &
Addresses                  5    $    /re/    /re/I    2,5    2,+3    2,~4    0~3  (every 3rd)    1~2    /a/,/b/    0,/re/ (can end on line 1)    addr!
Commands                   p d q Q = a i c y l r w n N D P h H g G x b t T    {cmd;cmd}
Insert / append / change   sed '3i\text'    sed '3a\text'    sed '3c\text'    sed '$a\text'    sed '1i\header'
Several commands           sed -e 's/a/b/' -e 's/c/d/'    sed 's/a/b/;s/c/d/'    sed -f script.sed    sed '2,5{s/a/b/;s/c/d/}'
Options                    -n  -e  -f  -E  -i[.bak]  -s  -z  -u  --debug  --posix  --sandbox  --follow-symlinks
Regex (BRE)                .  *  ^  $  [abc]  [^abc]  \(  \)  \{n,m\}  \+  \?  \|  \w  \s  \b  \<  \>  [[:space:]]  [[:digit:]]
Regex (-E)                 .  *  ^  $  [abc]  (  )  {n,m}  +  ?  |  \w  \s  \b  [[:space:]]    (a literal + is \+)
In place                   sed -i 's/a/b/' f    sed -i.bak ...    sed -i --follow-symlinks ...    never -n -i without p    never -ie
Many lines                 $!N;s/\n/ /     :a;N;$!ba;s/\n/,/g     -z 's/\n/,/g     1!G;h;$!d (reverse)     G (double space)
Config edit                sed -i 's/^key .*/key value/'    grep -q '^key' f && sed -i ... || echo 'key value' >> f    0,/re/s//new/ (first only)
Safe in scripts            single quotes    escape variables (esc_re, esc_rep)    --sandbox    sed -i -- '...' "$file"    find -exec ... {} +
Debug                      sed --debug '...'    sed -n 'l'    sed -n '/re/p'    sed -n '/re/='    diff <(cat f) <(sed '...' f)
Exit status                0 ok (even with no match)    1 bad script    2 file not found    4 I/O or runtime error

Common problems

every line printed twice                              you used p without -n: add -n, or remove the p
nothing printed                                      you used -n but no p (or the pattern matched nothing): check with sed -n '/re/p'
file emptied by sed -n -i                            -n with -i and nothing printed: do not use -n -i unless p prints what you keep
a stray file called fileE or filee                   you typed -ie: write -i -e, or -i.bak
sed: -e expression #1, char N: unknown option to `s' a / inside the pattern or the replacement (often from a variable): use another delimiter, or escape it
sed: unterminated `s' command                         a missing closing delimiter, or a backslash at the end
sed: unknown command: `X'                             a typo, or a missing ; or newline between commands
sed: missing command                                  an address with nothing after it: sed -n '/x/' needs a command such as p
unmatched { or Unmatched ( or \(                      a block or group that is not closed (the backslashes differ between BRE and -E)
invalid reference \1 on `s' command's RHS             the replacement uses a group that the pattern does not have
The + or | or ( ) does nothing in a pattern           basic regex treats them as literal: use -E, or write \+ \| \( \)
A + or ( in -E suddenly matches text                  in -E they are special: escape a literal one with \+ or \(
The dot matches too much                              . is any character: escape it \. or use [.]
.* took too much (greedy)                             use a negated set: s/<[^>]*>//g
Replacement has R&D turned into RxD                   & is the match: write \& in the replacement
A symlink became a plain file                         -i replaces the link: use --follow-symlinks
A hard-linked file no longer matches its twin         -i makes a new file: the other name keeps the old content
Duplicate setting after uncommenting                  ^#\?key matched both the comment and the real line: use 0,/re/s// or delete the comment line
The change applied twice (or not at all) on re-run    the script was not idempotent: use grep -q first, anchor the pattern, and test a re-run
Works on Ubuntu, fails on Alpine or a Mac             GNU extensions (\U, 0~3, -z, --debug) or the -i '' form: check the portability section
\U prints the letter U on Alpine                      BusyBox sed has no case conversion: use tr, or install GNU sed
sudo sed ... > /etc/file permission denied            the redirection is done by your shell, not by sudo: use sudo sed -i, or sudo tee
Variables not expanded in the script                  single quotes stop the shell: use double quotes, and escape the value
A range never stops                                   the end pattern does not occur after the start: the range runs to the end of the file
First match only is changed in a range                a range with c changes the whole range once; use { } with s for each line
A command in a variable ran (INJECTED)                untrusted text in the script: do not build scripts from it; use --sandbox
Slow on a big file                                    LC_ALL=C for ASCII data, q to stop early, one sed with several -e, not one per line

Tips

Try before you edit: run the command without -i, look at the output, check the diff, then add -i (or -i.bak while you are learning).
Use -n with p when you want to print lines. Use plain sed when you want to change lines and print them all.
Choose a delimiter that is not in your data: | or # for paths and URLs.
Use -E for anything with groups or alternatives, and quote the whole script with single quotes.
Anchor patterns (^key) so a comment or another key is not matched, and test that the script is safe to run twice.
Never put an unescaped shell variable into the pattern or the replacement; escape it, or use perl with the environment, or grep -F for fixed strings.
Use --sandbox for scripts you did not write. Never build a script from user input.
Use -i -- 's/a/b/' "$file" so that odd file names are not read as options, and --follow-symlinks when the file may be a link.
Check what the config program thinks before you reload: sshd -t, nginx -t, visudo -c, apachectl configtest. Keep the backup until it passes.
Keep a long script in a file with comments (sed -f), one command per line, and run it with --debug when it does something unexpected.
Stop early with q when you only want the first match; set LC_ALL=C for big plain ASCII data.
Know your sed (sed --version), and write for the oldest one that has to run the script, or install GNU sed there.
When a sed script needs N, D, P and the hold space together, or the data is JSON, YAML, CSV or HTML, use awk, jq, yq, perl or a parser instead.