4. Citations and licenses#

Every dataset in the catalog comes from a study that someone else ran, and it keeps the license under which they shared it. Before you publish a result, you need to know which studies to cite and whether their licenses allow what you did with the data. The client records both in the catalog, and the sections below show how to read them, filter on them and write a bibliography for a cohort.

You will learn how to:

  • read the license columns and their values

  • filter a query by license, and why the default does not

  • write BibTeX for a cohort in Python and from the shell

  • tell published entries from fallback entries, and fix both before you publish

The tutorial downloads only the catalog and the bibliography file next to it, about 10 MB in total.

import re
import subprocess
import tempfile
import warnings
from pathlib import Path

import pandas as pd

import sddb
from sddb.citations import citation_entries, parse_bibtex

pd.set_option("display.max_colwidth", 80)

cat = sddb.Catalog(version="2026-10-28")
df = cat.to_df()
work = Path(tempfile.mkdtemp())  # the files this tutorial writes go here

The license columns#

Five columns describe the license of a dataset:

  • license is an identifier such as CC-BY-4.0, or unknown.

  • license_url links the license text.

  • license_noncommercial is true when the license forbids commercial use.

  • license_redistributable is true when the license allows you to share the data further.

  • license_unknown is true when no license could be determined.

Every value in the 2026-10-28 release, with its number of datasets and the three flags:

licenses = (
    df.groupby("license")
    .agg(
        datasets=("uid", "size"),
        noncommercial=("license_noncommercial", "first"),
        redistributable=("license_redistributable", "first"),
        unknown=("license_unknown", "first"),
        url=("license_url", "first"),
    )
    .sort_values("datasets", ascending=False)
)
licenses
datasets noncommercial redistributable unknown url
license
ncbi-geo-policy 3530 False True False https://www.ncbi.nlm.nih.gov/geo/info/disclaimer.html
CC-BY-NC-SA-4.0 1072 True True False https://creativecommons.org/licenses/by-nc-sa/4.0/
unknown 1031 False False True <NA>
CC-BY-4.0 333 False True False https://creativecommons.org/licenses/by/4.0/
allen-institute-tou 115 True True False https://alleninstitute.org/terms-of-use/
CC0-1.0 6 False True False https://creativecommons.org/publicdomain/zero/1.0/

The flags are the same for every dataset with a given license, so first is enough here. What the values mean:

  • ncbi-geo-policy covers data deposited in GEO. NCBI places no restrictions on the use of GEO data, but its disclaimer notes that submitters may claim patent, copyright or other rights in their data. The client counts it as allowing commercial use and redistribution, and you should check the study if your use depends on it.

  • CC-BY-NC-SA-4.0 lets you share and adapt the data if you credit the authors, do not use it commercially, and share adaptations under the same license. Most HEST datasets carry it.

  • CC-BY-4.0 requires credit only, and CC0-1.0 places the data in the public domain.

  • allen-institute-tou is the Allen Institute's terms of use, which do not allow commercial use.

  • unknown means the source did not state a license that we could find. 1031 datasets, 17% of the catalog, have no known license.

Read the flags of unknown rows with care. license_noncommercial is false for them, because no license forbids commercial use, but the flag does not mean that commercial use is allowed. license_redistributable is false because the terms are not known. Always check license_unknown first.

unknown = df[df["license_unknown"]]
print(len(unknown), "datasets with an unknown license;", int((~unknown["license_noncommercial"]).sum()), "of them have license_noncommercial == False")
1031 datasets with an unknown license; 1031 of them have license_noncommercial == False

Filter by license#

query applies no license filter by default, so a search does not hide datasets without telling you. Three keywords filter by license, and license= takes exact values like any facet:

  • license_set=True keeps the datasets with a known license.

  • noncommercial=False keeps the datasets whose known license allows commercial use. It selects the same datasets as "Commercial use OK" on the website. noncommercial=True keeps only the datasets that forbid it.

  • redistributable=True keeps the datasets whose known license allows redistribution.

