How to publish a dataset with a citable identifier

On this page

Problem statement

A dataset that lives at https://example.org/downloads/current.gpkg cannot be cited. The URL will change, the file behind it already has, and a paper that says "data from example.org" is a dead end in five years.

A persistent identifier fixes both halves of that: it resolves to a landing page that survives the reorganisation, and it points at a specific immutable version rather than whatever is current. DOIs are the common choice for research data, and the free routes โ€” Zenodo, Figshare, an institutional repository โ€” all have APIs you can call from a pipeline.

This guide prepares a deposit properly, uploads it, and handles the part people get wrong: versioning, so that the citation in a paper keeps meaning the same bytes. The deposit prepared here is a real one โ€” freehold parcel counts for Rutland's wards, built from HM Land Registry and ONS open data โ€” and the versioning is shown on a real published record: the Ferrara yield-map archive this site uses elsewhere.

What was run and what was not. Everything that reads โ€” the packaging, the metadata, the licence checks, the published records, the DOI resolution โ€” runs on this page. The calls that create and publish a deposit need an access token and would mint a real DOI, so they are shown as code that was not executed here; they follow Zenodo's developer documentation, which is where to check them against before you run them.

Quick answer

import requests, pathlib

BASE = "https://sandbox.zenodo.org/api"          # the sandbox: test DOIs, nothing permanent
HEADERS = {"Authorization": f"Bearer {TOKEN}"}   # a sandbox token, with deposit:write and deposit:actions

def create_deposit(metadata):
    r = requests.post(f"{BASE}/deposit/depositions", headers=HEADERS,
                      json={"metadata": metadata}, timeout=60)
    r.raise_for_status()
    return r.json()

def upload_file(deposit, path):
    bucket = deposit["links"]["bucket"]
    path = pathlib.Path(path)
    with open(path, "rb") as fh:
        r = requests.put(f"{bucket}/{path.name}", data=fh, headers=HEADERS, timeout=1800)
    r.raise_for_status()
    return r.json()

def publish(deposit):
    r = requests.post(deposit["links"]["publish"], headers=HEADERS, timeout=120)
    r.raise_for_status()
    return r.json()["doi"]

Test the whole flow against the sandbox first โ€” https://sandbox.zenodo.org/api with a token made on the sandbox site. Publishing on the live service is irreversible: files in a published record cannot be changed, only superseded by a new version. Without a token, both services refuse even a read of your deposits:

for base in ("https://sandbox.zenodo.org/api", "https://zenodo.org/api"):
    reply = requests.get(f"{base}/deposit/depositions", timeout=60)
    print(base, reply.status_code, reply.json()["message"])
https://sandbox.zenodo.org/api 403 Permission denied.
https://zenodo.org/api 403 Permission denied.
Publishing a dataset to a repository and minting a DOI. one-way, so check firstfreeze theversionhash itprivacy +metadata gateslast chancecreate depositreserve the DOIupload +metadatastreamedpublishimmutableTest the whole flow on the sandbox first spatialworkflow.io
Publication is one-way; everything that needs to be right must be right before it.

Step-by-step solution

1. Freeze the version first

A citable identifier points at bytes. Produce the release, compute its content hash and its version identifier, and do not touch it again. Dataset versioning explained covers the numbering.

2. Package the deposit so it stands alone

A deposit should be usable by someone with no context: the data, the metadata record, the licence text, an attribution file, a README that states what one feature is, and โ€” if the data is derived โ€” the lineage record. Example 1 builds that folder for the Rutland counts.

3. Fill in the repository metadata properly

Repository metadata is what the DOI resolves to, and it is what search engines index. Title, description, creators with ORCIDs, keywords, the licence, and โ€” the field people skip โ€” the related identifiers linking to the source datasets and to any paper. The licence has to be one of the repository's own identifiers, and Zenodo's are lowercase: cc-by-4.0 and ogl-uk-3.0 exist, CC-BY-4.0 does not (Example 2).

4. Reserve the DOI before you publish if you need to cite it

Zenodo can reserve a DOI on an unpublished deposit, so a paper can cite a dataset that is published at the same time as the paper. The reserved DOI is in metadata.prereserve_doi.doi. It is not registered with DataCite until the deposit is published, so it does not resolve until then โ€” cite it, but do not test the link.

