Bioinformatics File Formats Explained: FASTA, FASTQ, SAM/BAM, VCF, GTF and BED
Chapter 4 of 5: Variants and annotation – VCF, BCF, GFF3, GTF and BED
VCF: the variant call format
A VCF file has three parts: meta-information lines starting with ##, one header line starting with #CHROM, and data lines, one per variant site. Let’s write a small VCF by hand. Its four variants are real events in our toy reads: r001 carries a 4-base insertion and a 1-base deletion, read x3 a 4-base insertion, and read x1 a T→A mismatch. The quality values are made up for practice (illustration, not a real call set).
VCF columns must be separated by tabs, and tabs are awkward to type. So we type spaces and let sed convert them on every line that does not start with ## (the meta-lines contain spaces inside descriptions that must stay).
cat << 'EOF' | sed -E '/^##/!s/ +/\t/g' > toy.vcf
##fileformat=VCFv4.2
##contig=<ID=ref,length=45>
##contig=<ID=ref2,length=40>
##FILTER=<ID=LowQual,Description="Quality below 20">
##INFO=<ID=DP,Number=1,Type=Integer,Description="Total read depth">
##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">
##FORMAT=<ID=DP,Number=1,Type=Integer,Description="Read depth">
#CHROM POS ID REF ALT QUAL FILTER INFO FORMAT toy
ref 14 . A AAGAG 30 PASS DP=2 GT:DP 0/1:2
ref 18 . AG A 25 PASS DP=2 GT:DP 0/1:2
ref2 14 . C CAAAT 40 PASS DP=4 GT:DP 0/1:4
ref2 17 . T A 12 LowQual DP=5 GT:DP 0/1:5
EOF
cat -A toy.vcf | sed -n '8,9p'cat << 'EOF' … EOF: a “heredoc”: everything you type until the lineEOFis sent into the pipe.sed -E '/^##/!s/ +/\t/g': on lines not (!) starting with##, replace each run of spaces with one tab.cat -A: shows hidden characters. Tabs appear as^Iand line ends as$.

