License Compatibility & Derivative Works
Combining two spatial datasets is a licensing operation as much as a geometric one, and the question it raises — what may the combined product be licensed under — has an answer that follows mechanically from the inputs and the intended distribution. This topic sits under Geospatial Data Licensing & Compliance Fundamentals and covers how to encode that answer as a rule set your pipeline can evaluate, rather than a judgement someone makes under deadline pressure at publication time.
Prerequisites
- Python 3.11+ with
geopandas==0.14.4andpandas==2.2.2for inventory handling. - A licence register — a table mapping each dataset identifier to an SPDX short-form identifier and the date that identifier was resolved.
- A declared distribution intent per product: internal, public download, or rendered output only. Compatibility cannot be evaluated without it.
- Write access to the pipeline’s build configuration, since compatibility resolution belongs at the join stage rather than at export.
- Familiarity with the obligation profiles described in open data license selection and comparison.
Concept & Spec Reference
Compatibility is not a property of a licence pair. It is a property of a licence pair plus an intended use, and every rule set that omits the second term produces answers that are confidently wrong in one direction or the other.
Three obligation classes account for the behaviour of the open licences that appear in spatial catalogues.
Permissive obligations impose nothing on the combined product. CC0-1.0 and public domain dedications sit here. They never constrain what the output may be, and they never conflict with anything.
Attribution obligations require that the source be credited in the combined product but say nothing about the licence that product carries. CC-BY-4.0 and most open government licences sit here. They accumulate — five attribution-bearing sources produce five entries in the notice — but they never collide.
Share-alike obligations require that the combined product itself be licensed under specified terms. ODbL-1.0 and CC-BY-SA-4.0 sit here, and this is where all real conflicts live. Two share-alike licences that each demand the whole be licensed under themselves cannot both be satisfied, and no amount of notice text resolves it.
| Obligation class | Example | Constrains output licence | Conflicts with |
|---|---|---|---|
| Permissive | CC0-1.0, PDDL-1.0 | No | Nothing |
| Attribution | CC-BY-4.0, OGL-3.0 | No | Nothing |
| Share-alike, database scope | ODbL-1.0 | Yes, on a distributed derivative database | Other share-alike terms |
| Share-alike, work scope | CC-BY-SA-4.0 | Yes, on any distributed adaptation | Other share-alike terms |
| Field-restricted | CC-BY-NC-4.0 | Yes, and restricts use | Any commercial intent |
The distribution intent term interacts with the third and fourth rows specifically. ODbL’s share-alike attaches to a distributed derivative database; an internal analysis that never leaves the organisation does not trigger it. CC-BY-SA’s attaches to any shared adaptation, which includes a rendered map. A rule set that evaluates the licence pair without knowing whether the output is a public GeoPackage, an internal notebook, or a tile layer will apply the obligation in cases where it does not arise, and teams that discover this stop trusting the checker.
Implementation Walkthrough
Step 1 — Model obligations, not licences
Encoding a table of licence pairs does not scale: adding one licence means adding a row for every existing licence. Encoding each licence’s obligations once, and deriving pair behaviour from them, means adding a licence is a single entry.
"""Obligation model for the open licences that appear in spatial catalogues."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Obligations:
"""What a licence demands of a product that incorporates it."""
spdx: str
requires_attribution: bool
# Scope in which a share-alike term fires: None, "database", or "work".
share_alike_scope: str | None
# True when the licence forbids commercial use of the combined product.
non_commercial: bool
REGISTER = {
"CC0-1.0": Obligations("CC0-1.0", False, None, False),
"PDDL-1.0": Obligations("PDDL-1.0", False, None, False),
"CC-BY-4.0": Obligations("CC-BY-4.0", True, None, False),
"OGL-3.0": Obligations("OGL-3.0", True, None, False),
"ODbL-1.0": Obligations("ODbL-1.0", True, "database", False),
"CC-BY-SA-4.0": Obligations("CC-BY-SA-4.0", True, "work", False),
"CC-BY-NC-4.0": Obligations("CC-BY-NC-4.0", True, None, True),
}
The rationale for share_alike_scope being a string rather than a boolean is the distinction the previous section drew: ODbL and CC-BY-SA both impose share-alike, and they impose it on different things. Collapsing them to a single flag reproduces the most common error in hand-written compatibility tables.
Step 2 — Describe the intended output
The second input is the product, and three properties of it decide which obligations fire.
@dataclass(frozen=True)
class Intent:
"""What is going to be done with the combined result."""
# True when the product leaves the organisation in any form.
distributed: bool
# "database" when features are redistributable; "work" for a rendered image.
form: str
commercial: bool
INTERNAL = Intent(distributed=False, form="database", commercial=True)
PUBLIC_DATA = Intent(distributed=True, form="database", commercial=True)
PUBLIC_MAP = Intent(distributed=True, form="work", commercial=True)
Step 3 — Evaluate the rule set
With obligations and intent modelled, the resolution is short enough to read in one sitting, which is the property that matters most for a rule people have to trust.
def fires(ob: Obligations, intent: Intent) -> bool:
"""Does this licence's share-alike term attach to this product?"""
if ob.share_alike_scope is None or not intent.distributed:
return False
if ob.share_alike_scope == "work":
return True
# Database-scope share-alike does not attach to a rendered produced work.
return intent.form == "database"
def resolve(spdx_ids, intent):
"""Return (output_licence, notices, conflicts) for a set of input licences."""
obs = [REGISTER[s] for s in sorted(set(spdx_ids))]
conflicts = []
if intent.commercial:
for ob in obs:
if ob.non_commercial:
conflicts.append(
f"{ob.spdx} forbids commercial use of the combined product"
)
firing = [ob for ob in obs if fires(ob, intent)]
distinct = {ob.spdx for ob in firing}
if len(distinct) > 1:
conflicts.append(
"two share-alike terms attach at once: " + ", ".join(sorted(distinct))
)
notices = [ob.spdx for ob in obs if ob.requires_attribution]
output = sorted(distinct)[0] if len(distinct) == 1 else None
if output is None and not conflicts:
output = "CC-BY-4.0" if notices else "CC0-1.0"
return output, notices, conflicts
The function returns three things rather than a boolean deliberately. conflicts is what stops a build, output is what the publisher needs, and notices is what the attribution renderer consumes — and returning them together means a single evaluation serves all three consumers without any of them re-deriving the others.
Step 4 — Bind it to the inventory
The final step attaches the resolution to real layers, which is where the resolution date matters.
import datetime
def resolve_for_layers(inventory, layer_ids, intent):
"""inventory maps layer id -> {"spdx": str, "resolved_on": date}."""
rows = [inventory[i] for i in layer_ids]
stale_cutoff = datetime.date.today() - datetime.timedelta(days=365)
stale = [i for i in layer_ids if inventory[i]["resolved_on"] < stale_cutoff]
output, notices, conflicts = resolve([r["spdx"] for r in rows], intent)
return {
"output_licence": output,
"notices": notices,
"conflicts": conflicts,
"stale_licence_records": stale,
"evaluated_on": datetime.date.today().isoformat(),
}
Validation & CI Integration
The rule set is code, so it is testable in the ordinary way — and it must be, because a compatibility checker that has never rejected anything proves nothing about the catalogue it guards.
def test_share_alike_pair_conflicts():
output, _, conflicts = resolve(["ODbL-1.0", "CC-BY-SA-4.0"], PUBLIC_DATA)
assert output is None
assert conflicts, "two share-alike terms must be reported as a conflict"
def test_internal_use_does_not_trigger_share_alike():
output, _, conflicts = resolve(["ODbL-1.0", "CC-BY-SA-4.0"], INTERNAL)
assert not conflicts
assert output in {"CC-BY-4.0", "CC0-1.0"}
def test_rendered_map_escapes_database_share_alike():
output, _, conflicts = resolve(["ODbL-1.0", "CC-BY-4.0"], PUBLIC_MAP)
assert not conflicts
assert output == "CC-BY-4.0"
The second and third tests are the ones worth writing first. Any rule set will reject the obvious conflict; the failures that cost teams credibility are the false positives, where the checker blocks an internal analysis or a rendered tile layer that was never constrained in the first place.
Wire the evaluation into the same gate that runs policy enforcement gates for data pull requests, and have it fail on a non-empty conflicts list while reporting stale_licence_records as a warning. A stale record is not a licensing problem today, but it is the mechanism by which one arrives unannounced.
Derivative & Lineage Management
A compatibility verdict is a statement about a specific set of inputs at a specific moment, and three ordinary pipeline events invalidate it.
An input is added. The obvious case, and the one the gate catches, provided the gate runs on the layer set rather than on the output file. A pipeline that evaluates compatibility once at the start and then joins an additional layer in a later stage has verified something that no longer describes the product.
An input’s licence is corrected. Registers get fixed. A layer recorded as CC-BY-4.0 that turns out to be CC-BY-SA-4.0 changes the verdict for every product containing it, and finding that set requires the verdict to have recorded which layer identifiers it consumed. Storing the input identifiers with the verdict — not just the licences — makes it a query.
The intent changes. This is the least visible and the most common. A product built for internal analysis is later published; a data extract created for one partner is added to an open portal. Nothing about the data changed, so no data-driven check fires, and the original verdict is now an answer to a different question. Recording the intent alongside the verdict at least makes the mismatch detectable when the publication step declares its own intent and finds it disagrees.
The lineage practice that follows is to treat the verdict as an artifact with the same status as the data: written at build time, carrying the inputs, the intent, the rule-set version and the date, and retained with the product. Spatial data lineage and provenance tracking covers the graph structure this fits into.
Where the Rule Set Should Live
The obligation register is small, changes rarely, and is consulted by several pipelines — which makes it exactly the kind of thing teams put in a shared library and then cannot update.
The workable arrangement keeps the register and the resolution function in one importable module, versioned independently of any pipeline, and has each pipeline pin a version. A licence added to the register is a patch release that no pipeline is obliged to take. A change to how an obligation is modelled — the day someone decides ODbL’s scope needs a third value — is a major release, and the pinning means it lands in one pipeline at a time rather than in all of them on the same Tuesday.
What should not be shared is the intent. Distribution intent is a property of a product, not of an organisation, and centralising it produces a configuration file that lists every product’s intent and is updated by nobody. Declaring the intent in the build that produces the product keeps it next to the person who knows the answer.
The register also deserves one test that has nothing to do with any pipeline: assert that every entry’s SPDX identifier is canonical. A register containing ODbL rather than ODbL-1.0 will silently fail to match inventory records, and the resolution will proceed as though that layer imposed nothing at all — the one failure mode of this design that produces a confident wrong answer rather than an error.
Pitfalls & Resolution Table
| Pitfall | Root Cause | Resolution Strategy |
|---|---|---|
| Checker blocks an internal join of ODbL and CC-BY-SA layers | Rule set keyed on the licence pair with no notion of distribution | Model intent explicitly; ODbL share-alike attaches only to a distributed derivative database |
| Three layers pass pairwise but the product is unpublishable | Compatibility tested pair by pair rather than across the whole input set | Evaluate the full set at once; collect all firing share-alike terms before deciding |
| A rendered tile layer is refused because a source is ODbL | The produced-work distinction is not modelled | Give the intent a form term; database-scope share-alike does not attach to a rendered work |
| Output licence silently changes between two builds | An upstream register correction altered an input licence with no verdict recorded | Store input identifiers and the rule-set version on every verdict; diff verdicts across builds |
| CC-BY-NC layer passes because no commercial intent was declared | Intent defaults to non-commercial when unspecified | Make commercial a required field with no default; refuse to evaluate without it |
| Attribution notice omits a CC0 source the team wanted credited | Courtesy citation modelled as a licence obligation, or not modelled at all | Keep a separate courtesy_credits list; never conflate it with requires_attribution |
| Verdict recorded but nobody can explain it a year later | The rule-set version was not stored | Stamp the rule-set version and evaluation date on every verdict |
Related
- Building a License Compatibility Matrix in Python — turning the obligation model into a matrix an engineer can read
- Combining ODbL and CC-BY Layers in One Product — the specific case that arises most often in municipal work
- Attribution Stacking for Multi-Source Basemaps — rendering the notices a compatible combination still owes
- Open Data License Selection & Comparison — choosing the licence for a dataset you own rather than one you are combining
- Geospatial Data Licensing & Compliance Fundamentals — the parent guide to obligations, risk surface and pipeline integration