How to check licence compatibility before combining datasets
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.
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
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 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 lic.values() if l not in LICENCES]
if unrecognised:
raise ValueError(f"licence identifiers not in the table: {sorted(set(unrecognised))}")
result = resolve(list(lic.values()), output_kind, commercial_use)
if not result["ok"]:
raise ValueError(f"licence conflict: {result['reason']}\n layers: {lic}")
return result | {"layers": lic}
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:
| inputs | produced work | derivative database |
|---|---|---|
| ODbL + OGL | your choice, with attribution | must be ODbL |
| ODbL + CC-BY-SA | your choice, with attribution | 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
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))
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 the result to be CC-BY-SA. The ODbL requires a derivative database to be ODbL. 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 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. As layers in a produced work such as a map image, both simply need attribution.
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.