5. Use the concept DOI and the version DOI deliberately

Repositories mint two: a concept DOI that always resolves to the latest version, and a version DOI for each release. A paper's methods section should cite the version DOI; a general "the data is here" link should use the concept DOI. Example 3 shows both on a record with three versions.

6. Put the DOI back into the dataset's metadata

The identifier is metadata about the dataset, and the dataset's own record should carry it โ€” which means either publishing the record twice, or writing it with the reserved DOI before upload.

7. Record the deposit in the pipeline's lineage

So that the artefact and the published record can be tied together later.

8. Do not publish personal data by accident

A repository deposit is irreversible and widely mirrored. Run the privacy gate before the upload, not after โ€” How to run a privacy check before publishing a spatial dataset.

A concept DOI resolving to the latest of three version DOIs. two kinds of DOIconcept DOI10.5281/zenodo.123456always the latestversion 2026.1โ€ฆzenodo.123459cite this in a methodversion 2025.2โ€ฆzenodo.123458still downloadableversion 2025.1โ€ฆzenodo.123457superseded, keptA superseded version stays available, or old results become unverifiable spatialworkflow.io
Cite the version DOI in a method; link the concept DOI in a footer.

Code examples

The data used on this page

The deposit is built from HM Land Registry's INSPIRE Index Polygons for Rutland (Open Government Licence, ยฉ Crown copyright and database rights) and Rutland's 15 wards from the ONS Wards (December 2024) boundaries: the number of freehold parcels whose representative point falls in each ward. The published record examined in Example 3 is Zenodo's 14216165, the Ferrara field yield maps.

import hashlib
import io
import json
import zipfile
from pathlib import Path

import geopandas as gpd
import requests

Path("data").mkdir(exist_ok=True)
if not Path("data/Rutland.zip").exists():
    reply = requests.Session().get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300)
    reply.raise_for_status()
    Path("data/Rutland.zip").write_bytes(reply.content)
if not Path("data/wards.geojson").exists():
    reply = requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "LAD24CD='E06000017'", "outFields": "WD24CD,WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120)
    reply.raise_for_status()
    Path("data/wards.geojson").write_bytes(reply.content)

with zipfile.ZipFile("data/Rutland.zip") as z:
    z.extract("Land_Registry_Cadastral_Parcels.gml", "data")
parcels = gpd.read_file("data/Land_Registry_Cadastral_Parcels.gml", columns=["INSPIREID"])
wards = gpd.read_file("data/wards.geojson")
points = parcels.set_geometry(parcels.representative_point())
counts = gpd.sjoin(points, wards, predicate="within").groupby("WD24CD").size().rename("parcels")
release = wards.merge(counts, on="WD24CD")
release["parcels_per_km2"] = (release["parcels"] / (release.area / 1e6)).round(1)
print(f"{len(release)} wards, {release['parcels'].sum():,} parcels")
15 wards, 21,007 parcels

Example 1 โ€” package the deposit so it stands alone

def sha256_of(path):
    digest = hashlib.sha256()
    with open(path, "rb") as f:
        for block in iter(lambda: f.read(1 << 20), b""):
            digest.update(block)
    return digest.hexdigest()

VERSION = "2026.09"
deposit = Path(f"deposit/rutland_parcels_per_ward_{VERSION}")
deposit.mkdir(parents=True, exist_ok=True)
release.to_parquet(deposit / "rutland_parcels_per_ward.parquet")    # byte-stable, so the hash is reproducible

record = {
    "title": "Freehold parcels per ward, Rutland",
    "description": "Count and density of HM Land Registry freehold title polygons in each of Rutland's 15 wards.",
    "feature_definition": "one ONS ward (December 2024 boundary), with the number of INSPIRE index polygons "
                          "whose representative point falls inside it",
    "content_date": "INSPIRE extract downloaded " + VERSION,
    "lineage": ["HM Land Registry INSPIRE Index Polygons, Rutland",
                "ONS Wards (December 2024) Boundaries UK BFC",
                "parcels assigned to wards by representative point (GeoPandas sjoin, within)"],
    "licence": "ogl-uk-3.0",
    "keywords": ["land registry", "parcels", "Rutland", "wards"],
    "content_sha256": sha256_of(deposit / "rutland_parcels_per_ward.parquet"),
}
(deposit / "metadata.json").write_text(json.dumps(record, indent=2))
(deposit / "README.txt").write_text(
    f"{record['title']} (version {VERSION})\n\nOne feature is {record['feature_definition']}.\n\n"
    f"Lineage:\n" + "".join(f"- {step}\n" for step in record["lineage"]) +
    f"\nSHA-256 of the Parquet file: {record['content_sha256']}\n")
