Nandishwar Singh

Home / Learn Linux / Lesson 12

Editing text with sed and regular expressions

Change text in files without opening them, using patterns that match exactly what you mean.

Lesson
12 of 30
Time
About 45 minutes

On this page

  1. Set up
  2. sed: edit text without opening it
  3. Choosing lines: addresses
  4. A real job: changing sshd_config
  5. Regular expressions
  6. Groups and backreferences
  7. Common mistakes
  8. Practice
  9. Cheat sheet

Set up

sed changes files, so practise on copies. Take a copy of the real SSH server config, plus two small files:

$ mkdir ~/sed-lab
$ cd ~/sed-lab
$ cp /etc/ssh/sshd_config .
$ printf "name,role,joined\nasha,admin,2024-03-15\nravi,dev,2025-11-02\nmeera,dev,2026-01-20\n" > users.csv
$ printf "2026-10-01 GET /home 200\n2026-10-01 GET /login 500\n2026-10-02 POST /pay 200\n2026-10-03 GET /cart 404\n" > web.log
$ ls
sshd_config  users.csv  web.log

sed: edit text without opening it

sed (stream editor) reads text line by line, applies your commands to each line, and prints the result. You'll use it to change config files from scripts, fix the same thing in a hundred files at once, or clean up output in a pipeline.

The command you'll use 90% of the time is s, substitute:

$ sed 's/dev/developer/' users.csv
name,role,joined
asha,admin,2024-03-15
ravi,developer,2025-11-02
meera,developer,2026-01-20

Read s/dev/developer/ as "substitute dev with developer". By default sed only prints the changed text. The file itself is untouched:

$ cat users.csv
name,role,joined
asha,admin,2024-03-15
ravi,dev,2025-11-02
meera,dev,2026-01-20

s changes only the first match on each line. Add g (global) for every match on the line, and i to ignore case:

$ echo "one cat, two cat, three cat" | sed 's/cat/dog/'
one dog, two cat, three cat
$ echo "one cat, two cat, three cat" | sed 's/cat/dog/g'
one dog, two dog, three dog
$ echo "Cat CAT cat" | sed 's/cat/dog/gi'
dog dog dog

Changing the file: -i

-i (in place) writes the result back into the file. Give it a suffix like -i.bak and sed keeps a backup of the original first. Do that until you trust your command:

$ sed -i.bak 's/dev/developer/' users.csv
$ cat users.csv
name,role,joined
asha,admin,2024-03-15
ravi,developer,2025-11-02
meera,developer,2026-01-20
$ ls users.csv*
users.csv  users.csv.bak

If you're happy, rm users.csv.bak. If not, mv users.csv.bak users.csv puts everything back.

Try it

  1. Print web.log with every GET changed to get, without changing the file.
  2. Change admin to owner in users.csv, in place, keeping a backup called users.csv.orig.
Answer
$ sed 's/GET/get/' web.log
2026-10-01 get /home 200
2026-10-01 get /login 500
2026-10-02 POST /pay 200
2026-10-03 get /cart 404
$ sed -i.orig 's/admin/owner/' users.csv
$ cat users.csv
name,role,joined
asha,owner,2024-03-15
ravi,developer,2025-11-02
meera,developer,2026-01-20

Choosing lines: addresses

Put a line number or a pattern in front of a command and it only applies to those lines:

AddressLines
3Line 3
2,4Lines 2 to 4
$The last line
/GET/Lines containing GET
/start/,/end/From a line matching start to one matching end

Three more commands go with addresses: d deletes the line, p prints it, and -n stops sed printing everything else, so -n plus p works like grep or head:

$ sed '1d' users.csv
asha,owner,2024-03-15
ravi,developer,2025-11-02
meera,developer,2026-01-20
$ sed -n '2,3p' web.log
2026-10-01 GET /login 500
2026-10-02 POST /pay 200
$ sed '/404/d' web.log
2026-10-01 GET /home 200
2026-10-01 GET /login 500
2026-10-02 POST /pay 200
$ sed -n '$p' web.log
2026-10-03 GET /cart 404

1d deletes the header line of a CSV, which you'll want before processing it. sed -n '2,3p' prints lines 2 and 3, which is shorter than head plus tail.

s takes an address too. This changes 200 only on POST lines:

$ sed '/POST/s/200/201/' web.log
2026-10-01 GET /home 200
2026-10-01 GET /login 500
2026-10-02 POST /pay 201
2026-10-03 GET /cart 404

Adding lines: i and a

i inserts a line before the address and a appends one after it:

$ sed '1i # exported 2026-10-21' users.csv
# exported 2026-10-21
name,role,joined
asha,owner,2024-03-15
ravi,developer,2025-11-02
meera,developer,2026-01-20
$ sed '$a sita,dev,2026-10-21' users.csv
name,role,joined
asha,owner,2024-03-15
ravi,developer,2025-11-02
meera,developer,2026-01-20
sita,dev,2026-10-21

