More Unix data tools:
regular expressions, awk, sed & more
Optional self-study content
1 Introduction
This page contains optional self-study material that builds on the w4c lecture, where you learned the basics of grep, cut, sort, and uniq. Here, you’ll go further with those commands and learn several new ones:
- Regular expressions, to search for text patterns rather than fixed text
- More
sortanduniq, plus the related commandscolumn,paste, andcomm trto substitute or delete single charactersawkto filter and process tabular datasedto find and replace text, and morejointo merge two tables, in a final example that combines many of these tools
Throughout, you’ll work with the files from the Garrigós et al. (2025) dataset, plus the Culex pipiens genome assembly FASTA file.
awk and sed are small programming languages in their own right, and nobody remembers all of their syntax. Aim to understand what the examples do, so that you can recognize, adapt, and look up such commands later. This also matters for week 8: generative AI is quite good at writing one-liners like the ones on this page, but you’ll need to be able to read them to check that they do what you want.
1.1 Setting up
At https://ondemand.osc.edu, start a VS Code session in
/fs/ess/PAS3493/people/$USER.Create a dir for this page and move into it:
mkdir -p week04/data-tools cd week04/data-toolsCopy the metadata file and one FASTQ file, like in w4c:
cp ../../garrigos/meta/metadata.tsv . cp ../../garrigos/fastq/S63_R1.fastq.gz .Create a decompressed copy of the GTF file. This way, you won’t have to start every command with
zcat:zcat ../../garrigos/ref/annot.gtf.gz > annot.gtfDownload the genome assembly FASTA file from NCBI with
wget, create a decompressed copy namedgenome.fna, and remove the compressed file:wget https://ftp.ncbi.nlm.nih.gov/genomes/all/GCF/016/801/865/GCF_016801865.2_TS_CPP_V2/GCF_016801865.2_TS_CPP_V2_genomic.fna.gz zcat GCF_016801865.2_TS_CPP_V2_genomic.fna.gz > genome.fna rm GCF_016801865.2_TS_CPP_V2_genomic.fna.gzwgetdownloads a file from a URL. You’ll learn more about it, and download this same file again, in week 11.Check that you have these four files:
ls -lh-rw-rw----+ 1 jelmer PAS0471 123M Sep 21 10:12 annot.gtf -rw-rw----+ 1 jelmer PAS0471 547M Sep 21 10:13 genome.fna -rw-rw----+ 1 jelmer PAS0471 457 Sep 21 10:11 metadata.tsv -rw-rw----+ 1 jelmer PAS0471 21M Sep 21 10:11 S63_R1.fastq.gz
The decompressed genome FASTA takes up quite a bit of space, so remove genome.fna once you’re done with this page.
2 Regular expressions with grep
2.1 What are regular expressions?
In w4c, you used grep to search for fixed text, like relictum. But grep actually interprets its search pattern as a regular expression (“regex”): a pattern in which some characters have a special meaning. With regular expressions, you can for example search for:
- Lines that start with a
>(the header lines of a FASTA file) - Reads that end in a stretch of 15 or more
As - Any text between quotes that follows
gene_idin a GTF file
Regular expressions are used far beyond grep: sed and awk (later on this page) use them, as do R and Python, and even VS Code’s search box (click the .* button in the search box to turn on regex search). So, time spent learning them pays off in many places.
Some regex symbols, like $ and \, also have a special meaning to the shell. Inside double quotes, the shell may therefore change the pattern before grep gets to see it. Inside single quotes, the shell leaves everything alone, so on this page, we’ll use single quotes around regular expressions.
2.2 Anchors: ^ and $
By default, a pattern can match anywhere in a line. ^ anchors a pattern to the start of a line, and $ anchors it to the end of a line. To see the difference, search the sample IDs in the metadata file (column 1) for the number 6:
cut -f 1 metadata.tsv | grep '6'S76
S86
S64
S67
S66
S69
S63
S65
S68
# Only sample IDs that start with 'S6':
cut -f 1 metadata.tsv | grep '^S6'S64
S67
S66
S69
S63
S65
S68
# Only sample IDs that end with '6':
cut -f 1 metadata.tsv | grep '6$'S76
S86
S66
Anchors are especially useful with genomics files, in which certain types of lines start with a specific character. For example, to count the sequences in a FASTA file, count its header lines, which start with a >:
grep -c '^>' genome.fna290
Let’s also look at the first few headers:
grep '^>' genome.fna | head -n 5>NC_068937.1 Culex pipiens pallens isolate TS chromosome 1, TS_CPP_V2, whole genome shotgun sequence
>NC_068938.1 Culex pipiens pallens isolate TS chromosome 2, TS_CPP_V2, whole genome shotgun sequence
>NC_068939.1 Culex pipiens pallens isolate TS chromosome 3, TS_CPP_V2, whole genome shotgun sequence
>NW_026292818.1 Culex pipiens pallens isolate TS unplaced genomic scaffold, TS_CPP_V2 Cpp_Un0001, whole genome shotgun sequence
>NW_026292819.1 Culex pipiens pallens isolate TS unplaced genomic scaffold, TS_CPP_V2 Cpp_Un0002, whole genome shotgun sequence
So, the genome assembly consists of 290 sequences: the three Culex chromosomes, and many smaller “unplaced scaffolds” (pieces of the genome that could not be assigned to a chromosome).
Combining both anchors, ^$ matches empty lines, so grep -v '^$' removes all empty lines from its input.
2.3 Any character (.), repetition (*), and grep -o
Two more key regex symbols are:
.— matches any single character.*— matches the preceding character any number of times, including zero.
Together, .* therefore matches any number of any characters, like the * shell wildcard does.
To see exactly what a regular expression matches, use grep’s -o option, which prints only the matching part of each line instead of the full line. Let’s try to extract the gene ID (the quoted text after gene_id) from the first line of the GTF file’s table part:
grep -v '^#' annot.gtf | head -n 1 | grep -o 'gene_id ".*"'gene_id "LOC120427725"; transcript_id ""; db_xref "GeneID:120427725"; description "homeotic protein deformed"; gbkey "Gene"; gene "LOC120427725"; gene_biotype "protein_coding"
That’s a lot more than we wanted! The problem is that .* is “greedy”: it matches as much as possible, so the match runs all the way to the last " on the line. Character classes, discussed next, offer a solution.
To match one of these special characters literally, put a backslash before it (“escape” it). For example, NC_068937\.1 only matches an actual period between 7 and 1, while NC_068937.1 would also match, e.g., NC_06893701.
2.4 Character classes: [...] and [^...]
A character class matches one character from a set, which you list between square brackets:
[ACGT]matches a singleA,C,G, orT.- Ranges are allowed:
[0-9]matches any digit, and[a-z]any lowercase letter. - A
^as the first character inside the brackets negates the class:[^ACGT]matches any single character that is notA,C,G, orT.
Note the two different meanings of ^: outside brackets, it anchors to the start of a line, while as the first character inside brackets, it negates the character class.
Negated character classes are the solution to our greedy matching problem. Instead of “any characters, followed by a quote” (.*"), we ask for “any characters except a quote, followed by a quote” ([^"]*"). This way, the match can’t extend beyond the next quote:
grep -v '^#' annot.gtf | head -n 1 | grep -o 'gene_id "[^"]*"'gene_id "LOC120427725"
The same trick works to extract just the sequence IDs from the FASTA headers — everything after the > up until the first space:
grep -o '^>[^ ]*' genome.fna | head -n 4>NC_068937.1
>NC_068938.1
>NC_068939.1
>NW_026292818.1
Extracting and counting matches
Combining grep -o with the sort | uniq -c frequency-table approach from w4c is a very handy way to summarize information that is “hidden” inside a column. For example, the GTF file’s attribute column (column 9) has a gene_biotype for every gene. How many genes of each type are there?
grep -o 'gene_biotype "[^"]*"' annot.gtf | sort | uniq -c | sort -nr 16297 gene_biotype "protein_coding"
1620 gene_biotype "lncRNA"
842 gene_biotype "pseudogene"
686 gene_biotype "tRNA"
155 gene_biotype "rRNA"
58 gene_biotype "snRNA"
9 gene_biotype "snoRNA"
5 gene_biotype "transcribed_pseudogene"
1 gene_biotype "misc_RNA"
Most genes are protein-coding, but the annotation also includes, e.g., about 1,600 long non-coding RNA (lncRNA) genes.
2.5 Extended regular expressions: grep -E
Several other useful regex symbols are only available as “extended regular expressions”, which you can turn on with grep -E:
| Symbol | Matches | Example | Example matches |
|---|---|---|---|
+ |
The preceding character one or more times | [0-9]+ |
7, 2046 |
? |
The preceding character zero or one time | genes? |
gene, genes |
{n} |
The preceding character exactly n times | A{3} |
AAA |
{n,} |
The preceding character at least n times | A{3,} |
AAA, AAAAAA |
{n,m} |
The preceding character n to m times | A{3,5} |
AAA, AAAAA |
| |
Either the pattern on the left or the one on the right | cecropin|defensin |
cecropin, defensin |
( ) |
Groups a pattern, e.g. to repeat it | (CA)+ |
CA, CACACA |
For example, how many reads in the FASTQ file end in at least 15 As, which may be the poly-A tails of mRNAs?
zcat S63_R1.fastq.gz | grep -cE 'A{15,}$'199
And which GTF features belong to genes for the antimicrobial peptides cecropin or defensin, which are part of the mosquito immune response?
grep -E 'cecropin|defensin' annot.gtf | cut -f 3 | sort | uniq -c 16 CDS
17 exon
7 gene
8 start_codon
8 stop_codon
8 transcript
Without -E, grep uses “basic” regular expressions, in which +, ?, { }, |, and ( ) are just literal characters. In the GNU version of grep (which is what OSC uses), you can still use their special meaning by putting a backslash before them:
Extended (grep -E) |
Basic (grep) |
|---|---|
A+ |
A\+ |
A{15,} |
A\{15,\} |
cecropin|defensin |
cecropin|defensin |
That gets hard to read quickly, so it’s easiest to simply use -E whenever you need these symbols. The same goes for sed, which also has an -E option. awk always uses extended regular expressions.
When searching online, you’ll also come across “Perl-compatible” regular expressions (grep -P), which offer additional shortcuts. For example, \t matches a Tab with grep -P, but not with grep or grep -E:
# Print lines with 'gene' as the feature type (column 3), surrounded by Tabs:
grep -P '\tgene\t' annot.gtf | head -n 2grep -A and -B (Click to expand)
When you search a FASTQ file for a sequence, grep only prints the matching sequence line. To see the full 4-line FASTQ record of each matching read, also print 1 line before (-B 1) and 2 lines after (-A 2) each match. The --no-group-separator option prevents grep from printing -- lines between non-adjacent matches, so that the output is valid FASTQ:
zcat S63_R1.fastq.gz | grep --no-group-separator -B 1 -A 2 -E 'A{15,}$' | head -n 8@ERR10802863.17755922 17755922 length=73
AGAATGCATACACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+
AAAAAEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
@ERR10802863.21692370 21692370 length=73
AGATCAACTACTGAGAAATGTGTTGAGCGATAAATACAGTTCATTAAAATCAACCAGAAAAAAAAAAAAAAAA
+
AAAAAEEEEEEEEEEEEEEEEEEEEAEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
2.6 Regular expressions vs. shell wildcards
Regular expressions and the shell wildcards from w4b (and the More Unix shell page) look similar, but they are different things:
- Shell wildcards match file names, and are expanded by the shell before a command runs.
- Regular expressions match text in a command’s input, and are interpreted by the command itself (e.g.
grep).
Confusingly, some symbols have a different meaning in each:
| Shell wildcard | Regex equivalent | Matches |
|---|---|---|
? |
. |
Any single character |
* |
.* |
Any number of any characters |
[...] and [^...] |
Same | One character that is / is not in the set |
Also recall from w4c that grep patterns don’t need to match the entire line, whereas a glob pattern has to match the entire file name.
Exercise 1: Regular expressions
Thinking of the shell wildcard
S6*, a classmate expects the command below to count the 7 samples whose ID starts withS6. What count do you think it will report instead, and why? Run the command to check your answer.cut -f 1 metadata.tsv | grep -c 'S6*'Click to see the solution
It reports
22: every single sample ID.In a regular expression,
*means “the preceding character any number of times, including zero”. So,S6*matches anSfollowed by zero or more6s — and every sample ID contains anS.The regex equivalent of the wildcard is
S6.*, but sincegreppatterns don’t need to match the full line, plainS6(or, more precisely,^S6) is all you need here.Count the number of FASTA headers that start with
>NC, and the number that start with>NW, and print the>NCheaders. What do theNCsequences represent, and what do theNWones represent?Click to see the solution
grep -c '^>NC' genome.fna4grep -c '^>NW' genome.fna286grep '^>NC' genome.fna>NC_068937.1 Culex pipiens pallens isolate TS chromosome 1, TS_CPP_V2, whole genome shotgun sequence >NC_068938.1 Culex pipiens pallens isolate TS chromosome 2, TS_CPP_V2, whole genome shotgun sequence >NC_068939.1 Culex pipiens pallens isolate TS chromosome 3, TS_CPP_V2, whole genome shotgun sequence >NC_015079.1 Culex pipiens pipiens mitochondrion, complete genomeThe 4
NCsequences are the 3 chromosomes plus the mitochondrial genome, while the 286NWsequences are the unplaced scaffolds (in NCBI’s RefSeq database,NC_IDs are used for complete molecules like chromosomes, andNW_IDs for scaffolds).A labmate counts the reads in a FASTQ file by counting the header lines, which start with
@:zcat S63_R1.fastq.gz | grep -c '^@'For this file, that gives the correct answer of 500,000 reads. But why could this approach overcount the reads in other FASTQ files? (Hint: think about the quality scores, and see the table in the w4a FASTQ quality score slides.)
Click to see the solution
The
@character is also used as a quality score: it represents a Phred score of 31 (one more than?, which represents 30). So, a quality score line can start with an@, and would then be counted as a read.It happens to work for our file because its quality scores are “binned” into just a few values, none of which is 31. More robust approaches are to divide the number of lines by 4, or to search for a longer, more specific header pattern like
'^@ERR'.How many genes have a description that contains “heat shock”, and are any of these descriptions shared by multiple genes? (Hint:
descriptionattributes are only present ongenelines, so you can adapt thegene_biotypecommand above.)Click to see the solution
Allow any characters except a quote both before and after “heat shock”, so that the match stays within the description’s quotes:
grep -o 'description "[^"]*heat shock[^"]*"' annot.gtf | sort | uniq -c | sort -nr3 description "heat shock protein 70 B2" 2 description "heat shock 70 kDa protein cognate 4-like" 1 description "heat shock protein beta-1" 1 description "heat shock protein 83-like" 1 description "heat shock protein 83" 1 description "heat shock protein 75 kDa, mitochondrial" 1 description "heat shock protein 70 A2-like" 1 description "heat shock protein 60A-like" 1 description "heat shock factor-binding protein 1" 1 description "heat shock factor protein" 1 description "heat shock 70 kDa protein cognate 5" 1 description "heat shock 70 kDa protein cognate 4" 1 description "heat shock 70 kDa protein cognate 2" 1 description "heat shock 70 kDa protein 4" 1 description "activator of 90 kDa heat shock protein ATPase homolog 1" 1 description "10 kDa heat shock protein, mitochondrial"There are 16 distinct descriptions, two of which are shared by multiple genes. To count the genes, count the matching lines:
grep -c 'description "[^"]*heat shock' annot.gtf19
3 More sort and uniq, and friends
3.1 Sorting by column, numerically
In w4c, you saw that sort -k sorts by specific columns, and that sort -n sorts numerically. You can combine the two by adding an n to a specific column (key).
To try this, create a small file with the sequence ID, feature type, and start and end positions (columns 1 and 3-5) of the first 6 genes in the GTF file. (The -w option makes grep only match gene as a whole word.)
cut -f 1,3-5 annot.gtf | grep -w 'gene' | head -n 6 > genes6.tsv
cat genes6.tsvNC_068937.1 gene 2046 110808
NC_068937.1 gene 48457 50554
NC_068937.1 gene 76295 82374
NC_068937.1 gene 82671 86331
NC_068937.1 gene 160196 177583
NC_068937.1 gene 299209 303777
These genes are already sorted by their start position (column 3 in this file). But see what happens when you sort them by that column without telling sort that it contains numbers:
sort -k3,3 genes6.tsvNC_068937.1 gene 160196 177583
NC_068937.1 gene 2046 110808
NC_068937.1 gene 299209 303777
NC_068937.1 gene 48457 50554
NC_068937.1 gene 76295 82374
NC_068937.1 gene 82671 86331
Sorting alphabetically, sort compares character by character, so 160196 comes before 2046 because 1 comes before 2. With -k3,3n, you get the correct numeric order back:
sort -k3,3n genes6.tsvNC_068937.1 gene 2046 110808
NC_068937.1 gene 48457 50554
NC_068937.1 gene 76295 82374
NC_068937.1 gene 82671 86331
NC_068937.1 gene 160196 177583
NC_068937.1 gene 299209 303777
A common use of this is to sort an annotation file by position, first by sequence ID (alphabetically) and then by start position (numerically). For the GTF file, whose start positions are in column 4, that would be:
# [Example only, no need to run this]
sort -k1,1 -k4,4n annot.gtfMany genomics tools require their input files to be sorted this way.
3.2 Other useful sort options
-cchecks whether a file is already sorted. It prints nothing if the file is sorted, and reports the first out-of-order line if not:sort -c -k3,3n genes6.tsvsort -c -k3,3 genes6.tsvsort: genes6.tsv:5: disorder: NC_068937.1 gene 160196 177583-uonly keeps unique lines, sosort -udoes the same assort | uniq.-V(“version sort”) sorts numbers within text by their numeric value:# printf prints text like echo does, and '\n' starts a new line: printf "sample1\nsample10\nsample2\n" | sort -Vsample1 sample2 sample10(Without
-V,sample10would come beforesample2.)-gsorts numbers in scientific notation (like1e-5) correctly, which-ndoesn’t. This is useful for, e.g., p-values.-tsets the column delimiter, which by default is any whitespace. For example,sort -t , -k2,2sorts a CSV file by its second column.
3.3 Finding duplicates with uniq -d
With the -d option, uniq only prints lines that occur more than once (one copy of each). For example, how many gene descriptions are shared by multiple genes?
grep -o 'description "[^"]*"' annot.gtf | sort | uniq -d | wc -l1424
To see which descriptions are shared by the most genes, use the frequency table approach instead:
grep -o 'description "[^"]*"' annot.gtf | sort | uniq -c | sort -nr | head -n 5 156 description "5S ribosomal RNA"
141 description "uncharacterized protein K02A2.6-like"
44 description "pickpocket protein 28-like"
41 description "transfer RNA valine (anticodon AAC)"
39 description "transfer RNA tyrosine (anticodon GUA)"
(The 156 5S ribosomal RNA genes are not an error: rRNA genes typically occur in many copies in a genome.)
3.4 column -t for viewing tabular files
When the values in a column differ in length, tabular files can look messy in the terminal:
grep -v '^#' annot.gtf | cut -f 1-8 | head -n 3NC_068937.1 Gnomon gene 2046 110808 . + .
NC_068937.1 Gnomon transcript 2046 110808 . + .
NC_068937.1 Gnomon exon 2046 2531 . + .
column -t lines up the columns nicely:
grep -v '^#' annot.gtf | cut -f 1-8 | head -n 3 | column -tNC_068937.1 Gnomon gene 2046 110808 . + .
NC_068937.1 Gnomon transcript 2046 110808 . + .
NC_068937.1 Gnomon exon 2046 2531 . + .
column is only meant for viewing: it separates columns with spaces, so don’t save its output as a TSV file. For a CSV file, specify the delimiter with -s: column -s , -t my-data.csv.
3.5 paste to combine lines
paste combines lines side by side, separated by Tabs. Given two files, it pastes line 1 of the first file next to line 1 of the second, and so on.
A neat trick is to give paste a dash (-) for each line it should combine from its standard input. With four dashes, paste combines every four lines into one — which turns each 4-line FASTQ record into a single line:
zcat S63_R1.fastq.gz | paste - - - - | head -n 2@ERR10802863.8435456 8435456 length=74 CAACGAATACATCATGTTTGCGAAACTACTCCTCCTCGCCTTGGTGGGGATCAGTACTGCGTACCAGTATGAGT + AAAAAEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
@ERR10802863.27637245 27637245 length=74 GCCACACTTTTGAAGAACAGCGTCATTGTTCTTAATTTTGTCGGCAACGCCTGCACGAGCCTTCCACGTAAGTT + AAAAAEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEAEE<EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
Now that each read is one line, with the sequence in column 2, you can use all the column-based tools you know. For example, which read sequences occur most often?
zcat S63_R1.fastq.gz | paste - - - - | cut -f 2 | sort | uniq -c | sort -nr | head -n 5 32589 NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN
255 ATAAATTAAAGGGCCGCAGTATTTTGACTGTGCGAAGGTAGCATAATCACTAGTCTTTTAATTGGAGGCTTGT
223 AACAGCGTAATTTTTTTTTAGAGTTCATATCGACAAAAAAGATTGCGACCTCGATGTTGGATTAAGAGTTATTT
204 AAATTAAAGGGCCGCAGTATTTTGACTGTGCGAAGGTAGCATAATCACTAGTCTTTTAATTGGAGGCTTGTATG
138 GCAAAAGTACGTCGACGTGCACTGCCACGACAGTGACGACCAGAGCGTGAAGCTGTACAAGAACCAGATCGCC
By far the most common “sequence” is that of the all-N reads you found in w4c. The FastQC program, which you’ll use in week 6, reports such “overrepresented sequences” too.
Exercise 2: sort, uniq, and paste
The experiment has 2 time points and 3 treatments. Create a frequency table of the number of samples for each combination of time point and treatment. Is the design balanced, i.e. does every combination have the same number of samples?
Click for a hint
Select both the time point and the treatment column with
cut.Click to see the solution
Select columns 2 and 3 with
cut -f 2,3, and keep the rest of the w4c frequency table pipeline the same:tail -n +2 metadata.tsv | cut -f 2,3 | sort | uniq -c4 10dpi cathemerium 4 10dpi control 4 10dpi relictum 3 24hpi cathemerium 3 24hpi control 4 24hpi relictumBecause
uniqcompares entire lines, it counts each distinct combination of time point and treatment. The design is not fully balanced: at 24hpi, there are only 3 samples for both the cathemerium and control treatments.A labmate wants to sort the GTF file by start position (column 4). They remove the header lines and then sort as follows:
grep -v '^#' annot.gtf | sort -k4Without running it, can you spot two problems with this command?
Click to see the solution
- Without
n, the start positions are sorted alphabetically, so that, e.g.,160196would come before2046. - The sequence ID (column 1) is ignored, so features from different chromosomes and scaffolds get mixed up. To sort by position within each sequence, use
sort -k1,1 -k4,4n.
(A minor third point:
-k4means “from column 4 to the end of the line”, rather than just column 4 as-k4,4would.)You can see the second problem right away in the output, whose first lines come from three different scaffolds:
grep -v '^#' annot.gtf | sort -k4 | head -n 3 | cut -f 1-5NW_026292908.1 Gnomon CDS 10 11481 NW_026292919.1 Gnomon CDS 10 1167 NW_026292909.1 Gnomon CDS 10 1587- Without
paste - - - -is a safe way to put each FASTQ read on a single line. Wouldpaste - -similarly be a safe way to put each sequence ingenome.fnaon a single line, next to its header? Why or why not?Click to see the solution
No: in FASTQ files, every record consists of exactly 4 lines, but in FASTA files, a sequence can be spread across any number of lines (recall exercise 2 in the w4a slides). In
genome.fna, each line holds 80 bases, sopaste - -would mostly paste one 80-base line next to the next one.
4 tr to translate characters
4.1 Substituting characters
The tr (translate) command substitutes characters one-for-one: the first character in its first argument is replaced by the first character in its second argument, and so on. For example, to replace every a by a b:
echo "aaaaccc" | tr 'a' 'b'bbbbccc
Oddly enough, tr does not accept a file name as an argument: it only reads from standard input. So, to use it on a file, pipe the output of cat into tr. For example, to turn the Tab-separated metadata.tsv into a comma-separated file, replace each Tab (\t) by a comma:
cat metadata.tsv | tr '\t' ',' > metadata.csv
head -n 3 metadata.csvsample_id,time,treatment
S82,10dpi,cathemerium
S75,10dpi,cathemerium
tr also understands ranges of characters. This is handy because many genome FASTA files, including ours, are “soft-masked”: repetitive regions are written in lowercase letters. For example, lines 12 and 13 of the genome FASTA file contain both:
head -n 13 genome.fna | tail -n 2CCTAATTCATGGAGGTGAATAGAGCATACGTAAATACAAAACTCATGACATTAGCCTGTAAGGATTGTGTaattaatgca
aaaatattgaTAGAATGAAAGATGCAAGTCccaaaaattttaagtaaatgaATAGTAATCATAAAGATAActgatgatga
To convert everything to uppercase, translate the range a-z into A-Z:
head -n 13 genome.fna | tail -n 2 | tr 'a-z' 'A-Z'CCTAATTCATGGAGGTGAATAGAGCATACGTAAATACAAAACTCATGACATTAGCCTGTAAGGATTGTGTAATTAATGCA
AAAATATTGATAGAATGAAAGATGCAAGTCCCAAAAATTTTAAGTAAATGAATAGTAATCATAAAGATAACTGATGATGA
4.2 Reverse complementing a sequence
Combined with rev, which reverses each line, tr can create the reverse complement of a DNA sequence: rev takes care of the “reverse” part, and tr of the “complement” part by swapping A with T and C with G:
echo "ATGCCGTA" | rev | tr 'ACGT' 'TGCA'TACGGCAT
4.3 Deleting and “squeezing” characters
The
-doption deletes characters. For example, by deleting all newlines (\n) from the sequence lines of the genome FASTA, you can count the total number of bases in the genome assembly (this takes a few seconds):grep -v '^>' genome.fna | tr -d '\n' | wc -c566354144So, the Culex pipiens genome assembly is about 566 million bases (566 Mb) long.
The
-soption “squeezes” runs of the same character into one. For example, to turn a variable number of spaces into a single space:echo "a b c" | tr -s ' 'a b c
Exercise 3: tr
A labmate wants to rename the
controltreatment tomockin the metadata file, and runs the command below. What do you think the output will look like? Run the command to check.cat metadata.tsv | tr 'control' 'mock'Click to see the solution
The output is mangled, also outside of the
controllines:sampke_id kime kkeakmeck S82 10dpi makhemekium S75 10dpi makhemekium [...] S78 10dpi mkckkkk S84 10dpi mkckkkk [...]trtranslates individual characters, not words: everycbecame anm, everynac, and so on, anywhere in the file. Because the second argument is shorter than the first, the remaining characters (t,r,o, andl) all became its last character,k.To replace a word, use
sed, which is covered below.Use
trto calculate which percentage of the genome assembly is soft-masked (i.e., written in lowercase).Click for a hint
Combine
-dwith the-coption, which takes the complement of the (first) set of characters: e.g.,tr -cd 'xyz'deletes everything exceptx,y, andz. Then compare with the total of 566,354,144 bases.Click to see the solution
Delete everything except lowercase bases, and count the remaining characters:
grep -v '^>' genome.fna | tr -cd 'acgtn' | wc -c315245493315,245,493 / 566,354,144 = 0.56, so 56% of the assembly is soft-masked. Large fractions of many genomes, including those of mosquitoes, consist of repetitive DNA such as transposable elements.
(The
grep -v '^>'step is needed because the header lines contain lowercase letters too.)A labmate wants the reverse complement of the entire genome, and runs:
rev genome.fna | tr 'ACGT' 'TGCA' > genome_revcomp.fnaWithout running it, can you name at least two problems with this command?
Click to see the solution
revreverses each line separately, but leaves the order of the lines unchanged. Because the sequences are spread across many lines, they are therefore not properly reversed.- The header lines are also reversed and “complemented”: e.g., the first header would end in
[...] sneipip xeluG 1.739860_GN>. - Lowercase (soft-masked) bases are not complemented, since
tris case-sensitive.
This is a task for a dedicated bioinformatics tool.
5 awk for tabular data processing
awk is a small programming language in its own right, which is especially good at quickly processing tabular data. For example, it can select rows based on the values in specific columns, reorder columns, or do calculations. To understand awk, you need to know about two core concepts: records and fields, and pattern-action pairs.
5.1 Records and fields
awk processes its input one record at a time, and by default, each line is one record. Within a record, each column is a field. The entire record is available as $0, and the fields as $1 (first column), $2 (second column), and so on.
An awk command is placed between single quotes, and actions go between curly braces { }. For example, to print columns 3 and 1 of the metadata file:
awk '{ print $3, $1 }' metadata.tsv | head -n 3treatment sample_id
cathemerium S82
cathemerium S75
This already goes beyond cut, which can’t change the order of columns. Note how the comma , between the columns made awk separate them with a space. Without the comma, the columns are pasted together, and to separate them with a Tab, you can add a "\t":
awk '{ print $3 $1 }' metadata.tsv | head -n 3treatmentsample_id
cathemeriumS82
cathemeriumS75
awk '{ print $3 "\t" $1 }' metadata.tsv | head -n 3treatment sample_id
cathemerium S82
cathemerium S75
-F '\t' for TSV files
By default, awk splits fields on any whitespace: Tabs as well as spaces. That works for the metadata file, which contains no spaces, but not for the GTF file, whose ninth column contains spaces. See what happens when you try to print column 9 of a GTF line:
grep -v '^#' annot.gtf | head -n 1 | awk '{ print $9 }'gene_id
The built-in variable NF (number of fields) shows that awk split this line into 24 fields, rather than the 9 columns of a GTF file:
grep -v '^#' annot.gtf | head -n 1 | awk '{ print NF }'24
With -F '\t', you tell awk to only split fields on Tabs:
grep -v '^#' annot.gtf | head -n 1 | awk -F '\t' '{ print NF }'9
To be safe, always use -F '\t' with Tab-separated files.
5.2 Pattern-action pairs
The general syntax of an awk command is awk 'pattern { action }' file, where:
- The pattern is a condition that is tested for each record.
- The action is performed for each record for which the pattern is true.
Either of the two can be omitted:
- Omit the pattern, like in the examples above, and the action is performed for every record.
- Omit the action, and every record that matches the pattern is printed in full.
Patterns
Patterns are often comparisons, such as $3 == "control" (column 3 equals “control”; note the double == and the quotes around the text). Patterns can also be combined, for example with && (“and”). To print all infected (i.e., non-control) samples from the 24hpi time point:
awk '$2 == "24hpi" && $3 != "control"' metadata.tsvS64 24hpi cathemerium
S67 24hpi cathemerium
S70 24hpi cathemerium
S71 24hpi relictum
S74 24hpi relictum
S65 24hpi relictum
S68 24hpi relictum
Here are the most important comparison and logical operators:
| Operator | Meaning |
|---|---|
a == b |
a is equal to b |
a != b |
a is not equal to b |
a < b / a > b |
a is less than / greater than b |
a <= b / a >= b |
a is less than or equal to / greater than or equal to b |
a ~ /b/ |
a matches regular expression b |
a !~ /b/ |
a does not match regular expression b |
a && b |
a and b |
a || b |
a or b |
!a |
not a |
As the ~ operator shows, awk can also use regular expressions, which go between forward slashes. For example, $1 ~ /^NW_/ would select features on the unplaced scaffolds.
You can also calculate in patterns. For example, the length of a GTF feature is its end position (column 5) minus its start position (column 4), plus 11. So, to print all genes longer than 1 Mb (1,000,000 bases):
awk -F '\t' '$3 == "gene" && $5 - $4 + 1 > 1000000' annot.gtfNC_068939.1 Gnomon gene 10362825 11542514 . + . gene_id "LOC120432606"; transcript_id ""; db_xref "GeneID:120432606"; description "RNA-binding protein Musashi homolog Rbp6"; gbkey "Gene"; gene "LOC120432606"; gene_biotype "protein_coding";
Only one gene, on chromosome 3, is that long.
Actions
Patterns and actions can of course be combined. For example, to print the sequence ID, start, end, and length of each gene:
awk -F '\t' '$3 == "gene" { print $1, $4, $5, $5 - $4 + 1 }' annot.gtf | head -n 3NC_068937.1 2046 110808 108763
NC_068937.1 48457 50554 2098
NC_068937.1 76295 82374 6080
5.3 BEGIN and END
Two special patterns are BEGIN and END, whose actions are performed once: before the first record and after the last record, respectively.
BEGIN is often used to set options. For example, setting the variable OFS (output field separator) to a Tab makes the commas in print produce Tabs instead of spaces:
awk -F '\t' 'BEGIN { OFS = "\t" } $3 == "gene" { print $1, $4, $5, $5 - $4 + 1 }' annot.gtf | head -n 3NC_068937.1 2046 110808 108763
NC_068937.1 48457 50554 2098
NC_068937.1 76295 82374 6080
END is useful for printing a summary after processing all records. For example, to compute the mean gene length, add up the lengths of all genes in a variable sum, count the genes in a variable n, and divide the two at the end:
awk -F '\t' '$3 == "gene" { sum += $5 - $4 + 1; n++ } END { print sum / n }' annot.gtf18961.9
In this command:
sum += xis shorthand forsum = sum + x, andn++forn = n + 1.- Two statements within one action are separated by a semicolon
;. - You don’t need to create (initialize)
sumandnbeforehand:awkvariables start out as 0.
5.4 Processing FASTQ files with awk
The built-in variable NR holds the current record (line) number. This is very handy for FASTQ files, where the sequence is always on the 2nd line of each 4-line record: the lines for which NR % 4 == 2 (the remainder after dividing the line number by 4 is 2). Combined with the length() function, this gives you the length of every read — and then, a frequency table of read lengths:
zcat S63_R1.fastq.gz | awk 'NR % 4 == 2 { print length($0) }' | sort | uniq -c | sort -nr | head -n 5 303297 74
123453 73
32678 35
28617 72
8483 71
Most reads are 72–74 bases long, but 32,678 reads are only 35 bases long: the all-N reads you saw in w4c and in the paste section above.
5.5 awk reference tables
Some useful built-in variables:
| Variable | Meaning |
|---|---|
$0 |
The entire record (by default, a line) |
$1, $2, … |
The first, second, … field (by default, a column) |
NR |
Number of the current record (in END: total number of records) |
NF |
Number of fields in the current record |
FS |
Input field separator (set with -F; default: any whitespace) |
OFS |
Output field separator (default: a space) |
Some useful functions and statements:
| Function / statement | Meaning |
|---|---|
length(s) |
Number of characters in s (length() alone: in the whole record) |
tolower(s) / toupper(s) |
s converted to lowercase / uppercase |
substr(s, start, n) |
The part of s that starts at position start and is n characters long |
split(s, arr, sep) |
Splits s into pieces on sep, and stores the pieces in array arr |
sub(regex, repl, s) |
Replaces the first match of regex in s by repl |
gsub(regex, repl, s) |
Replaces every match of regex in s by repl |
next |
Skips to the next record, without considering any remaining pattern-action pairs |
exit |
Stops processing records (any END action is still performed) |
Exercise 4: awk
A classmate wants to print the control samples, but makes a small typo:
awk '$3 = "control"' metadata.tsvWhat do you think this will print? Run the command to check.
Click to see the solution
It prints every line, with the third column replaced by
control(and with spaces instead of Tabs between the columns):sample_id time control S82 10dpi control S75 10dpi control [...]A single
=doesn’t compare but assigns a value: it sets column 3 tocontrolfor every line. Furthermore, the result of that assignment, the textcontrol, counts as “true”, so every line is printed. Whenawkchanges a field, it rebuilds the line usingOFS, which is a space by default.With
==, the command works as intended:awk '$3 == "control"' metadata.tsvCompute the mean length of exons. Why do you think it is so much shorter than the mean gene length of nearly 19,000 bases?
Click to see the solution
Change the pattern from
"gene"to"exon":awk -F '\t' '$3 == "exon" { sum += $5 - $4 + 1; n++ } END { print sum / n }' annot.gtf542.133Exons are only about 540 bases long on average, because most genes consist of multiple exons separated by introns, which are often much longer than the exons. The length of a gene spans all of its exons and introns.
Remove all reads shorter than 50 bases from
S63_R1.fastq.gz, and save the remaining reads in a new FASTQ fileS63_R1_filtered.fastq. How many reads are left?Click for a hint
Put each read on one line with
paste, filter withawk, and then turn each line back into a 4-line FASTQ record withtr. (Thelength()function can also take a specific field, likelength($2).)Click to see the solution
After
paste - - - -, the sequence is in column 2, so keep only lines withlength($2) >= 50. Then, replace the Tabs thatpasteinserted by newlines:zcat S63_R1.fastq.gz | paste - - - - | awk -F '\t' 'length($2) >= 50' | tr '\t' '\n' > S63_R1_filtered.fastq(A line that ends with a pipe
|continues on the next line, which makes long pipelines easier to read.)Count the reads by dividing the number of lines by 4:
wc -l S63_R1_filtered.fastq1869040 S63_R1_filtered.fastq1,869,040 / 4 = 467,260 reads are left, so 32,740 reads were shorter than 50 bases.
(In practice, you would use a dedicated read-trimming program for this, which can filter reads by length and quality in one go.)
6 sed to find and replace
6.1 Substitution with s/pattern/replacement/
sed (stream editor) is most often used to find and replace text, using the syntax s/pattern/replacement/, where s stands for “substitute”. Unlike tr, sed replaces whole patterns rather than single characters. So, here is how you can rename the control treatment to mock (recall Exercise 3a):
sed 's/control/mock/' metadata.tsv | head -n 8 | tail -n 4S83 10dpi cathemerium
S78 10dpi mock
S84 10dpi mock
S77 10dpi mock
The pattern is a regular expression, and by default, sed only replaces the first match on each line. To replace all matches, add the g (“global”) modifier at the end:
echo "AAACCCAAA" | sed 's/A/T/'TAACCCAAA
echo "AAACCCAAA" | sed 's/A/T/g'TTTCCCTTT
sed
Like the other commands on this page, sed does not change the input file, but prints its output to the screen. To save the output, redirect it to a new file (recall from w4c that you should never redirect to the input file itself):
sed 's/control/mock/' metadata.tsv > metadata_mock.tsvsed also has an -i option to edit a file in place, e.g. sed -i 's/control/mock/' metadata.tsv. This is convenient, but be careful: there is no undo, so make sure your command works before adding -i.
6.2 Selecting lines with addresses
By default, sed operates on every line. You can restrict it to certain lines by putting an “address” in front of the command: either a line number or range of line numbers, or a regular expression between slashes. For example, many tools use only the first “word” of a FASTA header as the sequence ID, and long headers can be a nuisance. To remove everything from the first space onwards, but only on header lines:
sed '/^>/ s/ .*//' genome.fna | grep '^>' | head -n 3>NC_068937.1
>NC_068938.1
>NC_068939.1
Besides s, two other handy sed commands are d (delete) and p (print):
sed '/^#/d' annot.gtfdeletes all lines starting with#, i.e. it does the same asgrep -v '^#' annot.gtf.sed '1d' metadata.tsvdeletes the first line, i.e. it does the same astail -n +2 metadata.tsv.sed -n '12,13p' genome.fnaprints lines 12 to 13. The-noption turns offsed’s default printing of every line, so only the lines selected withpare printed.
6.3 Using a different delimiter
The / in s/pattern/replacement/ can be replaced by (almost) any other character. This is useful when the pattern or replacement contains slashes, like file paths. For example, to remove the dirs from a list of file paths (the .*/ greedily matches everything up to and including the last /):
ls ../../garrigos/fastq/*fastq.gz | sed 's#.*/##' | head -n 3S63_R1.fastq.gz
S63_R2.fastq.gz
S64_R1.fastq.gz
To apply multiple substitutions in one sed command, put -e before each one. For example, to also remove the read direction and extension, and get a list of sample IDs:
ls ../../garrigos/fastq/*fastq.gz | sed -e 's#.*/##' -e 's/_R[12]\.fastq\.gz$//' | sort -u | head -n 3S63
S64
S65
6.4 Capture groups and backreferences
A powerful regex feature is that you can capture parts of a match by putting them in parentheses ( ), and then recall them in the replacement with \1 (the first group), \2 (the second group), and so on. (Use sed -E, so that the parentheses don’t need backslashes.)
For example, genomic regions are often written as <seqID>:<start>-<end>. To turn such a region into three Tab-separated columns:
echo "NC_068937.1:2046-110808" | sed -E 's/^([^:]+):([0-9]+)-([0-9]+)$/\1\t\2\t\3/'NC_068937.1 2046 110808
Here, ([^:]+) captures everything up to the colon, and each ([0-9]+) captures a number.
Capture groups are also a way to extract information from a line. For example, to extract the gene ID from the GTF’s gene lines, match the entire line, and replace it by just the captured ID:
awk -F '\t' '$3 == "gene"' annot.gtf | sed -E 's/.*gene_id "([^"]+)".*/\1/' | head -n 3LOC120427725
LOC120427726
LOC128092783
Let’s try the same with the transcript IDs, on all lines of the GTF file:
grep -v '^#' annot.gtf | sed -E 's/.*transcript_id "([^"]+)".*/\1/' | head -n 3NC_068937.1 Gnomon gene 2046 110808 . + . gene_id "LOC120427725"; transcript_id ""; db_xref "GeneID:120427725"; description "homeotic protein deformed"; gbkey "Gene"; gene "LOC120427725"; gene_biotype "protein_coding";
XM_052707445.1
XM_052707445.1
What went wrong in the first line? Gene lines have an empty transcript ID (transcript_id ""), which doesn’t match [^"]+ (one or more non-quote characters). And when the pattern doesn’t match, sed simply prints the line unchanged.
The solution is to combine the -n option with the p modifier, which prints a line only if a substitution was made:
grep -v '^#' annot.gtf | sed -E -n 's/.*transcript_id "([^"]+)".*/\1/p' | head -n 3XM_052707445.1
XM_052707445.1
XM_052707445.1
Exercise 5: sed
Above,
sed 's#.*/##'removed the dirs from a file path. What do you think the command below will print instead? Run it to check.echo "../../garrigos/fastq/S63_R1.fastq.gz" | sed 's#/.*##'Click to see the solution
..The pattern
/.*matches the first/and everything after it, so only the first..is left.Create a version of the metadata file in which the
timecolumn holds the number of hours post-infection:240instead of10dpi, and24instead of24hpi. Also rename the column header fromtimetotime_h— make sure that only the header is affected.Click for a hint
Use
-efor multiple substitutions, and an address to restrict the header change to line 1.Click to see the solution
sed -e '1s/time/time_h/' -e 's/10dpi/240/' -e 's/24hpi/24/' metadata.tsv > metadata_hours.tsv head -n 3 metadata_hours.tsvsample_id time_h treatment S82 240 cathemerium S75 240 cathemeriumIn this case,
s/time/time_h/without the1address would have worked too, because no other line containstime. But with the address, you can be sure that only the header line is changed.Do the reverse of the
sedcapture group example above: for each gene in the GTF file, print its region in the format<seqID>:<start>-<end>, e.g.NC_068937.1:2046-110808. Can you think of a way to do this withawkinstead?Click to see the solution
With
sed, first select the gene lines and the relevant columns, and then capture and rearrange the three columns:awk -F '\t' '$3 == "gene"' annot.gtf | cut -f 1,4,5 | sed -E 's/^([^\t]+)\t([0-9]+)\t([0-9]+)$/\1:\2-\3/' | head -n 3NC_068937.1:2046-110808 NC_068937.1:48457-50554 NC_068937.1:76295-82374With
awk, this is a lot simpler: print the columns separated by the desired text (between quotes):awk -F '\t' '$3 == "gene" { print $1 ":" $4 "-" $5 }' annot.gtf | head -n 3As you can see, there is often more than one way to do something with these tools.
7 Putting it together: genes per chromosome
As a final example, let’s combine several of the tools on this page to answer a biological question: how gene-dense is each chromosome? To compute the number of genes per million bases (Mb) for each sequence, you’ll need information from two files: sequence lengths from the genome FASTA, and gene counts from the GTF. You’ll then merge these with a new command, join.
7.1 Step 1: Sequence lengths from the FASTA file
Computing the length of each sequence in a FASTA file takes a slightly more complex awk command, because each sequence is spread across many lines:
awk '/^>/ { if (id) print id "\t" len; id = substr($1, 2); len = 0; next }
{ len += length($0) }
END { print id "\t" len }' genome.fna | sort -k1,1 > seq_lengths.tsvHere is what this does:
- For header lines (pattern
/^>/):- If this isn’t the first header, print the ID and length of the previous sequence.
- Store the new sequence ID: the first field without the
>(substr($1, 2)takes everything from the 2nd character onwards). - Reset the length to 0, and skip to the next line (
next).
- For sequence lines (no pattern, but header lines never get here because of
next): add the number of bases on the line to the length. - At the end, print the ID and length of the last sequence, which hasn’t been printed yet.
- Finally, sort by sequence ID, which
joinwill need.
head -n 4 seq_lengths.tsvNC_015079.1 14856
NC_068937.1 139153999
NC_068938.1 224097512
NC_068939.1 186194774
7.2 Step 2: Gene counts from the GTF file
To count the genes on each sequence, make a frequency table of column 1 for the gene lines. Then, swap the two columns of uniq -c’s output with awk, so that the sequence ID comes first, as in the other file:
awk -F '\t' '$3 == "gene"' annot.gtf | cut -f 1 | sort | uniq -c |
awk '{ print $2 "\t" $1 }' > gene_counts.tsv
head -n 4 gene_counts.tsvNC_015079.1 13
NC_068937.1 5173
NC_068938.1 7532
NC_068939.1 6248
7.3 Step 3: Merging the two files with join
join merges two files based on a shared column — by default, the first column of each file. For every sequence ID that occurs in both files, it prints the ID followed by the remaining columns of both files:
join seq_lengths.tsv gene_counts.tsv | head -n 4NC_015079.1 14856 13
NC_068937.1 139153999 5173
NC_068938.1 224097512 7532
NC_068939.1 186194774 6248
join requires both files to be sorted on the join column. If they aren’t, it will miss matches (usually, but not always, with a warning).
The joined output has fewer lines than seq_lengths.tsv. Which sequences are missing, and why?
join only prints lines whose ID occurs in both files (this is called an “inner join”). Sequences without any annotated genes don’t occur in gene_counts.tsv, so they’re left out. To also keep the lines that only occur in the first file, use join -a 1.
7.4 Step 4: Computing gene density
Finally, use awk to divide the number of genes (column 3) by the length in Mb (column 2 divided by 1,000,000), and only keep the sequences with NC IDs:
join seq_lengths.tsv gene_counts.tsv |
awk '{ print $1, $2, $3, $3 / ($2 / 1000000) }' |
grep '^NC'NC_015079.1 14856 13 875.067
NC_068937.1 139153999 5173 37.1746
NC_068938.1 224097512 7532 33.6104
NC_068939.1 186194774 6248 33.5563
So, the three chromosomes have a similar gene density of about 34–37 genes per Mb. The mitochondrial genome (NC_015079.1) is far more gene-dense: its 13 genes are packed into fewer than 15,000 bases.
For more complex merges of tables, R is a better tool: its dplyr package, which you’ll learn about in week 13, has a set of flexible join functions.
8 Wrap-up: which tool when?
| Task | Tool(s) |
|---|---|
| Find lines that contain a (regex) pattern | grep |
| Extract the matching part of lines | grep -o, or sed -E with capture groups |
| Select columns | cut; awk to also reorder them |
| Select rows based on the values in a column | awk |
| Calculate with values in columns | awk |
| Replace text | sed; tr for single characters |
| Count or summarize values | sort and uniq -c |
| Merge two tables | join |
These tools shine for quick, one-off questions about (large) files, and for small processing steps in shell scripts. But when a pipeline gets long or hard to read, it’s often better to switch to:
- R or Python, for more complex data wrangling, statistics, and plotting (you’ll start learning R in week 12).
- Dedicated bioinformatics tools, for format-specific tasks like filtering or reverse complementing reads and sequences. Such tools handle the details of file formats (like multi-line FASTA sequences, or soft-masked bases) for you.
Finally, now that you’re done with this page, remove the large genome FASTA file:
rm genome.fnaReferences
Footnotes
The plus 1 is needed because GTF coordinates are “1-based and inclusive”: a feature with start and end position 2046 is 1 base long. Watch out: in other formats, like BED, this is not the case.↩︎