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.
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.
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.
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.
Why related identifiers are worth filling in
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.
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 theAuthorizationheader, which does not end up in logs.
Internal links
- Dataset versioning explained โ freezing what the DOI points at
- How to checksum spatial datasets so you can prove they match โ the hash in the deposit notes
- Spatial metadata explained: what a dataset must tell you โ the record the deposit is built from
- How to package GIS deliverables in Python โ what goes in the upload
- How to run a privacy check before publishing a spatial dataset โ the gate before an irreversible step
- Open data licences explained for spatial data โ choosing the licence field
- Provenance and lineage explained for spatial workflows โ the related identifiers
- How to build a reproducible GIS workflow in Python โ archiving the code alongside
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.