Several commands can run in one go, separated by ; or with one -e per command:

$ sed -e '1d' -e 's/,/ | /g' users.csv
asha | owner | 2024-03-15
ravi | developer | 2025-11-02
meera | developer | 2026-01-20

Try it

  1. Print only line 3 of /etc/passwd.
  2. Print web.log without the line containing 500.
  3. Add a header line date method path status to the top of web.log's output.
Answer
$ sed -n '3p' /etc/passwd
bin:x:2:2:bin:/bin:/usr/sbin/nologin
$ sed '/500/d' web.log
2026-10-01 GET /home 200
2026-10-02 POST /pay 200
2026-10-03 GET /cart 404
$ sed '1i date method path status' web.log
date method path status
2026-10-01 GET /home 200
2026-10-01 GET /login 500
2026-10-02 POST /pay 200
2026-10-03 GET /cart 404

A real job: changing sshd_config

Here's what sed is really for. Say you need to turn off password logins on a server. In sshd_config the setting is commented out:

$ grep -n "PasswordAuthentication" sshd_config
78:#PasswordAuthentication yes
102:# PasswordAuthentication.  Depending on your PAM configuration,
106:# PAM authentication, then enable this but set PasswordAuthentication

You want line 78 to become PasswordAuthentication no, whether or not it currently has a # in front. One sed command does it:

$ sed -i.bak 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' sshd_config
$ grep -n "^PasswordAuthentication" sshd_config
78:PasswordAuthentication no

The pattern says: at the start of a line (^), maybe a # (#\?), then PasswordAuthentication, a space, and anything after it (.*). Replace the whole line. It works on a fresh server and on one where someone already changed the line, which is exactly what you want in a script that sets up many servers.

That pattern is a regular expression, and the rest of this lesson is about them. Lesson 19 does this on the real /etc/ssh/sshd_config, together with the safety steps that go with it.

Try it

  1. In your copy, set PermitRootLogin to no, whatever it's set to now.
Answer
$ grep -n "PermitRootLogin" sshd_config
54:#PermitRootLogin prohibit-password
104:# the setting of "PermitRootLogin prohibit-password".
$ sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' sshd_config
$ grep -n "^PermitRootLogin" sshd_config
54:PermitRootLogin no

Only the real setting line changed. The second match is inside a comment that doesn't start with PermitRootLogin, so ^ kept it safe.

Regular expressions

A regular expression (regex) is a pattern that describes text. grep, sed, Vim, and almost every programming language use them, so learning them once pays off everywhere.

The basics

PatternMatches
abcThe text abc anywhere
.Any one character
*The thing before it, 0 or more times
^ / $Start / end of the line
[abc]One of a, b or c
[0-9], [a-z]One digit, one lowercase letter
[^0-9]One character that's not a digit
\.A real dot (the backslash turns off the special meaning)
$ grep "^2026-10-0[12]" web.log
2026-10-01 GET /home 200
2026-10-01 GET /login 500
2026-10-02 POST /pay 200
$ grep " [45][0-9][0-9]$" web.log
2026-10-01 GET /login 500
2026-10-03 GET /cart 404
$ grep -c "^#" sshd_config
111

The first matches lines starting with the 1st or 2nd of October. The second finds every 4xx or 5xx status at the end of a line: a space, a 4 or 5, then two digits.

Extended regex: -E

The basic set needs backslashes for a few useful operators. With -E (in both grep and sed) you can write them plainly:

Pattern (with -E)Matches
+The thing before it, 1 or more times
?0 or 1 times (optional)
{3}, {2,4}Exactly 3 times, 2 to 4 times
a|ba or b
( )Group things together
$ grep -E "GET|POST" web.log | wc -l
4
$ grep -E "^[0-9]{4}-[0-9]{2}-[0-9]{2}" web.log | wc -l
4
$ echo "colour color" | grep -oE "colou?r"
colour
color

grep -o printed each match on its own line, so you can see u? matching both spellings.

Character classes

Instead of [0-9] you can write [[:digit:]], and there are others: [[:alpha:]] letters, [[:alnum:]] letters and digits, [[:space:]] spaces and tabs, [[:upper:]] capitals. They're longer to type but read clearly and work in any language setting:

$ echo "Order 66 shipped 3 items" | grep -oE "[[:digit:]]+"
66
3

Try it

  1. Find the lines in /etc/passwd whose user ID (the third field) is 1000 or higher, meaning four digits or more. (Hint: ^[^:]*:[^:]*: skips the first two fields.)
  2. Find every line in sshd_config that's a real setting: not a comment and not empty.
