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.

Decision tree from the inputs' licences and the kind of output to the licence the output may carry.
Two questions decide it: is anything non-commercial, and is anything share-alike that bites on this kind of output.

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.

Grid of licence pairs showing which combinations are compatible for a produced work and for a derivative database.
The same pair can be fine for a map and impossible for a dataset.

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.

Checklist of licence gate checks: every layer licensed, every identifier known, the output kind stated, the decision recorded, and terms of service handled separately.
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. 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.

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