GENCODE / Ensembl GTF Cookbook¶
GENCODE and Ensembl ship full mammalian annotations as gzipped GTF. Their hierarchy is gene → transcript → exon / CDS / UTR / start_codon / stop_codon — three levels deep, with several million features per annotation and ~100 features per gene on average.
This cookbook covers the four most common tasks downstream pipelines need.
1. Ingest¶
from gffbase import create_db
db = create_db(
"gencode.v49.chr_patch_hapl_scaff.basic.annotation.gtf.gz",
"gencode.duckdb",
force=True,
)
print(f"{db.count_features_of_type():,} features") # 6,068,892
print(db.fmt) # 'gtf'
GTF input is auto-detected. GENCODE v49 supplies a gene row for every
gene_id and a transcript row for every transcript_id, so inference finds
nothing to synthesize: the database holds exactly the file’s 6,068,892 rows.
disable_infer_genes=True and disable_infer_transcripts=True skip the
check, which the benchmarks do for a controlled comparison (see
Benchmark methodology). Leave inference on for leaf-only
files – StringTie, older Ensembl releases, anything derived: missing parents
are synthesized set-based from the transcript_id / gene_id attributes
(or the ones named by gtf_transcript_key / gtf_gene_key).
GENCODE ids are versioned: a gene’s id is its gene_id attribute,
ENSG00000139618.19, not the bare accession.
2. Walk a single gene’s hierarchy¶
gene = db["ENSG00000139618.19"] # BRCA2; ids carry the version
print(gene.featuretype, gene.start, gene.end)
# Only have the unversioned accession? Resolve it first:
(gene_id,) = db.execute(
"SELECT id FROM features WHERE featuretype = 'gene' AND split_part(id, '.', 1) = ?",
["ENSG00000139618"],
).fetchone()
for tx in db.children(gene, level=1, featuretype="transcript"):
n_exons = sum(1 for _ in db.children(tx, level=1, featuretype="exon"))
print(f" {tx.id} {tx.start}-{tx.end} ({n_exons} exons)")
# All descendants (exons + CDSs + UTRs) in one shot
for f in db.children(gene, level=None):
pass
children(level=1) reads the materialized closure; children(level=None)
returns the full descendant set. The dispatcher routes between the closure and
a recursive CTE. Each call is one or two queries, a few milliseconds – fine
for a handful of genes, slow as a loop over thousands. For those, use
children_batched (section 5).
3. Filter by attribute (e.g. all protein-coding genes)¶
GENCODE attributes are stored in a normalized long-form table,
attributes(feature_id, key, value, idx), so an attribute filter is an
ordinary join that DuckDB runs as a columnar scan – no JSON is parsed:
rows = db.execute("""
SELECT f.id, f.seqid, f.start, f."end"
FROM features f
JOIN attributes a ON a.feature_id = f.id
WHERE f.featuretype = 'gene'
AND a.key = 'gene_type'
AND a.value = 'protein_coding'
""").fetchall()
print(f"{len(rows):,} protein-coding genes")
4. BED12 export for genome-browser tracks¶
with open("gencode.bed", "w") as fout:
for tx in db.features_of_type("transcript", limit="chr1"):
fout.write(db.bed12(tx, name_field="transcript_id") + "\n")
5. Bulk extraction into PyArrow (the ML path)¶
For ML pipelines that need exons for many transcripts at once, prefer the vectorized API — see machine_learning_workflows.md for the full pattern:
gene_ids = [r[0] for r in db.execute(
"SELECT id FROM features WHERE featuretype='gene' LIMIT 50000"
).fetchall()]
table = db.children_batched(gene_ids, featuretype="exon", format="arrow")
print(table.num_rows, "exons,", len(table.column_names), "columns")
Performance notes¶
Whole-corpus numbers – ingest time, peak memory, batched extraction – are on the Performance page, measured per release. Per-call latency, measured on MANE v1.5 (19,363 genes) on the benchmark machine:
Call |
Median |
Path |
|---|---|---|
|
~0.6 ms |
primary-key lookup |
|
~5 ms |
closure table |
|
~10 ms |
closure table |
|
~2 ms |
R-tree |
A per-feature loop pays those costs once per feature. That is why the batched
API exists: children_batched answers thousands of anchors in one query.