# Cleaning Duplicate Gene IDs in pandas: Problems and Fixes

Duplicate gene IDs almost always come from the same place: the table you just loaded holds more than one row per gene. Transcript-level quantification, versioned Ensembl identifiers, and one-to-many mapping tables all put repeated keys in a column that the rest of your script treats as unique. The first check takes one line and settles whether duplicates are even your problem:

```python
df["gene_id"].duplicated().sum()
```

Zero means look elsewhere. Any other number means you have repeated gene identifiers (gene IDs), and the right fix depends on why they repeat. A gene ID is the label, such as the Ensembl ID ENSG00000141510 or the symbol TP53, that ties one gene together across a counts file, an annotation table, and a results table. pandas, the standard Python library for tabular work in python for bioinformatics, will not warn you when keys repeat. It will fan out a merge, double count a sum, or quietly change what `.loc` returns.

This guide walks the causes from most common to rarest, gives a six-step diagnostic sequence, a troubleshooting table, and concrete fixes for how to clean duplicate gene IDs in pandas without losing counts or annotations. All examples use pandas 2.x and Biopython, the standard Python library for reading biological file formats.

## Why Duplicate Gene IDs Appear

### Annotation rows are per feature, not per gene

The most common cause is granularity. Ensembl and GENCODE release genome annotation in GTF format (Gene Transfer Format, a tab-separated file where each row describes one genomic feature). A gene with five transcripts contributes one gene row, five transcript rows, and dozens of exon rows, and every one of those rows carries the same gene_id. If you parse the whole file and assume gene_id is unique per row, it never was.

The same logic applies to quantification output. Salmon and kallisto report one row per transcript, and featureCounts can report one row per transcript or per gene depending on the settings you used. Two transcript rows sharing one gene_id are correct data, not corruption. The mistake is treating them as independent genes during aggregation, which double counts every read that maps to a multi-transcript gene.

The fix preview is simple: either filter the GTF down to feature rows of type gene before extracting IDs, or aggregate transcript rows to gene level with `groupby` before anything else touches the table.

### Versioned and mixed-namespace IDs

Ensembl appends a dot and an integer version to its identifiers, so TP53 can appear as ENSG00000141510 in one file and ENSG00000141510.16 in another. String equality sees two different keys, so a merge on gene_id returns NaN for every row on one side. The same failure appears when one file uses Ensembl gene IDs, another uses RefSeq transcript accessions such as NM_ sequences, and a third uses gene symbols. These are different namespaces (different ID systems for the same genes), and no amount of deduplication fixes a cross-namespace join. You need an explicit mapping step first.

### One-to-many mappings

HGNC (the HUGO Gene Nomenclature Committee) assigns one official symbol per human gene and maintains alias and previous symbol records, but public mapping tables still join aliases to IDs. UniProt, the [protein database](/blog/guides/protein-database), can hold several protein entries that map to one gene, so a UniProt-derived table repeats the same symbol on multiple rows. NCBI Gene aliases behave the same way in reverse: several old symbols can point at one GeneID. When a mapping file contains one symbol on many rows, every merge on that symbol fans out, and `drop_duplicates` on the symbol column silently discards real alternative IDs you may later need.

### Merge fan-out from duplicate keys

pandas `merge` emits one output row for every pair of matching input rows. If the key ENSG00000141510 appears twice in the left table and twice in the right table, that key contributes four output rows. A left join against a table with duplicate keys expands even when the left side is clean, because each left row matches every right copy. This is the mechanism behind the classic complaint that a merge returned far more rows than either input. Row counts before and after each merge are the cheapest guard you can build.

### Concatenation artifacts

`pd.concat` keeps the original index labels by default. Two runs that each carry row labels 0 through 999 produce a combined table with duplicate labels 0 through 999. A double append of the same file (running the append cell twice in a notebook) produces fully identical duplicate rows, which inflate every downstream sum. Both problems are invisible in a quick `head()` and obvious in a row count.

### Parsing artifacts: dtypes, whitespace, case, and NA strings