| Col | Field | Meaning |
|---|---|---|
| 1 | CHROM | Sequence name; must match the reference and the ##contig lines |
| 2 | POS | 1-based position of the first base in REF |
| 3 | ID | Variant ID such as a dbSNP rs number, or . |
| 4 | REF | Reference base(s) at POS |
| 5 | ALT | Alternative allele(s), comma-separated |
| 6 | QUAL | Phred-scaled confidence that the ALT call is wrong: −10 log10 P(no variant) |
| 7 | FILTER | PASS, the names of failed filters (e.g. LowQual), or . if no filters were applied |
| 8 | INFO | Site-level key=value pairs separated by ; |
| 9 | FORMAT | Keys for the per-sample columns, e.g. GT:DP |
| 10+ | samples | One column per sample, values in FORMAT order |
Why does the deletion start at 18, not 19?
In r001, the deleted base is the G at position 19. But an allele in VCF may never be empty, so a deletion is written with the base before it: REF AG (positions 18–19), ALT A, POS 18. Insertions work the same way: A → AAGAG at 14 means “after the A at 14, insert AGAG”. This padding base is required by the specification for simple indels.
Genotypes
| GT | Meaning |
|---|---|
0/0 | Homozygous reference (both copies = REF) |
0/1 | Heterozygous (one REF, one ALT) |
1/1 | Homozygous alternative |
1/2 | Two different ALT alleles (multi-allelic site) |
./. | No call (missing) |
0|1 | Phased: the | says which allele is on which chromosome copy |
Step 9: Compress, index and query the VCF
bgzip toy.vcf
tabix -p vcf toy.vcf.gz
bcftools view -H toy.vcf.gz
bcftools view -H -r ref2 toy.vcf.gz
bcftools view -H -f PASS toy.vcf.gz | wc -lbgzip: compresses with BGZF and replacestoy.vcfwithtoy.vcf.gz. Use bgzip, not gzip.tabix -p vcf: buildstoy.vcf.gz.tbi(bcftools index -tdoes the same).bcftools view -H: print data lines without the header.-r ref2: only one region (needs the index).-f PASS: only sites that passed all filters.
bcftools query turns a VCF into a clean table, which is the easiest way to get variants into R, Python or Excel:
bcftools query -f '%CHROM\t%POS\t%REF\t%ALT\t%QUAL\t%FILTER[\t%GT]\n' toy.vcf.gz
bcftools norm -f toy.fa -c e -Ou toy.vcf.gz > /dev/null && echo "REF alleles match toy.fa"-f '…': the output template.%CHROM,%POS… are site fields; anything inside[ ]repeats for every sample.bcftools norm -f toy.fa -c e: checks every REF allele against the reference and stops with an error (e) on a mismatch.-Ouwrites uncompressed BCF, which we throw away.
The REF check is the best single test that a VCF belongs to a reference. A VCF from GRCh37 checked against GRCh38 fails on thousands of sites.
▸ Go deeper: Number=A, R, G and why normalisation matters
Each ##INFO and ##FORMAT line declares how many values a field holds. Number=1 is one value; A is one per ALT allele (allele frequency); R is one per allele including REF (allelic depths, AD); G is one per possible genotype (genotype likelihoods, PL); . is variable. Tools use these declarations to split multi-allelic sites correctly.
The same indel can often be written in several ways inside a repeat (an A deleted from AAAA can sit at any of the four positions). Tan et al. (2015) defined a normal form: left-aligned and as short as possible. bcftools norm -f ref.fa left-aligns indels, and bcftools norm -m -any splits multi-allelic sites into one line per ALT. Normalise before you compare two VCFs or annotate with a database, or identical variants will look different.
# Left-align an indel (simplified idea, pseudo-code)
while REF and ALT end with the same base:
remove that last base from both
if REF or ALT becomes empty:
prepend the reference base before POS to both; POS -= 1🔬 Researcher notes: VCF versions, BCF and gVCF
The current specification is VCFv4.5 with BCFv2.2 (hts-specs, February 2026). Many tools still write ##fileformat=VCFv4.2, which is fine: later versions mostly add features (for example local alleles and better structural-variant fields). Inside BCF, the binary form, positions are stored 0-based, but bcftools always prints 1-based VCF.
QUAL is defined as −10 log10 P(call in ALT is wrong); it is a site-level number and is not comparable between callers. For filtering, GATK recommends VQSR or hard filters on annotations such as QD, FS and MQ rather than QUAL alone, and FILTER should record what you applied.
A gVCF also stores non-variant blocks (using <NON_REF> in GATK or <*> in bcftools, with an END INFO field), so you can tell “reference with good coverage” apart from “no data”. This is what joint genotyping of many samples needs.
Missing vs reference matters in population analyses: ./. is not 0/0. When you merge VCFs with bcftools merge, sites absent from one sample become missing unless you use gVCFs or --missing-to-ref (which assumes reference, and should be reported).
GFF3: gene models with a hierarchy
GFF3 describes features on a sequence: genes, mRNAs, exons, CDS, UTRs. It has nine tab-separated columns, and its 9th column links features into a tree with ID and Parent. Let’s write a tiny gene, toyA, on ref: two exons, and a coding sequence (CDS) that starts with the ATG at positions 4–6 and ends with the TAG stop at 26–28. (A made-up gene for practice, not real biology.)
cat << 'EOF' | sed -E '/^#/!s/ +/\t/g' > toy.gff3
##gff-version 3
ref bhl gene 1 40 . + . ID=gene1;Name=toyA
ref bhl mRNA 1 40 . + . ID=tx1;Parent=gene1
ref bhl exon 1 15 . + . ID=exon1;Parent=tx1
ref bhl exon 20 40 . + . ID=exon2;Parent=tx1
ref bhl CDS 4 15 . + 0 ID=cds1;Parent=tx1
ref bhl CDS 20 28 . + 0 ID=cds1;Parent=tx1
EOF
grep -v "^#" toy.gff3 | cut -f 3 | sort | uniq -c| Col | Name | Meaning | In toy.gff3 |
|---|---|---|---|
| 1 | seqid | Sequence the feature is on | ref |
| 2 | source | Program or database that made it | bhl |
| 3 | type | Feature type, a Sequence Ontology term | gene, mRNA, exon, CDS |
| 4–5 | start, end | 1-based, inclusive; start ≤ end | 1, 15 |
| 6 | score | A number, or . | . |
| 7 | strand | +, -, . (none) or ? (unknown) | + |
| 8 | phase | For CDS only: 0, 1 or 2 bases to skip to the next codon start | 0 |
| 9 | attributes | tag=value pairs separated by ; (ID, Name, Parent, …) | ID=exon1;Parent=tx1 |
Read the tree from the bottom up: each exon and CDS has Parent=tx1, and tx1 has Parent=gene1. A gene with three transcripts has three mRNA lines, and an exon shared by two transcripts can list both: Parent=tx1,tx2. Both CDS lines share ID=cds1, which is how GFF3 says “these pieces form one CDS”.
▸ Go deeper: computing CDS phase
Phase says how many bases at the start of a CDS piece belong to a codon that began in the previous piece. The first piece has phase 0. Each next piece’s phase follows from the bases used so far:
# Phase of each CDS piece, in transcription order (pseudo-code)
phase = 0
for piece in cds_pieces_in_5prime_to_3prime_order:
write(piece, phase)
length = piece.end - piece.start + 1
phase = (3 - ((length - phase) % 3)) % 3
# toyA: piece 1 is 4-15 (12 bp, phase 0): (3 - (12 % 3)) % 3 = 0, so piece 2 has phase 0For genes on the minus strand, “5′ to 3′ order” means from the highest coordinate down. Getting phase wrong shifts the reading frame and produces nonsense proteins, so let tools such as gffread compute it.
GTF: the format most RNA-seq tools want
GTF (also called GFF2.5) has the same first eight columns as GFF3, but a different 9th column: key "value"; pairs separated by spaces, and every line carries gene_id and transcript_id instead of an ID/Parent tree. An Ensembl GTF line looks like this:
1 havana exon 3069168 3069296 . + . gene_id "ENSG00000142611"; gene_version "17"; transcript_id "ENST00000511072"; transcript_version "5"; exon_number "1"; gene_name "PRDM16"; …(The first exon line of the human GTF from Ensembl release 115; the attribute list is shortened here. Note the chromosome is 1, not chr1.)
| GFF3 | GTF | |
|---|---|---|
| Attribute style | ID=tx1;Parent=gene1 | gene_id "g1"; transcript_id "t1"; |
| Hierarchy | Any depth, via ID and Parent | Two levels: gene and transcript IDs on every line |
| Feature types | Sequence Ontology terms | A fixed set (gene, transcript, exon, CDS, start_codon, stop_codon, UTR, …) |
| Typical users | Genome annotation, Ensembl/NCBI downloads, JBrowse | STAR, featureCounts, HTSeq, Salmon/tximport, Cell Ranger |
| Coordinates | 1-based, inclusive | 1-based, inclusive |
Use the GTF and the FASTA from the same source and release. Convert between the two with gffread (gffread in.gff3 -T -o out.gtf) rather than by hand.
🔬 Researcher notes: annotation sources and versions
For human and mouse, GENCODE (Frankish et al., 2023) and Ensembl share the same gene models; GENCODE files use chr names, Ensembl files do not. NCBI RefSeq annotation differs in transcript set and in IDs (NM_/XM_). Results such as counts per gene, the number of isoforms and splice-junction novelty depend on this choice, so report the source and release number (for example “Ensembl release 115” or “GENCODE comprehensive annotation, release N”).
GFF3 type names must be Sequence Ontology terms or SO accession numbers (Eilbeck et al., 2005; GFF3 specification v1.26). Prefer the GFF3 file when you need the full hierarchy (for example non-coding RNAs, pseudogenes, multiple parents), and the GTF when a quantification tool requires it. Programs like AGAT and gffread (Pertea and Pertea, 2020) validate files and fix common problems such as missing phases or duplicate IDs.
BED: intervals, 0-based
BED is the simplest interval format: chromosome, start, end, and up to nine optional columns. Only the first three are required. The specification allows spaces or tabs between fields, but tools such as bedtools need tabs.
| Col | Name | Meaning |
|---|---|---|
| 1 | chrom | Sequence name |
| 2 | chromStart | Start, 0-based |
| 3 | chromEnd | End, not included (half-open) |
| 4 | name | Feature name |
| 5 | score | Integer 0–1000 (use 0 if meaningless) |
| 6 | strand | +, - or . |
| 7–8 | thickStart, thickEnd | Part drawn thick in browsers (the CDS) |
| 9 | itemRgb | Colour, e.g. 255,0,0 |
| 10–12 | blockCount, blockSizes, blockStarts | Exons of a spliced feature on one line (BED12); blockStarts are relative to chromStart |
▸ Go deeper: BED12, a whole transcript on one line
BED12 stores a spliced feature in one line. Columns 7–8 (thickStart, thickEnd) mark the coding part, and columns 10–12 list the exons as blocks. Block starts are relative to chromStart, and the last block must end exactly at chromEnd (BED specification v1).
ref 0 40 toyA 0 + 3 28 0 2 15,21 0,19Read it as: transcript toyA on ref from 0 to 40; coding part 3–28 (bases 4–28 in 1-based terms); colour 0; 2 blocks of sizes 15 and 21, starting at 0 + 0 = 0 and 0 + 19 = 19. The second block ends at 19 + 21 = 40, equal to chromEnd, as required. RSeQC and the UCSC browser use this layout.
🔬 Researcher notes: BED files in real projects
Sort BED files with sort -k1,1 -k2,2n (or bedtools sort) before merging, indexing or intersecting large files; bedtools then runs a fast, low-memory sweep with -sorted, which also needs the same chromosome order as a genome file (-g genome.txt, the first two columns of the .fai).
Typical operations on capture or peak sets: bedtools merge joins overlapping intervals, bedtools slop -b 100 -g genome.txt pads targets by 100 bp on each side (common for exome variant calling), and bedtools intersect -v removes regions such as the ENCODE blacklist. Report the exact target BED file and any padding in your Methods, because coverage and variant counts depend on them.
Step 10: Convert GFF3 to BED and prove it is right
Turn the exons and CDS of toy.gff3 into BED with awk. The only change is start − 1:
awk -v OFS='\t' '$3=="exon" {print $1, $4-1, $5, "exon", 0, $7}' toy.gff3 > exons.bed
awk -v OFS='\t' '$3=="CDS" {print $1, $4-1, $5, "CDS", 0, $7}' toy.gff3 > cds.bed
cat exons.bed cds.bed-v OFS='\t': print columns separated by tabs.$3=="exon": only lines whose 3rd column isexon.$4-1, $5: 1-based start minus one; the end stays the same.
Now check that the BED interval and the 1-based region give exactly the same bases:
bedtools getfasta -fi toy.fa -bed exons.bed
samtools faidx toy.fa ref:1-15
bedtools getfasta -fi toy.fa -bed cds.bedSame 15 bases from ref:0-15 (BED, 0-based) and ref:1-15 (samtools, 1-based). Joined, the two CDS pieces read ATG TTA GAT AAG CTG TGC TAG: start codon, five codons, stop. The coordinates are right.
Step 11: Combine formats
Real questions need several formats at once. Which variants hit the coding sequence? How many reads overlap the exons?
bedtools intersect -a toy.vcf.gz -b cds.bed | cut -f 1-7
samtools view -c -L exons.bed toy.bambedtools intersect -a VCF -b BED: prints the VCF lines that overlap any BED interval. bedtools handles the 1-based VCF and 0-based BED for you.samtools view -c -L exons.bed: counts alignments that overlap the BED regions.
Only the 4-base insertion at ref:14 falls in the CDS. Four bases is not a multiple of three, so in a real gene it would be a frameshift. The deletion at 18–19 sits in the intron (16–19), so it is not coding.
▸ Go deeper: bedGraph and bigWig for coverage tracks
A bedGraph is BED3 plus a value: chrom start end value, 0-based. bigWig is the indexed binary version that genome browsers load over the internet (Kent et al., 2010). Make one from a BAM:
bedtools genomecov -bga -ibam toy.bam | sort -k1,1 -k2,2n > toy.bedgraph
cut -f 1,2 toy.fa.fai > toy.chrom.sizes
bedGraphToBigWig toy.bedgraph toy.chrom.sizes toy.bw-bga reports every interval, including zero coverage. bedGraphToBigWig (Bioconda package ucsc-bedgraphtobigwig) needs a sorted bedGraph and a chromosome-sizes file, which is just the first two columns of the .fai. For normalised tracks (CPM, RPGC), deepTools bamCoverage is the usual choice.
Quick check: a GFF3 exon is on chr2 from 5,001 to 5,100. Write it as BED3 and give its length.
chr2 5000 5100, length 100: 5,100 − 5,000 = 100, the same as 5,100 − 5,001 + 1.