filters = {
    "no filter": {},
    "license_set=True": {"license_set": True},
    "noncommercial=False": {"noncommercial": False},
    "noncommercial=True": {"noncommercial": True},
    "redistributable=True": {"redistributable": True},
    'license="CC-BY-4.0"': {"license": "CC-BY-4.0"},
}
pd.Series({name: len(cat.query(**kwargs)) for name, kwargs in filters.items()}, name="datasets")
no filter               6087
license_set=True        5056
noncommercial=False     3869
noncommercial=True      1187
redistributable=True    5056
license="CC-BY-4.0"      333
Name: datasets, dtype: int64

noncommercial=False leaves out the datasets with an unknown license, because their terms are not known.

To check a cohort you already built, licenses() counts its members per license:

lung = cat.query(organism="human", technology="Visium", tissue="lung")
lung.licenses()
license n
0 ncbi-geo-policy 116
1 CC-BY-NC-SA-4.0 31
2 unknown 3
3 CC-BY-4.0 2
lung_commercial = cat.query(organism="human", technology="Visium", tissue="lung", noncommercial=False)
print(len(lung), "lung sections,", len(lung_commercial), "cleared for commercial use")
152 lung sections, 118 cleared for commercial use

Write citations#

Every dataset belongs to a study. cohort.citations(path) writes one BibTeX entry per study to a file, and citations_text() returns the same text with the number of entries. The entries come from citations.bib, a file published with each catalog release.

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    bib_path = lung.citations(work / "lung.bib")
print(*[w.message for w in caught if issubclass(w.category, UserWarning)])
print(bib_path.read_text()[:900])
65 of 152 studies have a published citation, in 8 entries after merging those that share a DOI; added 87 fallback entries that cite the dataset page; check them before publishing
@article{geo:GSM7905760,
  title = {Distinct mural cells and fibroblasts promote pathogenic plasma cell accumulation in idiopathic pulmonary fibrosis},
  volume = {65},
  ISSN = {1399-3003},
  url = {http://dx.doi.org/10.1183/13993003.01114-2024},
  DOI = {10.1183/13993003.01114-2024},
  number = {6},
  journal = {European Respiratory Journal},
  publisher = {European Respiratory Society (ERS)},
  author = {Yang, Zhi and Cao, Gaoyuan and Tan, Xiaosheng and Orfanos, Sarah and Jude, Joseph and Barbet, Gaetan and An, Steven S. and Jiang, Dianhua and Panettieri, Reynold A. and Yang, Qi},
  year = {2025},
  month = Feb,
  pages = {2401114},
  note = {covers samples: geo:GSM7905760, geo:GSM7905761, geo:GSM7905762, geo:GSM7905763, geo:GSM7905764 and 8 more}
}

@article{geo:GSM8217624,
  title = {Early human fetal lung atlas reveals the temporal dynamics of epithelial cell plasticity},
  volume

The warning reports two kinds of entries.

A published entry is the entry for a study_id in citations.bib. It was built from the publication's DOI and has the authors, the title and the journal.

A fallback entry is written for a study that has no published entry yet, and for a dataset without a study_id. It cites the dataset page on the website and carries the title, DOI, year, uids and license that the catalog has. Fallback entries make sure the file cites every study in the cohort. Client versions before 0.1.2 left the studies without a published entry out, and a cohort without any published entry gave an empty .bib file.

The bibliography has one entry per study_id, and for GEO data a study_id is a single sample, so a publication with several samples in the cohort has several entries. The client merges entries that share a DOI into one: the first entry by key keeps its key and fields, and its note lists the other keys it covers. The warning above therefore names more studies with a published citation than there are published entries.

citation_entries returns the text with the two counts apart, counted after merging:

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    text, n_published, n_fallback = citation_entries(lung)
print(n_published, "published,", n_fallback, "fallback")
entries = parse_bibtex(text)
8 published, 87 fallback

The published entry with the longest list of covered samples shows how a merged entry looks:

published = [entry for entry in entries.values() if not entry.startswith("@misc")]
merged = max(published, key=lambda entry: entry.count("geo:GSM"))
print(re.search(r"note\s*=\s*\{[^}]*\}", merged).group(0))
note = {covers samples: geo:GSM7905760, geo:GSM7905761, geo:GSM7905762, geo:GSM7905763, geo:GSM7905764 and 8 more}

An empty cohort raises an error instead of writing an empty file:

try:
    lung.where(lung.members["n_obs"] < 0).citations(work / "empty.bib")
except ValueError as err:
    print("ValueError:", err)
ValueError: no datasets to cite: the cohort is empty

Check the entries before you publish#

The published entries are complete, but the fallback entries need more work. A fallback entry has a DOI when the catalog has one for the study, and then you can look up the publication and replace the entry with the publication's own BibTeX. When there is no DOI, the dataset page is the best reference we have, and the note field lists the uids and the license.

fallback = {key: entry for key, entry in entries.items() if entry.startswith("@misc")}
print(fallback[next(iter(fallback))])
with_doi = sum(bool(re.search(r"\bdoi\s*=", entry, flags=re.IGNORECASE)) for entry in fallback.values())
print(with_doi, "of", len(fallback), "fallback entries have a DOI")
pd.Series([key.split(":")[0] for key in fallback]).value_counts()
@misc{10x:human-lung-cancer-11-mm-capture-area-ffpe-2-standard,
  title = {human-lung-cancer-11-mm-capture-area-ffpe-2-standard},
  howpublished = {\url{https://spatialdata-db-web.pages.dev/datasets/visium-spatial-gene-expression-lung-pntnmjy70ylup8eq0000/}},
  year = {2023},
  note = {SpatialData-DB dataset PntNMJy70YLUP8eq0000; license CC-BY-4.0}
}
0 of 87 fallback entries have a DOI
geo    85
10x     2
Name: count, dtype: int64

In this cohort none of the fallback entries has a DOI, and almost all of them are GEO samples. The dataset_url of a GEO dataset links its GEO series, and the series page lists the publication when the authors added one.

From the shell#

sddb-cli cite takes the same filters as sddb-cli query and writes the file:

sddb-cli cite --organism human --technology Visium --tissue lung -o lung.bib

--catalog selects the catalog file. The cell below runs the command with the dated file that cat uses.

command = ["sddb-cli", "cite", "--organism", "human", "--technology", "Visium", "--tissue", "lung"]
command += ["--catalog", cat.url, "-o", str(work / "lung-cli.bib")]
print(subprocess.run(command, capture_output=True, text=True, check=True).stdout)
wrote 95 citations (87 fallback; entries that share a DOI merged) -> /tmp/sddb-docs-tmp/tmpgmpq8o7a/lung-cli.bib

It prints how many entries it wrote and how many of them are fallback entries. The MCP server has the same command as its cite tool, see Command line and agents.

Before you publish#

  1. Run licenses() on the final cohort. Drop the datasets with an unknown license, or find their terms at the source (the dataset_url column links it).

  2. If any dataset is NonCommercial, make sure your use is not commercial. For CC-BY-NC-SA-4.0, share the data you derive under the same license.

  3. Write the citations, and replace fallback entries with the publication's own entry where it has a DOI.

  4. Cite the client from its CITATION.cff, which GitHub shows as "Cite this repository". A publication that describes the database is forthcoming.

  5. Record the catalog release and keep the manifest from freeze(), so that others can load the same datasets (3. Cohorts and manifests).

Summary#

  • license_unknown comes first: an unknown license has license_noncommercial false and is not cleared for any use.

  • query filters by license only when you ask, with license_set, noncommercial, redistributable or license.

  • citations() writes one entry per study, published where citations.bib has one and a fallback otherwise. Entries that share a DOI are merged. Replace fallback entries before you publish.

Tutorial 4 is the last in the series. The use cases build longer analyses on the same steps, and 0. Overview puts all of them on one page.