Three quiet parsers cause the rest. First, dtypes: if a column of numeric NCBI GeneIDs contains even one missing value, pandas parses the whole column as float64, so 7157 becomes 7157.0 and fails to match the integer 7157 in the other table. Second, missing-value strings: `read_csv` treats the strings NA, NULL, and N/A as missing by default, so any ID that collides with those strings becomes NaN. Third, characters you cannot see: trailing spaces, carriage returns from Windows line endings, a byte order mark (BOM) at the start of Excel-exported CSVs, and case differences. Human symbols are all uppercase (HBB) and mouse symbols capitalize only the first letter (Hbb), so a case-insensitive comparison merges species that should stay separate.

## A Six-Step Diagnostic Sequence

Run these in order. Each step narrows the cause.

```python
import pandas as pd

# 1. Confirm duplicates exist and count them
df["gene_id"].duplicated().sum()
df.index.is_unique

# 2. Print every copy, not just the later ones
dupes = df.loc[df["gene_id"].duplicated(keep=False)]
dupes.sort_values(["gene_id", "counts"], ascending=[True, False]).head(20)

# 3. Characterize the pattern
df["gene_id"].str.contains(r"\.\d+$").sum()        # version suffixes
df["gene_id"].str.startswith(" ").sum()            # leading whitespace
df["gene_id"].str.endswith(" ").sum()              # trailing whitespace
df["gene_id"].isna().sum()                         # IDs lost to NA parsing
df["gene_id"].value_counts().head(10)              # worst offenders

# 4. Decide the keep rule from the biology
#    (canonical row, highest support, or additive sum)

# 5. Apply the fix with a validation guard
#    (merge validate=..., set_index verify_integrity=True, assert)

# 6. Recheck and log
len(df), df["gene_id"].nunique(), df["gene_id"].is_unique
```

Step 3 is where most people save hours. The pattern in the duplicated rows tells you which fix applies. Version suffixes call for stripping. Identical full rows call for `drop_duplicates`. Different values on repeated IDs call for a keep rule or an aggregation, and that decision belongs to the biology, not to pandas.

## Troubleshooting Table: Symptoms, Causes, Confirmation, Fixes

| Symptom | Likely cause | How to confirm | Fix |
|--|--|--|--|
| MergeError mentioning non-unique merge keys | Duplicate keys in one input | `df["gene_id"].duplicated().sum()` above zero | Deduplicate first, or merge with `validate="many_to_one"` |
| Output rows exceed input rows after a merge | Key duplicated in both tables (fan-out) | Compare `len()` before and after, group the key and count | Aggregate both sides to one row per key, then merge |
| `.loc["ID"]` returns a table instead of one row | Duplicated index labels | `df.index.duplicated().any()` | Deduplicate the index or aggregate to unique labels |
| Grouped totals are higher than published values | Fully duplicate rows from a double append or concat | `df.duplicated().sum()`, row count against the source file | `drop_duplicates()` on all columns |
| Ensembl IDs with a dot-number suffix fail to match annotation | Version suffix on one side only | `df["gene_id"].str.contains(r"\.\d+$").sum()` | Strip versions on both sides with the same regex |
| Merge returns NaN for every row on one side | dtype mismatch (int vs str) or hidden whitespace | Check `df.dtypes`, print values with `repr()` | Read ID columns with `dtype=str`, apply `.str.strip()` |
| Some gene IDs become NaN after `read_csv` | ID collides with a default missing-value string | `df["gene_id"].isna().sum()` on the raw read | `keep_default_na=False, na_values=[]` on ID columns |
| `.str.strip()` raises AttributeError | ID column parsed as numbers | `df.dtypes` | `dtype=str` on read, or `astype("string")` |
| `pivot` raises a ValueError about duplicate entries | Duplicate gene and sample pairs in long data | `df.duplicated(["gene_id", "sample"]).sum()` | Deduplicate, or use `pivot_table` with an explicit `aggfunc` |
| scanpy appends "-1" to gene names | Duplicate `var_names` in the AnnData object | `adata.var_names.is_unique` | Aggregate to gene level before building the AnnData object |
| Two tables match after case folding but not before | Species mix (human HBB vs mouse Hbb) or inconsistent case | Compare counts before and after `.str.upper()` | Keep species in separate tables, standardize case per species |
| Two different genes collapse into one ID | Trailing whitespace or case folding on a case-sensitive namespace | Print affected values with `repr()` | Strip whitespace, never case-fold Ensembl IDs |