(deposit / "ATTRIBUTION.txt").write_text(
    # HM Land Registry's statement, word for word from the licence in its download; the counts reuse
    # no polygon geometry, so its Ordnance Survey polygon statement does not apply
    "This information is subject to Crown copyright and database rights 2026 and is reproduced "
    "with the permission of HM Land Registry. "
    "Conditions: https://use-land-property-data.service.gov.uk/datasets/inspire/#conditions\n"
    "Ward boundaries: Source: Office for National Statistics licensed under the Open Government Licence v.3.0. "
    "Contains OS data ยฉ Crown copyright and database right 2026.\n")
with zipfile.ZipFile("data/Rutland.zip") as z:                    # the licence text, as supplied
    (deposit / "INSPIRE Download Licence.pdf").write_bytes(z.read("INSPIRE Download Licence.pdf"))
for f in sorted(deposit.iterdir()):
    print(f"{f.name:34} {f.stat().st_size:>8,} bytes")
ATTRIBUTION.txt                         385 bytes
INSPIRE Download Licence.pdf         35,584 bytes
README.txt                              474 bytes
metadata.json                           779 bytes
rutland_parcels_per_ward.parquet    314,513 bytes

HM Land Registry's download carries its licence as a PDF, and that PDF gives the attribution statement to use, word for word; a deposit that reuses the data has to carry both.

Example 2 โ€” build the repository metadata from your own record

def zenodo_metadata(record, version, creators, related=()):
    return {
        "title": record["title"],
        "upload_type": "dataset",
        "description": (
            f"<p>{record['description']}</p>"
            f"<p><b>One feature is:</b> {record['feature_definition']}</p>"
            f"<p><b>Content date:</b> {record['content_date']}</p>"
            f"<p><b>Lineage:</b> {'; '.join(record['lineage'])}</p>"
        ),
        "creators": creators,                      # [{"name": "Last, First", "orcid": "โ€ฆ"}]
        "version": version,
        "license": record["licence"],              # one of Zenodo's licence ids, lowercase
        "keywords": record.get("keywords", []),
        "related_identifiers": [
            {"identifier": r["id"], "relation": r.get("relation", "isDerivedFrom"),
             "scheme": r.get("scheme", "url")} for r in related
        ],
        "notes": f"Content SHA-256: {record['content_sha256']}",
    }

metadata = zenodo_metadata(
    record, VERSION, creators=[{"name": "Example, Author"}],
    related=[{"id": "https://use-land-property-data.service.gov.uk/datasets/inspire"},
             {"id": "https://geoportal.statistics.gov.uk/"}])

for licence in (metadata["license"], "OGL-UK-3.0", "cc-by-4.0", "CC-BY-4.0", "odbl-1.0"):
    reply = requests.get(f"https://zenodo.org/api/vocabularies/licenses/{licence}", timeout=60)
    title = reply.json()["title"]["en"] if reply.ok else "not a Zenodo licence id"
    print(f"{licence:12} {reply.status_code}  {title}")
print(metadata["notes"])
ogl-uk-3.0   200  Open Government Licence v3.0
OGL-UK-3.0   404  not a Zenodo licence id
cc-by-4.0    200  Creative Commons Attribution 4.0 International
CC-BY-4.0    404  not a Zenodo licence id
odbl-1.0     200  ODC Open Database License v1.0
Content SHA-256: 54c565ba9dd385f1fb17344c804f7957cdc97a796aeb3d135d0e03a26d9300d2

Putting the content hash in the notes is a small thing that pays off: a user who downloads the file five years later can check it against the record. Checking the licence id against the vocabulary before the deposit is the difference between a clean publish and a rejected one.

Example 3 โ€” concept and version DOIs on a real record

