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.

Resolving the licence an output may carry from its inputs. what licence can the output have?Any non-commercial input, and anyshare-alike that bites?NC presentblockedcannot publishtwo share-alikesblockedconflicting demandsone share-alikethat licenceforcedattribution onlyyour choicewith creditsRun it on the inputs, at the top of the pipeline spatialworkflow.io
Two questions decide it: is anything non-commercial, and is anything share-alike that bites on this kind of output.

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.

Four input pairs and the output licence each allows for two kinds of output. same inputs, two answersinputsproduced workderivative databaseODbL + OGLyour choicemust be ODbLODbL + CC-BY-SAmust be CC-BY-SAblockedCC-BY + CC0your choiceyour choiceCC-BY + CC-BY-NCblockedblockeda map image and a dataset built from the same layers are not the same questionCommercial publication assumed spatialworkflow.io
The same pair can be fine for a map and impossible for a dataset.

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.

Licences of the 2024 GBIF occurrence records for the Isle of Wight. one GBIF layer, three licencesCC-BY-4.085,416CC-BY-NC-4.02,972 โ€” blocks commercial useCC0-1.02,652045k90k3.3% of the records decide what the whole layer may be used for spatialworkflow.io
The setupโ€™s facet count; the gate blocked on the non-commercial bar.

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.

What a licence compatibility gate checks before a pipeline runs. the gateevery layer has a recorded licenceunknown is a failureevery identifier is in the tableno free textthe output kind is statedmap or datasetthe resolved licence is recordedwith its reasonsterms of service checked separatelynot a licence questionThe last line is the limit of what this can decide spatialworkflow.io
The gate encodes licence algebra, not contracts or terms of service.

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.

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.

No. It encodes the common open licences and catches obvious conflicts. Contracts, terms of service, personal data rules and jurisdiction are outside it.