Answer
$ grep -E "^[^:]*:[^:]*:[0-9]{4,}:" /etc/passwd
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
ubuntu:x:1000:1000:Ubuntu:/home/ubuntu:/bin/bash
$ grep -vE "^(#|$)" sshd_config
Include /etc/ssh/sshd_config.d/*.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding yes
PrintMotd no
AcceptEnv LANG LC_* COLORTERM NO_COLOR
Subsystem       sftp    /usr/lib/openssh/sftp-server

[^:]* means "any characters that aren't a colon", so [^:]*: swallows one field. ^(#|$) matches lines that start with # or are empty, and -v inverts it.

Groups and backreferences

Parentheses do more than group. In sed -E, whatever matched inside the first pair can be put back with \1, the second with \2, and so on. That's how you rearrange text:

$ echo "2026-10-21" | sed -E 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\3\/\2\/\1/'
21/10/2026
$ sed -E '1d; s/^([^,]+),([^,]+),.*/\2: \1/' users.csv
owner: asha
developer: ravi
developer: meera

The date pattern catches the year, month and day in three groups, then writes them back as day/month/year. The slashes in the replacement need backslashes because / is also the separator.

That's ugly, so sed lets you pick another separator: whatever character follows the s is used. | or # are common when the text is full of slashes, like paths:

$ echo "2026-10-21" | sed -E 's|([0-9]{4})-([0-9]{2})-([0-9]{2})|\3/\2/\1|'
21/10/2026
$ echo "root /var/www/html" | sed 's|/var/www|/srv/www|'
root /srv/www/html

& in the replacement means "the whole match", handy for wrapping things:

$ echo "error on port 8080" | sed -E 's/[0-9]+/[&]/'
error on port [8080]

Try it

  1. Turn web.log's lines into /home -> 200 style, path then status.
  2. Swap the first two fields of users.csv (skip the header).
Answer
$ sed -E 's/^[^ ]+ [^ ]+ ([^ ]+) ([0-9]+)$/\1 -> \2/' web.log
/home -> 200
/login -> 500
/pay -> 200
/cart -> 404
$ sed -E '1d; s/^([^,]+),([^,]+)/\2,\1/' users.csv
owner,asha,2024-03-15
developer,ravi,2025-11-02
developer,meera,2026-01-20

Common mistakes

Practice

  1. Print lines 10 to 15 of /etc/services.
Answer
$ sed -n '10,15p' /etc/services
echo            7/tcp
echo            7/udp
discard         9/tcp           sink null
discard         9/udp           sink null
systat          11/tcp          users
daytime         13/tcp
  1. Print users.csv without its header, with the commas turned into tabs.
Answer
$ sed '1d; s/,/\t/g' users.csv
asha    owner   2024-03-15
ravi    developer       2025-11-02
meera   developer       2026-01-20
  1. Find every line in web.log whose date is in October 2026 and whose status is 200.
Answer
$ grep -E "^2026-10-.* 200$" web.log
2026-10-01 GET /home 200
2026-10-02 POST /pay 200
  1. In your sshd_config copy, set Port to 2222, whether it's commented out or not, keeping a backup. Check the result.
Answer
$ sed -i.bak2 's/^#\?Port .*/Port 2222/' sshd_config
$ grep -n "^Port" sshd_config
35:Port 2222
  1. Turn every date in web.log from 2026-10-01 into 01.10.2026.
Answer
$ sed -E 's/^([0-9]{4})-([0-9]{2})-([0-9]{2})/\3.\2.\1/' web.log
01.10.2026 GET /home 200
01.10.2026 GET /login 500
02.10.2026 POST /pay 200
03.10.2026 GET /cart 404
  1. From ip -4 addr show (lesson 18 explains it), pull out just the IPv4 addresses.
Answer
$ ip -4 addr show | grep -oE "inet [0-9.]+" | sed 's/inet //'
127.0.0.1
192.168.5.15

127.0.0.1 is the machine talking to itself. The other is the VM's address on its network.

Cheat sheet

CommandWhat it does
sed 's/old/new/' fileReplace the first match on each line, print the result
s/old/new/g, /giEvery match, ignore case
sed -i.bak '...' fileChange the file, keep a backup
sed -n '5,10p'Print only lines 5 to 10
sed '3d', '/re/d', '$d'Delete line 3, matching lines, last line
sed '1i text', '$a text'Insert before line 1, append after the last
sed -e 'a' -e 'b', 'a; b'Several commands
s|/a|/b|Another separator for paths
. * ^ $Any char, repeat, start, end
[abc] [0-9] [^0-9]One of, a range, not in
\.A real dot
-E with + ? {n,m} | ( )Extended regex
\1, &Group 1, the whole match
[[:digit:]] [[:alpha:]] [[:space:]]Character classes
grep -oE "re"Print only the matching parts