## How to Fix Duplicate Gene IDs in pandas

These fixes are ordered from the situations you will meet most often to the rare ones. Each one states when it is the right tool.

### Fix 1: deduplicate with an explicit keep rule

Use `drop_duplicates` when repeated IDs are redundant copies and you want one representative row. The catch is that `keep="first"` means first in the current row order, so if row order is arbitrary, your kept row is arbitrary. Sort first by the criterion that matters, then drop.

```python
# Keep the row with the highest mean expression per gene
clean = (df
         .sort_values("mean_counts", ascending=False, kind="stable")
         .drop_duplicates(subset="gene_id", keep="first"))

# Alternative: pick the row holding the max value per group
df = df.reset_index(drop=True)
rep = df.loc[df.groupby("gene_id")["mean_counts"].idxmax()]
```

`kind="stable"` matters when several rows tie on the sort column, because a stable sort preserves the original file order for ties and makes the result reproducible. If you cannot state the keep rule in one sentence, stop and use Fix 2 instead, because you probably want an aggregation, not a selection.

### Fix 2: aggregate to gene level with groupby

Use `groupby` when repeated IDs carry complementary values that belong together, which is exactly the transcript-to-gene case. Raw read counts are additive across transcripts, so the correct gene-level value is the sum. Gene name and biotype are attributes, so `first` is appropriate. Length is usually taken as the max or a weighted value depending on your pipeline convention.

```python
gene_level = (df
              .groupby("gene_id", as_index=False)
              .agg(total_counts=("counts", "sum"),
                   gene_name=("gene_name", "first"),
                   length=("length", "max"),
                   n_transcripts=("transcript_id", "nunique")))
```

Two cautions. Only sum columns that are additive: raw counts, transcript counts summed to genes, and per-sample quantities such as TPM (transcripts per million) that are defined additively. Never sum log2 fold changes, p-values, or percentages, because those are not additive quantities and the result is meaningless. Second, `groupby` drops rows whose key is NaN by default. If unmapped IDs should stay visible while you debug, pass `dropna=False`.

### Fix 3: strip version suffixes safely

Strip Ensembl version suffixes only when both sides of the join share the same underlying annotation release, and strip both sides with the same rule.

```python
for tbl in (counts, annot):
    tbl["gene_id"] = tbl["gene_id"].str.replace(r"\.\d+$", "", regex=True)
```

The regex removes a dot and the digits after it at the end of the string, and nothing else. For gene IDs this is safe in practice. For transcript IDs, be careful: if you quantified against a transcriptome reference that includes versions (common with Salmon and kallisto indices), the version is what ties your quantification matrix to the right transcript entries. Strip there only after confirming that both the quantification reference and the annotation file come from the same release, or keep versions on both sides instead.

### Fix 4: normalize strings before any comparison

Whitespace, carriage returns, and BOM characters are invisible in a notebook but fatal to equality checks. Normalize once, right after reading.

```python
df["gene_id"] = (df["gene_id"]
                 .str.strip()
                 .str.replace("\r", "", regex=False))

# Excel-exported CSVs often start with a BOM
df = pd.read_csv("export.csv", encoding="utf-8-sig")
```

Case needs a policy, not a reflex. Ensembl IDs and HGNC symbols are case-sensitive namespaces, so never case-fold them as a deduplication trick. Uppercasing symbols is acceptable only inside a single-species human table where you have confirmed that alias collisions will not merge distinct genes. Across species, case is the difference between human HBB and mouse Hbb, so keep species in separate tables with a species column, and never fold case across that boundary.

