Chatomics Field GuideWhat They Don't Teach You

Conversion · GTF → BED

How to Convert GTF to BED (and Keep Your Coordinates Right)

gtf2bed subtracts one from the start coordinate for you; a naive awk one-liner won't, and that gap costs you a silent off-by-one bug three steps downstream.

By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Updated 2026-09-13 · 3 min read

GTF
.gtf, .gtf.gz · coordinates: 1-based-closed
BED
.bed, .bed.gz · coordinates: 0-based-half-open

You need GTF-to-BED when you're feeding gene models into interval tools that only speak BED: bedtools intersect against a peak set, a custom IGV track, or subsetting exons before running bedtools getfasta. GTF carries the full annotation (gene, transcript, exon, CDS, UTR, start_codon) in one file; BED tools want flat chrom/start/end intervals, so part of the conversion is deciding what to keep and what to throw away.

Three things change on the way over. The coordinate system shifts from GTF's 1-based closed intervals to BED's 0-based half-open intervals, so every start needs a -1. The rich attribute column collapses into a single BED name field (BEDOPS gtf2bed picks gene_id by default), so everything else in that column is gone unless you extract it separately. And GTF stacks multiple feature types for the same locus, gene, transcript, exon, CDS; convert the whole file without filtering and your BED ends up with redundant, overlapping intervals for one gene.

The failure mode that bites hardest is the quiet one: a hand-rolled awk '{print $1, $4, $5}' that forwards the GTF start straight through without subtracting 1. Everything still runs. bedtools intersect still returns results, IGV still draws a track. It's just shifted by one base, which is exactly wrong enough to corrupt boundary-sensitive work, TSS calls, splice sites, motif positions, while looking completely fine everywhere else.

The commands

Type your file names once; every command below updates.

  1. 01gtf2bed (BEDOPS)v2.4.41

    bash
    gtf2bed < sample.gtf > sample.bed

    Converts every line of the GTF to sorted BED, subtracting 1 from each start coordinate for you and writing sorted output by default. Assumes gtf2bed is on your PATH from the BEDOPS package, and uses gene_id as the BED name column unless you override it.

  2. 02awk + gtf2bed (BEDOPS)

    bash
    awk '$3=="exon"' sample.gtf | gtf2bed > sample.bed

    Filters to one feature type before converting, so you don't end up with overlapping gene+transcript+exon+CDS intervals stacked on the same locus. Swap "exon" for "gene" or "transcript" depending on what your downstream tool expects.

  3. 03gtf2bed (BEDOPS)v2.4.41

    bash
    gtf2bed --attribute-key=transcript_id < sample.gtf > sample.bed

    Puts transcript_id instead of the default gene_id into BED column 4. Missing values fall back to '.', so check that the attribute you ask for actually exists on the feature type you kept.

  4. 04UCSC tools (gtfToGenePred, genePredToBed)

    bash
    gtfToGenePred sample.gtf tmp.genePred && genePredToBed tmp.genePred sample.bed

    Produces BED12 with block starts and sizes that preserve exon structure per transcript, plus strand and frame, instead of gtf2bed's flat one-interval-per-line output. Requires downloading both binaries from the UCSC utilities directory and assumes the GTF has consistent transcript_id/gene_id structure.

  5. 05GNU sort

    bash
    sort -k1,1 -k2,2n sample.gtf > sample.bed

    Re-sorts a BED file by chromosome then numeric start, which bedtools and BEDOPS require for merge, closest, and intersect operations. Needed if you ran gtf2bed with --do-not-sort or hand-edited the file afterward.

Coordinates, strand, names, builds

GTF is 1-based and closed on both ends: a feature with start=100, end=100 is a single base at position 100. BED is 0-based and half-open: that same base is written start=99, end=100, so end minus start gives you the feature length directly. gtf2bed applies the -1 to start automatically and leaves end untouched; a manual cut or awk conversion that copies both columns as-is is off by one at the start on every line. Strand lives in GTF column 7 and BED column 6; BEDOPS and bedtools both pass it through unchanged as + or -, but BED strand is positional, not named, so a 4-column BED has no strand field at all and a manual conversion that drops or reorders columns 4 and 5 (name/score) will misplace it. Chromosome naming doesn't change format-to-format, but it has to match your genome FASTA and any BED/BAM files you intersect against: Ensembl GTFs typically use bare 1, 2, MT, while UCSC and GENCODE GTFs and most BAMs use chr1, chr2, chrM. A mismatch here doesn't error, bedtools intersect just silently returns zero rows. GTF's zero-length-insertion edge case, where start equals end, is normalized by gtf2bed, which decrements start and tags the record so a 0-length feature doesn't vanish. Genome build is never encoded in either file, so a GTF built against GRCh38 intersected with a BED lifted from GRCh37 coordinates produces nonsense with no warning; track build in your filenames.

