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
- Set up
- sed: edit text without opening it
- Choosing lines: addresses
- A real job: changing sshd_config
- Regular expressions
- Groups and backreferences
- Common mistakes
- Practice
- 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-20Read 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-20s 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
- Print
web.logwith everyGETchanged toget, without changing the file. - Change
admintoownerinusers.csv, in place, keeping a backup calledusers.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:
| Address | Lines |
|---|---|
3 | Line 3 |
2,4 | Lines 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 404Adding 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-20Try it
- Print only line 3 of
/etc/passwd. - Print
web.logwithout the line containing500. - Add a header line
date method path statusto the top ofweb.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 PasswordAuthenticationYou 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
- In your copy, set
PermitRootLogintono, 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
| Pattern | Matches |
|---|---|
abc | The 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|b | a 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
3Try it
- Find the lines in
/etc/passwdwhose user ID (the third field) is 1000 or higher, meaning four digits or more. (Hint:^[^:]*:[^:]*:skips the first two fields.) - Find every line in
sshd_configthat'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
- Turn
web.log's lines into/home -> 200style, path then status. - 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
- **
-iwithout testing first.** Run the command without-iand read the output. Then add-i.bak. - **Forgetting
g.** Without it, only the first match on each line changes. - **Double quotes around patterns with
$.** In"...", Bash expands$beforesedsees it. Use single quotes forsedandgreppatterns unless you mean to put a shell variable in. - Unescaped dots.
192.168.1.1as a pattern also matches192x168y1z1. Write192\.168\.1\.1. - **Greedy
.*.**.*grabs as much as it can, sos/".*"/X/on"a" and "b"replaces everything from the first quote to the last. Use"[^"]*"to match one quoted part. - Basic and extended mixed up. Without
-E,+and?and|are plain characters (or need a backslash). If a pattern does nothing, try adding-E.
Practice
- 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- Print
users.csvwithout 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- Find every line in
web.logwhose 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- In your
sshd_configcopy, setPortto2222, 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
- Turn every date in
web.logfrom2026-10-01into01.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- 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.15127.0.0.1 is the machine talking to itself. The other is the VM's address on its network.
Cheat sheet
| Command | What it does |
|---|---|
sed 's/old/new/' file | Replace the first match on each line, print the result |
s/old/new/g, /gi | Every match, ignore case |
sed -i.bak '...' file | Change 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 |
← Archives and links: tar, gzip, hard links and symlinksClean up a messy folder →