### Fix 5: control dtypes and NA parsing on read

Most dtype and NaN surprises are preventable at the read step. Read every ID column as a string, and disable the default missing-value strings for ID columns.

```python
counts = pd.read_csv("counts.csv",
                     dtype={"gene_id": "string"},
                     keep_default_na=False, na_values=[])

# Numeric NCBI GeneIDs that may contain gaps: keep them nullable integers
gene_map = pd.read_csv("gene_map.csv", dtype={"ncbi_gene_id": "Int64"})
```

The first call guarantees that gene_id stays textual, that the string NA survives as the string NA, and that `.str` methods work. The second uses the nullable integer type, which preserves integers as integers even when some rows are missing, avoiding the 7157 versus 7157.0 mismatch that a plain float64 column produces. If you already have a mixed table, convert both sides before merging with `astype("string")` or `astype("Int64")` so the join keys share one dtype.

### Fix 6: validate merges and indexes at every boundary

pandas gives you guards that fail loudly instead of failing silently. Use them on every join and every index assignment in a pipeline.

```python
# Fail unless the right table has one row per key
merged = counts.merge(annot, on="gene_id", how="left",
                      validate="many_to_one")

# Fail if the column cannot serve as a unique index
counts = counts.set_index("gene_id", verify_integrity=True)

# Cheap runtime assertions
assert counts["gene_id"].is_unique, "gene_id still has duplicates"
assert len(merged) == len(counts), "merge changed the row count"
```

The `validate` argument accepts one_to_one, one_to_many, many_to_one, and many_to_many, and raises a MergeError when the data violates the contract you declared. `verify_integrity` raises a ValueError when the labels are not unique. These two lines convert silent corruption into an immediate, debuggable failure, which is the single highest-value habit in this article.

### Fix 7: repair duplicated indexes for downstream tools

A duplicated index changes behavior in place. `df.loc["ENSG00000141510"]` returns a Series when the label is unique and a DataFrame when it is not, so code written against the unique case breaks without an error message. Operations such as `reindex` refuse to run on an axis with duplicate labels. Clean the index directly:

```python
df = df.loc[~df.index.duplicated(keep="first")]
```

For single-cell workflows, scanpy (the Python single-cell analysis package) stores genes in `adata.var_names` and requires unique labels. The method `adata.var_names_make_unique()` exists, but it works by appending "-1" to later copies and keeping the first copy's values, which can silently retain the wrong transcript's counts for a gene with multiple entries. Prefer aggregating the matrix to gene level before constructing the AnnData object, and treat `make_unique` as a last-resort guard, not a cleaning step.

### Fix 8: rare causes (mixed releases, species mixes, and special rows)

Three rarer patterns deserve a check before you conclude the data is corrupt. Mixed annotation releases: rows copied from an old Ensembl or GENCODE release can carry IDs that were later merged or retired, so a table assembled from two releases has IDs that no current release resolves. Pin one release per project and record it in the output metadata. Pseudoautosomal genes: GENCODE and Ensembl list the Y copy of genes in the pseudoautosomal regions with a suffix on the ID, so the same gene appears twice by design, once for X and once for Y. Decide explicitly whether your analysis wants both copies. Non-gene summary rows: featureCounts writes summary lines whose keys start with two underscores, and those rows must be excluded before aggregation:

```python
df = df.loc[~df["gene_id"].str.startswith("__")]
```

## A Worked Example From Raw Rows to a Clean Gene Table

![Worked sum of two TP53 transcript rows of 120 and 80 to 200 counts and BRCA1 rows of 40 and 60 to 100.](/article-images/illustrations/clean-duplicate-gene-ids-pandas-tp53-brca1-totals.webp)

Here is the full path on toy data with illustrative version numbers. The input has two rows per gene because two transcripts were quantified, and the annotation file uses unversioned IDs.

| gene_id | gene_name | counts |
|--|--|--|
| ENSG00000141510.16 | TP53 | 120 |
| ENSG00000141510.16 | TP53 | 80 |
| ENSG00000012048.5 | BRCA1 | 40 |
| ENSG00000012048.5 | BRCA1 | 60 |

