Chatomics Field GuideWhat They Don't Teach You

Conversion · GFF3 → GTF

How to Convert GFF3 to GTF (and Why IDs Go Missing)

gffread -T looks like a one-command fix until your NCBI GFF3 comes out the other side missing gene_id on half its transcripts.

By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 3 min read

GFF3
.gff3, .gff · coordinates: 1-based-closed
GTF
.gtf, .gtf.gz · coordinates: 1-based-closed

You need this conversion whenever an upstream annotation source ships GFF3, usually NCBI RefSeq or a non-model organism annotation, but the tool downstream (STAR, featureCounts, Cell Ranger's mkref) expects GTF. GFF3 is the more expressive format: it builds gene, transcript, and exon relationships through generic ID and Parent attributes, and it can carry arbitrary key-value metadata. GTF flattens all of that into one required pair of attributes per line, gene_id and transcript_id, which is exactly what makes it easy for tools to parse and exactly what makes conversion lossy.

gffread -T is the standard way to do this, and it works cleanly on well-formed GFF3 where every exon points to an mRNA and every mRNA points to a gene. What gets lost by default: gene-level feature lines (dropped unless you add --keep-genes), and most free-form attributes beyond gene_id/transcript_id (dropped unless you add -F and --keep-exon-attrs).

The most common silent failure is running the plain gffread -T command against an NCBI GFF3. NCBI's GFF3 uses the ID attribute purely for structural Parent/child linking, not as a biological identifier, so transcript_id and protein_id live as separate qualifiers on the mRNA and CDS features. Plain -T conversion doesn't know to look there, so it either synthesizes its own IDs or emits an empty gene_id for the affected transcripts. The command exits 0, the GTF file looks fine at a glance, and the missing IDs only surface once featureCounts or STAR quietly assigns fewer reads than you expected.

The commands

Type your file names once; every command below updates.

  1. 01gffread

    bash
    gffread sample.gff3 -T -o sample.gtf

    -T forces GTF2 output (gffread's default output is GFF3). It collapses GFF3's ID/Parent chain into flat gene_id/transcript_id attributes and keeps only transcript-level features (mRNA, exon, CDS), dropping gene lines and most non-exon attributes. Assumes your GFF3 has a clean exon -> mRNA -> gene Parent chain; anything looser gets silently dropped.

  2. 02gffread

    bash
    gffread sample.gff3 -F -T --keep-exon-attrs --keep-genes -o sample.gtf

    -F preserves the full GFF attribute set for non-exon features that plain -T strips out; --keep-genes keeps gene-level feature lines in the GTF; --keep-exon-attrs stops exon attributes from being collapsed onto the parent transcript. Use this combination for NCBI RefSeq GFF3, where locus_tag, transcript_id and protein_id live on the gene or mRNA feature rather than being inferable from ID alone. Output isn't strictly GTF2.2-conformant once you add these flags, so check the downstream tool tolerates it.

  3. 03gffread

    bash
    gffread -E sample.gff3 -o sample.gtf

    -E reformats the GFF3 while printing parsing warnings to stderr: dangling Parent references, duplicate IDs, features with no recognizable hierarchy. Run this before the -T conversion, not after. Output stays in GFF3; the point is to see what gffread will silently discard once you switch to -T.

  4. 04gffread

    bash
    gffread -w sample.gtf -g genome.fa sample.gff3

    Extracts transcript FASTA sequences straight from the GFF3, using the same Parent-based feature resolution gffread uses internally for GTF conversion. If this step throws 'sequence not found' errors, your first column's chromosome names don't match genome.fa's headers, the same mismatch that will silently zero out counts downstream.

Coordinates, strand, names, builds

GFF3 and GTF both use 1-based, closed coordinates: base 1 is the first nucleotide, and both start and end are inclusive. gffread does not touch coordinates during this conversion, so if your GTF's positions look off, the bug is in the source file or a downstream tool's assumptions, not the conversion step. Strand is encoded identically in both formats (+, -, or . in column 7), so no translation happens there either.

What actually changes is structural, not positional. GFF3's ID/Parent graph, which can represent arbitrary hierarchies and even multi-parent features, gets flattened into GTF's fixed gene_id/transcript_id key-value pair on every line. Free-form GFF3 attributes like Dbxref, Note, product, and Ontology_term are dropped by default under -T; you only keep them with -F and --keep-exon-attrs, and even then the result isn't strictly GTF2.2-conformant. Chromosome naming (chr1 vs 1, chrM vs MT) is never normalized by gffread; it's copied through verbatim from column 1 of the input, so a naming mismatch with your genome FASTA or BAM will pass conversion silently and only show up as zero counts later.

Check the output before you trust it

  1. 01Every exon line carries a non-empty gene_id and transcript_id

    bash
    awk -F'\t' '$3=="exon" && ($9 !~ /gene_id "[^"]+"/ || $9 !~ /transcript_id "[^"]+"/)' output.gtf | head

    Expected No lines printed. Any output means gffread couldn't resolve an ID for that transcript, usually a broken Parent chain or an NCBI file converted without -F.

  2. 02Gene count in the GTF roughly matches the gene count in the source GFF3

    bash
    awk -F'\t' '$3=="gene"' input.gff3 | wc -l
    grep -oP 'gene_id "\K[^"]+' output.gtf | sort -u | wc -l

    Expected The two numbers are close. The GTF count can be lower if some genes have zero transcripts (gffread drops those by design), but a large gap means IDs are going missing, not genes.

  3. 03Chromosome names match your genome FASTA or BAM headers exactly

    bash
    cut -f1 output.gtf | sort -u | head
    samtools view -H aln.bam | grep ^@SQ | head

    Expected Identical naming style in both lists, e.g. both 'chr1' or both '1'. A mismatch means every downstream count against that reference will be silently zero.

  4. 04Attribute column follows valid GTF2.2 quoting and spacing

    bash
    head -1 output.gtf | cut -f9

    Expected Format like gene_id "ENSG00000000003"; transcript_id "ENST00000373020";, double-quoted values, semicolon plus one space between attributes, not tabs.

  5. 05Feature type counts are sane relative to the source

    bash
    cut -f3 output.gtf | sort | uniq -c

    Expected exon and transcript/mRNA counts in the same ballpark as the GFF3's equivalents. Zero 'gene' lines is expected unless you ran gffread with --keep-genes.

Errors you will see, and what they mean

GTF lines missing gene_id, or gffread reports 'discarding <N> loci without a valid gene_id' during conversion
Cause: The source GFF3 (common with Prokka output and some non-NCBI annotation tools) doesn't have a complete exon -> mRNA -> gene Parent chain, so gffread has nothing to derive gene_id or transcript_id from for those transcripts. Fix: Run gffread -E on the GFF3 first and read the warnings; fix the missing Parent links upstream, or if the file is otherwise usable, patch gene_id in with a short awk/sed pass keyed on the locus_tag or gene column you do have.
Converted GTF has no 'gene' feature lines at all, even though the GFF3 clearly had them
Cause: gffread -T's default behavior discards non-transcript features, including gene-level lines, because GTF2 doesn't strictly require them. Fix: Add --keep-genes to the gffread command if your downstream tool (some gene-level counters, IGV gene tracks) expects gene rows.
featureCounts or STAR --quantMode returns zero counts for every feature after switching to the converted GTF
Cause: Chromosome naming mismatch between the GTF and the BAM/genome FASTA used for alignment, e.g. the GTF says '1' and the BAM headers say 'chr1', or vice versa. Fix: Compare `cut -f1 output.gtf | sort -u` against your BAM's `samtools view -H | grep ^@SQ` and rename with sed ('s/^chr//' or add 'chr' prefix) so they match exactly.
Transcript and protein IDs in the GTF look auto-generated (e.g. rna-XM_012345.1_mRNA) instead of matching the NCBI RefSeq accessions you expected
Cause: In NCBI GFF3, the GFF3 ID attribute is only used for structural Parent/child linking, not as a biological identifier. transcript_id and protein_id are separate qualifiers that NCBI's own conversion tools auto-generate from the gene's locus_tag when they're absent, and gffread's default pass loses that distinction. Fix: Convert with -F so gffread preserves NCBI's native transcript_id and protein_id qualifiers instead of synthesizing its own from the ID field.
gffread issue #45-style: 'gene_id ""' (empty string) shows up for a subset of transcripts
Cause: Those specific transcripts have a Parent attribute pointing to a gene ID that doesn't exist anywhere else in the file, so gffread can't resolve a real gene_id and emits an empty one. Fix: grep the offending Parent ID against the GFF3's gene features to confirm it's actually missing, then either add the missing gene line to the source GFF3 or drop those transcripts before conversion.

Questions people ask

Why does my GTF file have no gene_id after converting from GFF3?

Most often the source GFF3 lacks a clean exon -> mRNA -> gene Parent chain, which is common in Prokka output and some non-NCBI annotation pipelines, so gffread has nothing to derive gene_id from for those transcripts. Run gffread -E on the GFF3 first to see the parsing warnings before you commit to -T.

Do I need to convert coordinates when converting GFF3 to GTF?

No. Both formats use 1-based, closed coordinates, so gffread doesn't touch positions during this conversion. If coordinates look wrong after conversion, the problem was already in the source file.

What does the gffread -F flag do and when do I need it?

-F enables full GFF attribute preservation, keeping attributes on non-exon features that plain -T conversion discards. You need it for NCBI RefSeq GFF3 files, where transcript_id and protein_id live as qualifiers on the gene or mRNA feature rather than being derivable from the ID attribute alone.

Why are gene feature lines missing from my converted GTF?

gffread's default -T behavior discards non-transcript features, including gene-level rows, because plain GTF2 doesn't require them. Add --keep-genes to the conversion command if your downstream tool expects gene lines.

How do I fix chr1 vs 1 chromosome naming mismatches after converting GFF3 to GTF?

gffread copies chromosome names through from column 1 of the input without normalizing them. Compare the unique names in your converted GTF against your genome FASTA or BAM headers, and rename with sed ('s/^chr//' or adding 'chr') so they match exactly before you use the GTF for alignment or counting.

Related pages

Related reading on the blog

Sources

  1. GitHub - gpertea/gffread: GFF/GTF utility — Source and options for the gffread tool used for all conversion commands
  2. gffread Example Commands — Basis for the -T conversion command and the sequence-extraction / chromosome-naming requirement
  3. The Sequence Ontology GFF3 Specification — GFF3's ID/Parent attribute semantics and case sensitivity
  4. UCSC GTF2.2 Format Documentation — Mandatory gene_id/transcript_id attributes and required quoting/spacing in GTF
  5. NCBI GFF3 Annotation Format Guidelines — NCBI's locus_tag/transcript_id/protein_id qualifier requirements beyond base GFF3
  6. NCBI GFF3 Format Reference — ID attribute used only for structural linking, not as a biological identifier, in NCBI GFF3
  7. gffread GitHub Issue #45: Missing gene id after converting gff to gtf — Root cause of missing gene_id for Prokka-style and broken Parent-chain GFF3 files
  8. gffread GitHub Issue #74: GTF output file is different from GFF3 input file — Source for the -F --keep-exon-attrs --keep-genes command combination