Check the output before you trust it

  1. 01Row count matches the filtered feature type, not the whole GTF

    bash
    wc -l < output.bed
    awk '$3=="exon"' input.gtf | wc -l

    Expected wc -l on output.bed equals the count of matching feature-type lines in the GTF (e.g. exon), not the total GTF line count

  2. 02Start coordinate is GTF start minus 1

    bash
    grep -P '\texon\t' input.gtf | head -1
    head -1 output.bed

    Expected For any spot-checked feature, BED start equals GTF start - 1, and BED end equals GTF end unchanged

  3. 03BED file is sorted for bedtools and BEDOPS

    bash
    sort -k1,1 -k2,2n -c output.bed

    Expected sort -c exits 0 with no output; a 'disorder' message means you must sort before running merge, closest, or bedops operations

  4. 04Chromosome naming matches your reference FASTA

    bash
    cut -f1 output.bed | sort -u
    cut -f1 genome.fa.fai | sort -u

    Expected The set of names in column 1 uses the same convention (chr1 vs 1) as your genome.fa.fai, not just similar-looking names

  5. 05Strand column only contains +, -, or .

    bash
    cut -f6 output.bed | sort | uniq -c

    Expected uniq -c on column 6 shows only +, -, and optionally .; anything else means columns shifted during conversion

  6. 06Extracted sequence length matches the interval length

    bash
    bedtools getfasta -fi genome.fa -bed output.bed -fo check.fa
    awk '/^>/{next}{print length($0)}' check.fa | head

    Expected FASTA sequence length from bedtools getfasta equals end - start for that row

Errors you will see, and what they mean

bedtools intersect -a output.bed -b peaks.bed returns nothing, even though the regions clearly overlap in IGV
Cause: Chromosome naming doesn't match between files, one uses chr1, the other uses 1. bedtools does not warn about this, it just finds zero overlaps. Fix: Normalize both files to the same convention, e.g. awk 'BEGIN{OFS="\t"}{$1="chr"$1; print}' file.bed, and confirm with cut -f1 *.bed | sort -u before rerunning.
Every feature in the BED looks shifted one base to the left of the GTF record or annotation browser
Cause: A manual awk '{print $1, $4, $5}' or cut conversion copied the GTF start straight through without subtracting 1 for BED's 0-based system. Fix: Use gtf2bed (BEDOPS), which applies the -1 automatically, or explicitly compute $4-1 in your own awk conversion.
BED file has several overlapping intervals for what should be one gene
Cause: The whole GTF, gene, transcript, exon, CDS, and start_codon lines for the same locus, was converted without filtering by feature type (column 3). Fix: Filter first: awk '$3=="exon"' input.gtf | gtf2bed > output.bed, and decide up front whether you want gene-, transcript-, or exon-level intervals.
bedtools merge or closest complains the input is not sorted, or silently returns wrong nearest-feature results
Cause: The BED file wasn't sorted by chromosome then numeric start (sort -k1,1 -k2,2n); gtf2bed --do-not-sort skips this step entirely. Fix: Run sort -k1,1 -k2,2n output.bed > output.sorted.bed, or drop --do-not-sort from your gtf2bed call.
BED column 4 (name) is just a column of periods
Cause: The --attribute-key you chose doesn't exist on those GTF lines, e.g. gene_type is present on gene lines but missing on exon lines, and gtf2bed defaults missing values to '.'. Fix: Check which attributes your GTF actually carries per feature type with awk '$3=="exon"' input.gtf | head -1, and pick an attribute-key present on the lines you kept, or fall back to the default gene_id.

Questions people ask

Why does converting GTF to BED shift my coordinates?

GTF is 1-based with both start and end inclusive; BED is 0-based with the end exclusive. Every start position needs 1 subtracted from it to represent the same base range, and tools like BEDOPS gtf2bed do that automatically. If you write your own awk or cut conversion and skip the -1, every interval starts one base too far to the right.

Should I convert the whole GTF or filter to one feature type first?

Filter first. A GTF stacks gene, transcript, exon, CDS, and start/stop codon lines on top of each other for the same locus, and converting all of them gives you overlapping, redundant BED intervals. Use awk '$3=="exon"' (or gene, transcript, etc.) before piping into gtf2bed, matching whatever feature level your downstream tool expects.

How do I keep exon structure (BED12) instead of one flat interval per gene?

Standard gtf2bed output is one flat interval per line, so multi-exon transcripts collapse unless you convert at the exon level. For a proper BED12 with block starts and sizes that preserve exon structure, use the UCSC tool chain: gtfToGenePred to an intermediate genePred file, then genePredToBed.

Why does bedtools intersect return zero results after I convert GTF to BED?

The most common cause is a chromosome-naming mismatch, not a coordinate bug: one file uses chr1 and the other uses 1. bedtools does not error on this, it just finds no overlaps. Check cut -f1 on both files and normalize the prefix before intersecting.

Does gtf2bed handle strand correctly?

Yes, BEDOPS gtf2bed carries GTF's strand column straight through into BED column 6 unchanged. Watch the downstream side: BED strand is positional, so if a manual conversion drops or reorders columns 4 and 5 (name/score), tools will misread the strand column or find none at all.

Related reading on the blog

Sources

  1. 6.3.3.6. gtf2bed, BEDOPS v2.4.41 — Coordinate conversion behavior, --attribute-key, and --do-not-sort options
  2. Frequently Asked Questions: Data File Formats — Official BED field definitions and coordinate convention
  3. Overview, bedtools 2.31.0 documentation — bedtools 0-based, half-open coordinate handling
  4. How to convert GTF format into BED12 or BIGBED format? — BED12 conversion via UCSC gtfToGenePred/genePredToBed and BED sorting convention
  5. The Most Common Stupid Mistakes In Bioinformatics — Off-by-one coordinate errors cited as the #1 bioinformatics mistake