```python
import pandas as pd

counts = pd.read_csv("counts_transcripts.csv", dtype={"gene_id": "string"})
annot  = pd.read_csv("annotation.tsv", sep="\t", dtype={"gene_id": "string"})

# 1. Normalize IDs on both sides
for tbl in (counts, annot):
    tbl["gene_id"] = tbl["gene_id"].str.strip()
    tbl["gene_id"] = tbl["gene_id"].str.replace(r"\.\d+$", "", regex=True)

# 2. Aggregate transcript rows to gene level (counts are additive)
gene_counts = (counts
               .groupby("gene_id", as_index=False)
               .agg(total_counts=("counts", "sum"),
                    gene_name=("gene_name", "first")))

# 3. Guard the merge: annotation must have one row per gene
annot = annot.drop_duplicates(subset="gene_id", keep="first")
merged = gene_counts.merge(annot, on="gene_id", how="left",
                           validate="many_to_one")

# 4. Verify
assert merged["gene_id"].is_unique
assert len(merged) == len(gene_counts)
```

The cleaned table has one row per gene with TP53 at 200 total counts and BRCA1 at 100. Note what each step contributed: stripping made the join possible, `groupby` made the numbers correct, `drop_duplicates` on the annotation made the join legal under `validate`, and the assertions make the failure loud if a future file regresses. Writing the cleaned output to a new filename rather than overwriting the raw input is the data-safety equivalent of a fume hood: raw files are your only path back when a cleaning rule turns out to be wrong.

## A Short Biopython Tutorial for Gene ID Work

pandas cleans tables, but Biopython is how you build and verify them from primary sources. Two patterns cover most needs.

### Build a gene table from GenBank features

GenBank records store genes as features, and the locus_tag (the systematic identifier assigned on that record) is the reliable key. Within a single record, locus_tags are unique, but gene qualifiers can repeat across features, so deduplication is still required.

```python
from Bio import SeqIO
import pandas as pd

rows = []
for record in SeqIO.parse("chromosome.gb", "genbank"):
    for feature in record.features:
        if feature.type == "gene":
            rows.append({
                "locus_tag": feature.qualifiers.get("locus_tag", [None])[0],
                "gene": feature.qualifiers.get("gene", [None])[0],
                "start": int(feature.location.start),
                "end": int(feature.location.end),
            })

features = pd.DataFrame(rows)
features = features.dropna(subset=["locus_tag"])
features = features.drop_duplicates("locus_tag", keep="first")
assert features["locus_tag"].is_unique
```

For FASTA files, where duplicate record IDs break many downstream tools, stream the file once and track IDs in a set rather than loading everything into memory:

```python
seen = set()
for record in SeqIO.parse("transcripts.fa", "fasta"):
    if record.id in seen:
        print("duplicate ID:", record.id)
    seen.add(record.id)
```

### Fetch official gene records with Bio.Entrez

When you need to confirm a symbol against NCBI's own records, Biopython's Entrez module queries the [NCBI Gene database](/blog/guides/ncbi-gene-database) directly. NCBI asks programmatic users to set an email address and to keep to three requests per second without an API key, and ten per second with one.

```python
from Bio import Entrez
import time

Entrez.email = "you@institution.edu"

handle = Entrez.esearch(db="gene", term="TP53[gene] AND Homo sapiens[orgn]")
results = Entrez.read(handle)
handle.close()

gene_uid = results["IdList"][0]

handle = Entrez.esummary(db="gene", id=gene_uid)
summary = Entrez.read(handle)
handle.close()
doc = summary["DocumentSummarySet"]["DocumentSummary"][0]

print(doc["Name"], doc["Description"], doc["TaxID"])
time.sleep(0.5)   # stay within the NCBI rate limit in loops
```

This is the pattern for a verification step in a pipeline: map your IDs, then spot check a sample against the source database rather than trusting a mapping file of unknown age.

## Prevention Practices That Keep Gene IDs Unique