record_14216165 = requests.get("https://zenodo.org/api/records/14216165", timeout=60).json()
print("version DOI:", record_14216165["doi"], "| concept DOI:", record_14216165["conceptdoi"])
print("files:", [(f["key"], f"{f['size']:,} bytes", f["checksum"]) for f in record_14216165["files"]])

versions = requests.get("https://zenodo.org/api/records/14216165/versions", timeout=60).json()["hits"]["hits"]
for v in versions:
    print(f"  {v['id']:>9}  {v['doi']}  published {v['metadata']['publication_date']}")

latest = requests.get(f"https://zenodo.org/api/records/{record_14216165['conceptrecid']}", timeout=60)
print("the concept id resolves to record", latest.json()["id"])
for doi in (record_14216165["doi"], record_14216165["conceptdoi"]):
    reply = requests.head(f"https://doi.org/{doi}", allow_redirects=False, timeout=60)
    print(doi, "->", reply.status_code, reply.headers["location"])
version DOI: 10.5281/zenodo.14216165 | concept DOI: 10.5281/zenodo.7365838
files: [('FIELD_YIELD.zip', '10,957,622 bytes', 'md5:4bdfa154e12fd901f29b60f210820683')]
   14216165  10.5281/zenodo.14216165  published 2024-08-22
   14216160  10.5281/zenodo.14216160  published 2024-08-22
    7365839  10.5281/zenodo.7365839  published 2022-11-26
the concept id resolves to record 14216165
10.5281/zenodo.14216165 -> 302 https://zenodo.org/doi/10.5281/zenodo.14216165
10.5281/zenodo.7365838 -> 302 https://zenodo.org/doi/10.5281/zenodo.7365838

A paper that used the 2022 version and cited the concept DOI now points readers at a different upload. The version DOI is the one that keeps meaning the same bytes. The file checksum Zenodo publishes is MD5, so compute it as well as your own SHA-256 if readers are to check a download against the landing page.

The three versions of Zenodo record 14216165 and the concept DOI that resolves to the latest. one archive, three versions, one concept DOIidentifierpublishedresolves to10.5281/zenodo.7365838(concept)โ€”latest: 1421616510.5281/zenodo.142161652024-08-22this version10.5281/zenodo.142161602024-08-22this version10.5281/zenodo.73658392022-11-26this versionCite the version; link the concept spatialworkflow.io
Measured in Example 3. A 2022 paper that cited the concept DOI now sends readers to a 2024 upload.

Example 4 โ€” the full deposit, with a reserved DOI (not run here)

import requests, pathlib

def deposit_dataset(files, metadata, base="https://sandbox.zenodo.org/api", token=TOKEN):
    headers = {"Authorization": f"Bearer {token}"}

    dep = requests.post(f"{base}/deposit/depositions", headers=headers,
                        json={"metadata": {**metadata, "prereserve_doi": True}}, timeout=60)
    dep.raise_for_status()
    dep = dep.json()
    doi = dep["metadata"]["prereserve_doi"]["doi"]
    print("reserved DOI:", doi)

    for f in files:
        f = pathlib.Path(f)
        with open(f, "rb") as fh:
            r = requests.put(f"{dep['links']['bucket']}/{f.name}", data=fh, headers=headers, timeout=3600)
        r.raise_for_status()
        print(f"uploaded {f.name} ({f.stat().st_size / 1e6:.1f} MB), checksum {r.json()['checksum']}")
    return dep, doi

# deposit_dataset(sorted(deposit.iterdir()), metadata)

The bucket API streams the file rather than loading it into memory, and it is the one that takes large files: Zenodo documents 50 GB per record and up to 100 files, against 100 MB per file for its older files endpoint.

Example 5 โ€” publish a new version of an existing record (not run here)

