How to check licence compatibility before combining datasets
On this page
Problem statement
The map is finished, it uses six layers, and somebody asks what licence it goes out under. The honest answer requires knowing every input's licence, whether what you are publishing is a database or a picture, and whether any of them is share-alike or non-commercial.
Done by hand, this happens at the end, when the answer is expensive. Done as a check in the pipeline, it happens at the start, when swapping a layer still costs nothing.
This guide encodes the licence properties, implements the compatibility rule for both kinds of output, and wires it into a gate.
Quick answer
LICENCES = {
"CC0-1.0": dict(attribution=False, share_alike=None, commercial=True),
"CC-BY-4.0": dict(attribution=True, share_alike=None, commercial=True),
"CC-BY-SA-4.0": dict(attribution=True, share_alike="always", commercial=True),
"ODbL-1.0": dict(attribution=True, share_alike="database", commercial=True),
"ODC-BY-1.0": dict(attribution=True, share_alike=None, commercial=True),
"OGL-UK-3.0": dict(attribution=True, share_alike=None, commercial=True),
"CC-BY-NC-4.0": dict(attribution=True, share_alike=None, commercial=False),
"proprietary": dict(attribution=True, share_alike="always", commercial=False),
}
def resolve(inputs, output_kind="produced_work", commercial_use=True):
props = {i: LICENCES[i] for i in set(inputs)}
blocking = [i for i, p in props.items() if commercial_use and not p["commercial"]]
if blocking:
return {"ok": False, "reason": f"non-commercial input(s): {sorted(blocking)}"}
binding = sorted(i for i, p in props.items()
if p["share_alike"] == "always"
or (p["share_alike"] == "database" and output_kind == "derivative_database"))
if len(binding) > 1:
return {"ok": False, "reason": f"conflicting share-alike inputs: {binding}"}
if binding:
return {"ok": True, "output_licence": binding[0], "reason": f"share-alike from {binding[0]}"}
return {"ok": True, "output_licence": "your choice", "reason": "attribution only"}
The output_kind argument is the whole game. The same set of inputs gives a different answer for a map image and for a dataset.
What you get โ from The data used on this page
all 2024 records: {'CC_BY_4_0': 85416, 'CC_BY_NC_4_0': 2972, 'CC0_1_0': 2652}
the 300 kept here: {'CC-BY-4.0': 169, 'CC0-1.0': 101, 'CC-BY-NC-4.0': 30}
boundary.meta.json OGL-UK-3.0
countries.meta.json LicenseRef-PublicDomain
gbif_2024.meta.json ['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC0-1.0']
roads.meta.json ODbL-1.0
Step-by-step solution
1. Store each layer's licence as an identifier
An SPDX identifier in the layer's metadata record, not a sentence in a wiki. Everything in this guide depends on the value being comparable.
2. Decide what you are publishing
A produced work is a map image, a report, a chart, a set of statistics. A derivative database is a dataset โ GeoPackage, GeoJSON, tiles carrying attributes, an API serving features. Under the ODbL these have different obligations, and getting the classification wrong is the commonest real error.
3. Run the check on the inputs, at the top of the pipeline
Not on the outputs at the end. The check is cheap, and its value is that it fails before anybody has built anything.
4. Treat an unknown licence as a failure
A layer whose licence is None or "unknown" makes the whole result unpublishable. That is the correct answer, and the way it gets fixed is by somebody resolving the licence, which only happens if the build fails.
5. Collect the attribution strings at the same time
The same metadata pass that reads the licence should build the credit line, so that a layer swap updates both.
6. Record the decision
Put the resolved licence, the inputs it was derived from and the date into the output's metadata. In a year, the question "why is this CC-BY-SA?" has an answer.
7. Ask a lawyer for the cases the table cannot express
This check encodes the common open licences and the common outputs. It does not encode terms of service, contractual restrictions, patents, personal data obligations or jurisdiction. It is a gate that catches obvious errors, not legal advice.
Code examples
The data used on this page
Four real layers for the Isle of Wight, each with the licence its provider states, recorded in a .meta.json beside it the way a pipeline would: OpenStreetMap roads from Geofabrik's extract (ODbL-1.0), the island's boundary from the ONS (Open Government Licence v3.0), Natural Earth's 1:110m countries (public domain, which has no SPDX identifier), and 300 GBIF occurrence records from 2024 โ a layer whose records carry their own licences, so its metadata lists every licence that occurs in it.
import json
from pathlib import Path
import geopandas as gpd
import requests
Path("data").mkdir(exist_ok=True)
def save(gdf, name, licence, source):
gdf.to_file(f"data/{name}.gpkg")
Path(f"data/{name}.meta.json").write_text(json.dumps({"licence": licence, "source": source}, indent=2))
pbf = Path("isle-of-wight-latest.osm.pbf")
if not pbf.exists():
pbf.write_bytes(requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
"isle-of-wight-latest.osm.pbf", timeout=300).content)
save(gpd.read_file(pbf, layer="lines", where="highway IN ('primary','secondary')", columns=["name", "highway"]),
"roads", "ODbL-1.0", "OpenStreetMap via Geofabrik")
save(gpd.read_file("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_May_2024_Boundaries_UK_BGC/FeatureServer/0/query"
"?where=LAD24CD%3D%27E06000046%27&outFields=LAD24NM&outSR=4326&f=geojson"),
"boundary", "OGL-UK-3.0", "ONS Open Geography Portal")
save(gpd.read_file("https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip"),
"countries", "LicenseRef-PublicDomain", "Natural Earth")
SPDX = {"http://creativecommons.org/licenses/by/4.0/legalcode": "CC-BY-4.0",
"http://creativecommons.org/publicdomain/zero/1.0/legalcode": "CC0-1.0",
"http://creativecommons.org/licenses/by-nc/4.0/legalcode": "CC-BY-NC-4.0"}
records = requests.get("https://api.gbif.org/v1/occurrence/search", timeout=60, params={
"geometry": "POLYGON((-1.60 50.57,-1.06 50.57,-1.06 50.77,-1.60 50.77,-1.60 50.57))",
"year": 2024, "hasCoordinate": "true", "limit": 300}).json()["results"]
gbif = gpd.GeoDataFrame({"species": [r.get("species") for r in records],
"licence": [SPDX[r["license"]] for r in records]},
geometry=gpd.points_from_xy([r["decimalLongitude"] for r in records],
[r["decimalLatitude"] for r in records]), crs=4326)
save(gbif, "gbif_2024", sorted(gbif["licence"].unique()), "GBIF occurrence search")
facet = requests.get("https://api.gbif.org/v1/occurrence/search", timeout=60, params={
"geometry": "POLYGON((-1.60 50.57,-1.06 50.57,-1.06 50.77,-1.60 50.77,-1.60 50.57))",
"year": 2024, "hasCoordinate": "true", "limit": 0, "facet": "license"}).json()
print("all 2024 records:", {c["name"]: c["count"] for c in facet["facets"][0]["counts"]})
print("the 300 kept here:", gbif["licence"].value_counts().to_dict())
for m in sorted(Path("data").glob("*.meta.json")):
print(f"{m.name:26}", json.loads(m.read_text())["licence"])
all 2024 records: {'CC_BY_4_0': 85416, 'CC_BY_NC_4_0': 2972, 'CC0_1_0': 2652}
the 300 kept here: {'CC-BY-4.0': 169, 'CC0-1.0': 101, 'CC-BY-NC-4.0': 30}
boundary.meta.json OGL-UK-3.0
countries.meta.json LicenseRef-PublicDomain
gbif_2024.meta.json ['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC0-1.0']
roads.meta.json ODbL-1.0
Example 1 โ the gate, from layer metadata to a decision
import json, pathlib
def layer_licences(paths):
out = {}
for p in paths:
meta_path = pathlib.Path(str(p).rsplit(".", 1)[0] + ".meta.json")
if not meta_path.exists():
out[str(p)] = None
continue
out[str(p)] = json.loads(meta_path.read_text()).get("licence")
return out
def flat(values): # a layer may carry several licences
return [v for l in values for v in (l if isinstance(l, list) else [l])]
def licence_gate(paths, output_kind, commercial_use=True):
lic = layer_licences(paths)
unknown = [p for p, l in lic.items() if not l]
if unknown:
raise ValueError(f"layers with no licence recorded: {unknown}")
unrecognised = [l for l in flat(lic.values()) if l not in LICENCES]
if unrecognised:
raise ValueError(f"licence identifiers not in the table: {sorted(set(unrecognised))}")
result = resolve(flat(lic.values()), output_kind, commercial_use)
if not result["ok"]:
raise ValueError(f"licence conflict: {result['reason']}")
return result | {"layers": lic, "output_kind": output_kind}
Run on the four layers for a published map:
layers = ["data/roads.gpkg", "data/boundary.gpkg", "data/countries.gpkg", "data/gbif_2024.gpkg"]
try:
licence_gate(layers, "produced_work")
except ValueError as exc:
print("1:", exc)
LICENCES["LicenseRef-PublicDomain"] = dict(attribution=False, share_alike=None, commercial=True)
try:
licence_gate(layers, "produced_work")
except ValueError as exc:
print("2:", exc)
commercial = gbif[gbif["licence"] != "CC-BY-NC-4.0"]
save(commercial, "gbif_2024_commercial", sorted(commercial["licence"].unique()), "GBIF, CC-BY-NC records removed")
layers[-1] = "data/gbif_2024_commercial.gpkg"
for kind in ("produced_work", "derivative_database"):
r = licence_gate(layers, kind)
print(f"3: {kind:20} -> {r['output_licence']} ({r['reason']}); {len(commercial)} of {len(gbif)} GBIF records kept")
1: licence identifiers not in the table: ['LicenseRef-PublicDomain']
2: licence conflict: non-commercial input(s): ['CC-BY-NC-4.0']
3: produced_work -> your choice (attribution only); 270 of 300 GBIF records kept
3: derivative_database -> ODbL-1.0 (share-alike from ODbL-1.0); 270 of 300 GBIF records kept
The gate failed twice before it passed, and both failures were real. Natural Earth is public domain, which has no SPDX identifier, so the table had to be told what LicenseRef-PublicDomain means rather than guessing. Then the GBIF layer blocked a commercial map, because 30 of its 300 records are CC-BY-NC-4.0 โ in the full 2024 record for the island that is 2,972 of 91,040. Dropping those records is a decision the gate forced someone to make; GBIF's download API can apply the same licence filter at source.
Example 2 โ the three answers that matter
for kind in ("produced_work", "derivative_database"):
for inputs in (["ODbL-1.0", "OGL-UK-3.0"],
["ODbL-1.0", "CC-BY-SA-4.0"],
["CC-BY-4.0", "CC0-1.0"],
["CC-BY-4.0", "CC-BY-NC-4.0"]):
r = resolve(inputs, kind)
print(f"{kind:20} {str(inputs):40} -> "
f"{r.get('output_licence', 'BLOCKED'):14} ({r['reason']})")
The pairs worth understanding, as the loop printed them:
| inputs | produced work | derivative database |
|---|---|---|
| ODbL + OGL | your choice, with attribution | must be ODbL |
| ODbL + CC-BY-SA | must be CC-BY-SA | blocked โ two conflicting share-alikes |
| CC-BY + CC0 | your choice | your choice |
| CC-BY + CC-BY-NC | blocked for commercial use | blocked for commercial use |
Example 3 โ record the decision with the output
The gate above returns output_kind alongside its result. An earlier version did not, and this function then recorded "output_kind": null in every decision โ the one field that explains why the same inputs got a different licence last time.
import datetime, json, pathlib
def record_licence_decision(output_path, result):
meta_path = pathlib.Path(str(output_path).rsplit(".", 1)[0] + ".meta.json")
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
meta["licence"] = result["output_licence"]
meta["licence_basis"] = {
"inputs": result["layers"],
"output_kind": result.get("output_kind"),
"reason": result["reason"],
"decided": datetime.date.today().isoformat(),
}
meta_path.write_text(json.dumps(meta, indent=2))
decision = licence_gate(layers, "derivative_database")
gbif.iloc[:0].to_file("data/iow_layers.gpkg") # stands in for the published dataset
record_licence_decision("data/iow_layers.gpkg", decision)
print(Path("data/iow_layers.meta.json").read_text())
{
"licence": "ODbL-1.0",
"licence_basis": {
"inputs": {
"data/roads.gpkg": "ODbL-1.0",
"data/boundary.gpkg": "OGL-UK-3.0",
"data/countries.gpkg": "LicenseRef-PublicDomain",
"data/gbif_2024_commercial.gpkg": [
"CC-BY-4.0",
"CC0-1.0"
]
},
"output_kind": "derivative_database",
"reason": "share-alike from ODbL-1.0",
"decided": "2026-09-30"
}
}
Explanation
Why "produced work" versus "derivative database" is the pivot
The ODbL's share-alike clause applies to databases, not to everything made from them. A PNG map of OpenStreetMap buildings is a produced work: it needs the credit and can otherwise be licensed however you like. A GeoJSON of the same buildings is a derivative database and must be ODbL. That single distinction resolves most of the confusion around OSM licensing, and it is a question about your artefact rather than your process.
Why two share-alike inputs can be fatal
CC-BY-SA requires anything adapted from it โ a dataset or a map โ to be CC-BY-SA. The ODbL requires a derivative database to be ODbL, but asks only for attribution on a produced work. A dataset built from both must be both, and it cannot be. The only routes out are to drop one input, or to publish a produced work instead of a database, which then carries CC-BY-SA โ which is why the check needs the output kind.
Why an unknown licence has to fail
"Probably fine" is how an unlicensed layer ends up in a published product. The value of encoding this as a gate is that the unknown becomes a build failure, which somebody has to resolve, rather than a nagging doubt that everyone defers.
Why the table is a floor and not an answer
Real obligations come from more than the data licence: API terms of service, contracts, personal data rules, national restrictions on derived products. This check is a fast filter for the errors that are purely about licence algebra. Anything it flags โ and anything involving money or risk โ deserves a human.
Edge cases or notes
- Tiles can be either kind. Raster tiles are a produced work; vector tiles carrying attributes are usually a database.
- An API serving features is a database. The obligations follow.
- Aggregated statistics are usually a produced work. A table of counts is not the database.
- Geocoder output often cannot be stored at all. That is a terms-of-service restriction, not a licence.
- CC-BY-NC is broader than "not sold". Treat any external deliverable as commercial.
- Version matters. CC-BY-SA 3.0 and 4.0 have different compatibility rules.
- Jurisdiction matters. The database right exists in the EU and UK and not everywhere.
- Record the licence you downloaded under. Terms change.
Internal links
- Open data licences explained for spatial data โ what each identifier requires
- Attribution requirements explained: OpenStreetMap, Copernicus and national data โ the credit line built in the same pass
- A downloaded layer has no licence you can find โ what to do when the gate fails
- Spatial metadata explained: what a dataset must tell you โ where the licence is recorded
- How to write a metadata record for a dataset in Python โ storing the decision
- How to package GIS deliverables in Python โ shipping the notices
- The OpenStreetMap data model explained โ the ODbL dataset you will meet most
- How to validate pipeline inputs and outputs in Python โ where the gate belongs
FAQ
Can I combine ODbL and CC-BY-SA data?
Not into one published database: each requires the result to carry its own licence. In a produced work such as a map image, the ODbL asks only for attribution, but CC-BY-SA's share-alike still applies โ the map must be published under CC-BY-SA.
Does a map image count as a derivative database?
No. Under the ODbL a map image is a produced work โ attribution is required, ODbL licensing is not. A dataset, an API or attribute-carrying vector tiles are derivative databases.
What should happen when a layer's licence is unknown?
The build should fail. An unresolved licence makes the output unpublishable, and a failure is what causes somebody to resolve it.
Where should this check run?
At the top of the pipeline, on the inputs. Discovering a conflict after the map is built is the expensive version.
Is CC-BY-NC usable for internal work?
Assume not for anything leaving your organisation. "Commercial" is read broadly and covers consultancy deliverables and advertising-supported publication.
Is this legal advice?
No. It encodes the common open licences and catches obvious conflicts. Contracts, terms of service, personal data rules and jurisdiction are outside it.