1. Read every ID column as a string with `dtype=str`, and pass `keep_default_na=False, na_values=[]` for ID columns.
2. Validate at every boundary: `validate=` on merges, `verify_integrity=True` on `set_index`, and an `assert df["gene_id"].is_unique` after every deduplication.
3. Pick one canonical namespace per project (Ensembl gene ID, NCBI GeneID, or HGNC symbol) for joins, and carry other identifiers as extra columns for display and cross-checking.
4. Record the annotation release (Ensembl release or GENCODE version) and the mapping source in your output metadata, so a cleaned table is interpretable six months later.
5. Log row counts and unique ID counts before and after every read, merge, filter, and aggregation. Divergence you did not expect is the earliest warning you get.
6. Keep a small fixture file with known duplicates and run it through your pipeline as a unit test.
7. Never round-trip ID tables through spreadsheet software. Spreadsheet programs corrupt symbols such as MARCH1 and SEPT1 by converting them to dates, which is why HGNC renamed that class of symbols to forms like MARCHF1 and SEPTIN1 in 2017.
8. When a mapping is needed, do it once, from a named source, into a saved mapping table with columns for both namespaces and the source date, instead of re-deriving mappings inline in analysis scripts.

## Common Mistakes and Limitations

- Using `drop_duplicates(..., keep="first")` without sorting first. The kept row is then whatever happened to be first in file order, which is not reproducible across file versions.
- Treating `var_names_make_unique()` as a cleaning step. It renames duplicates rather than resolving them, and the retained values may come from the wrong transcript.
- Summing non-additive columns during gene-level aggregation. Log fold changes, p-values, and per-transcript percentages do not sum to anything meaningful.
- Uppercasing symbols in a table that mixes human and mouse, which merges HBB and Hbb into one key.
- Stripping transcript version suffixes when the quantification reference was versioned, which breaks the link between your matrix and the annotation.
- Assuming symbols are unique keys. HGNC maintains alias and previous symbol records precisely because symbols have collided and changed, and public mapping tables still carry those aliases.
- Forgetting that `groupby` drops NaN keys by default, which silently removes unmapped rows from aggregated output. Pass `dropna=False` while debugging.
- Comparing a float64 ID column against an int64 column and concluding the IDs are missing rather than mismatched.

On limitations: every dataset needs review by someone who knows the biology and the annotation source behind it. Gene IDs are stable within a release but change across releases as genes are merged, split, or retired, so a cleaned table is only valid for the release it was built against, and the keep rule you choose (canonical row, highest support, or additive sum) is a biological decision that pandas cannot make for you.

## Frequently Asked Questions

### How do I check whether my gene ID column has duplicates?

Run `df["gene_id"].duplicated().sum()` to count later copies, and `df.index.is_unique` if gene IDs are your index. To see every copy including the first, use `df.loc[df["gene_id"].duplicated(keep=False)]` and sort by the ID column before inspecting.

### Should I use drop_duplicates or groupby to clean duplicate gene IDs?

![Side-by-side comparison of drop_duplicates and groupby for cleaning duplicate gene IDs, with when each applies.](/article-images/illustrations/clean-duplicate-gene-ids-pandas-drop-vs-groupby.webp)

Use `drop_duplicates` when repeated rows are redundant copies and one representative row is correct, after sorting by the criterion that defines best. Use `groupby` with an aggregation when the repeated rows carry complementary values that belong together, such as transcript counts that should be summed to gene level.

### Why does my pandas merge return more rows than I started with?

A merge emits one output row for every pair of matching keys, so a key present twice in both inputs produces four rows. Count rows before and after the merge, deduplicate or aggregate the join keys on both sides, and add `validate="many_to_one"` or `validate="one_to_one"` so pandas raises an error instead of fanning out.

### How do I remove version suffixes from Ensembl gene IDs?

Apply `df["gene_id"].str.replace(r"\.\d+$", "", regex=True)` to every table in the join, not just one side. For transcript-level data, confirm that your quantification reference and annotation come from the same release before stripping, or keep versions on both sides.