def new_version(latest_record_id, files, metadata, base="https://sandbox.zenodo.org/api", token=TOKEN):
    """latest_record_id must be the newest version's id โ€” Zenodo refuses the concept id here."""
    headers = {"Authorization": f"Bearer {token}"}
    r = requests.post(f"{base}/deposit/depositions/{latest_record_id}/actions/newversion",
                      headers=headers, timeout=60)
    r.raise_for_status()
    draft_url = r.json()["links"]["latest_draft"]          # the response is the old record, not the draft
    draft = requests.get(draft_url, headers=headers, timeout=60).json()

    # a new version starts as a snapshot: remove the old files before uploading the new ones
    for f in requests.get(draft["links"]["files"], headers=headers, timeout=60).json():
        requests.delete(f"{draft['links']['self']}/files/{f['id']}", headers=headers, timeout=60)

    for f in files:
        f = pathlib.Path(f)
        with open(f, "rb") as fh:
            requests.put(f"{draft['links']['bucket']}/{f.name}", data=fh,
                         headers=headers, timeout=3600).raise_for_status()

    requests.put(draft["links"]["self"], headers=headers,
                 json={"metadata": metadata}, timeout=60).raise_for_status()
    return requests.post(draft["links"]["publish"], headers=headers, timeout=120).json()["doi"]

Two details from the documentation are easy to get wrong. The new-version action takes the id of the latest version; the concept id โ€” the obvious thing to pass, and what this function used to take โ€” is refused. And the new draft inherits a snapshot of the previous version's files, so uploading without clearing them publishes both sets.

Explanation

Why a DOI is different from a URL

A DOI is an identifier plus a promise of resolution. The registry holds a mapping from the identifier to a current location, and the repository commits to keeping the landing page alive. When the server is replaced, the mapping is updated and every citation keeps working โ€” which a bare URL does not.

Why immutability is the point

The value of a citation is that a reader can obtain the same data. A repository that let you edit files behind a published DOI would destroy that, which is why they do not. The corollary is that the check for personal data, wrong CRS, missing licence or a stale metadata record has to happen before publication, because afterwards the only remedy is a new version and a note.

Why concept and version DOIs both exist

A methods section needs to say exactly which bytes were used, which is the version DOI. A project page wants a link that stays current, which is the concept DOI. Publishing only one of them forces users to choose between reproducibility and freshness.

They are how a reader gets from your derived product to its sources, and how a source's maintainers see what has been built from their work. isDerivedFrom for the inputs, isSupplementTo for a paper, isNewVersionOf for a supersession โ€” three relations cover almost everything.

What belongs in a repository deposit besides the data. what goes in the depositthe data, at one frozen versionthe metadata recordderived and authoredthe licence text and attribution noticesa README defining what one feature isthe lineage record for derived datathe content checksum, in the notesThe landing page description is not downloaded with the files spatialworkflow.io
A deposit has to stand alone for someone with no context.

Edge cases or notes

  • Test on the sandbox. Publishing is irreversible, and sandbox DOIs are test DOIs.
  • File size limits apply. Zenodo documents 50 GB and 100 files per record through the bucket API.
  • Use the repository's licence ids. Zenodo's are lowercase: cc-by-4.0, ogl-uk-3.0, odbl-1.0.
  • Put a README in the upload. The landing page description is not downloaded with the files.
  • ORCIDs make creators unambiguous. Names are not identifiers.
  • Archive the code too. A DOI for the pipeline repository, linked with isSupplementTo.
  • Embargo if you must publish late. Repositories support an embargo date with a licence.
  • Mint the DOI once per version, not per file.
  • Send the token in a header. Zenodo accepts ?access_token= in the URL but recommends the Authorization header, which does not end up in logs.

FAQ

How do I get a DOI for a spatial dataset?

Deposit it in a repository that mints them โ€” Zenodo, Figshare or an institutional repository โ€” through its API, with metadata, files and a licence, then publish the deposit.

What is the difference between a concept DOI and a version DOI?

The concept DOI always resolves to the latest version; a version DOI points at one immutable release. Cite the version DOI in a method and link the concept DOI elsewhere.

Can I change the files after publishing?

No. Published records are immutable; you publish a new version instead. That is why the privacy and metadata checks have to run before the upload.

Can I reserve a DOI before publishing?

Yes. Create the deposit with prereserve_doi and read the identifier from the response, so a paper can cite a dataset published at the same time. The reserved DOI is not registered until you publish, so it will not resolve before then.

What should go in the deposit besides the data?

The metadata record, the licence text, the attribution notices, a README defining what one feature is, and the lineage record for derived data.

How do I publish an update?

Use the repository's new-version action on the latest version's id (Zenodo refuses the concept id), delete the inherited files, upload the new ones, update the metadata and publish. Forgetting the deletion publishes both versions' files together.