### Are gene symbols safe keys for merging tables?

No. Symbols change, collide through aliases, and differ in case between species, so use a stable identifier such as the Ensembl gene ID or NCBI GeneID for joins and carry symbols as display columns. If you must join on symbols, deduplicate the mapping first and record its source and date.

### Why do some gene IDs turn into NaN after read_csv?

The default missing-value strings include NA, NULL, and N/A, so IDs that collide with those strings are parsed as missing. Read ID columns with `dtype=str` plus `keep_default_na=False, na_values=[]`, then check `df["gene_id"].isna().sum()` on the raw read to confirm.

### What does var_names_make_unique do in scanpy?

It appends "-1" to later copies of duplicated names in `adata.var_names` so the labels become unique, keeping the first copy's values. That can silently retain the wrong transcript for a gene, so prefer aggregating the matrix to gene level before building the AnnData object and use the method only as a guard.

### How do I handle one gene symbol that maps to several Ensembl IDs?

Decide the rule before touching pandas: keep the canonical or MANE Select entry, aggregate the IDs into one row, or preserve all mappings in a separate table. Applying `drop_duplicates` on the symbol column without a rule silently discards real alternative IDs and makes the choice irreproducible.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "How do I check whether my gene ID column has duplicates?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Run df.gene_id.duplicated().sum() to count later copies, and df.index.is_unique if gene IDs are your index. To see every copy including the first, use df.loc[df.gene_id.duplicated(keep=False)] and sort by the ID column before inspecting."
      }
    },
    {
      "@type": "Question",
      "name": "Should I use drop_duplicates or groupby to clean duplicate gene IDs?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Use drop_duplicates when repeated rows are redundant copies and one representative row is correct, after sorting by the criterion that defines best. Use groupby with an aggregation when repeated rows carry complementary values that belong together, such as transcript counts summed to gene level."
      }
    },
    {
      "@type": "Question",
      "name": "Why does my pandas merge return more rows than I started with?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "A merge emits one output row for every pair of matching keys, so a key present twice in both inputs produces four rows. Count rows before and after, deduplicate or aggregate the join keys on both sides, and add validate=many_to_one or validate=one_to_one so pandas raises an error instead of fanning out."
      }
    },
    {
      "@type": "Question",
      "name": "How do I remove version suffixes from Ensembl gene IDs?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Apply a regular expression that removes a dot followed by digits at the end of the string, using str.replace with regex=True, to every table in the join, not just one side. For transcript-level data, confirm the quantification reference and annotation share the same release before stripping, or keep versions on both sides."
      }
    },
    {
      "@type": "Question",
      "name": "Are gene symbols safe keys for merging tables?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. Symbols change, collide through aliases, and differ in case between species, so join on a stable identifier such as the Ensembl gene ID or NCBI GeneID and carry symbols as display columns. If you must join on symbols, deduplicate the mapping first and record its source and date."
      }
    },
    {
      "@type": "Question",
      "name": "Why do some gene IDs turn into NaN after read_csv?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "The default missing-value strings include NA, NULL, and N/A, so IDs that collide with those strings are parsed as missing. Read ID columns with dtype=str plus keep_default_na=False and na_values=[], then check the isna count on the raw read to confirm."
      }
    },
    {
      "@type": "Question",
      "name": "What does var_names_make_unique do in scanpy?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "It appends -1 to later copies of duplicated names in adata.var_names so the labels become unique, keeping the first copy's values. That can silently retain the wrong transcript for a gene, so aggregate the matrix to gene level before building the AnnData object and use the method only as a guard."
      }
    },
    {
      "@type": "Question",
      "name": "How do I handle one gene symbol that maps to several Ensembl IDs?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Decide the rule before touching pandas: keep the canonical or MANE Select entry, aggregate the IDs into one row, or preserve all mappings in a separate table. Applying drop_duplicates on the symbol column without a rule silently discards real alternative IDs and makes the choice irreproducible."
      }
    }
  ]
}
</script>