Combining ODbL and CC-BY Layers in One Product

An ODbL layer and a CC-BY layer can be combined and published as a single derivative database, and the result must be licensed under ODbL-1.0 with the CC-BY source credited in the notice — ODbL’s share-alike fixes the output licence, and CC-BY imposes attribution without constraining it.

This is the most common non-trivial licensing combination in municipal and national open data work, because road and address databases are typically ODbL while thematic overlays published by individual agencies are typically CC-BY. It is worth walking through in full because the correct outcome is frequently mistaken in both directions: teams either refuse the combination entirely, believing two open licences with obligations must conflict, or publish under CC-BY and quietly drop the share-alike term they inherited. The reasoning behind the verdict is set out in license compatibility and derivative works, which sits under Geospatial Data Licensing & Compliance Fundamentals.

What each source contributes to the combined productThe ODbL source fixes the output licence and requires database credit; the CC-BY source requires attribution only.Combined GeoPackagepublished for downloadRoad centrelinesODbL-1.0Fixes output licencederivative databaseRequires creditnamed in the noticeFlood risk zonesCC-BY-4.0No licence constraintattribution onlyRequires creditauthor named
One source decides the licence, the other decides part of the notice. Neither cancels the other.

Automated Python Implementation

The script below performs the combination and, in the same pass, writes the licence and notice that the combination obliges. Writing them together matters: a spatial join that produces a correct output file and leaves the licence field to a later step produces a file that is wrong in the only way anyone will notice.

#!/usr/bin/env python3
"""Combine an ODbL feature layer with a CC-BY overlay and license the result.

Usage:
    python combine_layers.py roads.gpkg flood_zones.gpkg combined.gpkg
"""
import json
import sys
import sqlite3

import geopandas as gpd

# Obligations of the two inputs, keyed by SPDX identifier.
SOURCES = {
    "roads": {
        "spdx": "ODbL-1.0",
        "holder": "City Open Data Programme",
        "share_alike": True,
    },
    "flood_zones": {
        "spdx": "CC-BY-4.0",
        "holder": "National Flood Agency",
        "share_alike": False,
    },
}


def resolve_output_licence(sources):
    """ODbL's database share-alike fixes the output; CC-BY does not."""
    sa = {s["spdx"] for s in sources.values() if s["share_alike"]}
    if len(sa) > 1:
        raise SystemExit(f"conflicting share-alike terms: {sorted(sa)}")
    return sa.pop() if sa else "CC-BY-4.0"


def build_notice(sources, output_licence):
    """One line per source, in a stable order, plus the output statement."""
    lines = [
        f"{name}: {meta['holder']} ({meta['spdx']})"
        for name, meta in sorted(sources.items())
    ]
    lines.append(f"Combined product licensed under {output_licence}.")
    return "\n".join(lines)


def write_gpkg_metadata(path, licence, notice):
    """Write the licence into the GeoPackage metadata tables, not just a column."""
    con = sqlite3.connect(path)
    try:
        con.execute(
            "INSERT INTO gpkg_metadata "
            "(md_scope, md_standard_uri, mime_type, metadata) VALUES (?, ?, ?, ?)",
            ("dataset", "http://www.isotc211.org/2005/gmd", "text/plain",
             json.dumps({"license": licence, "attribution": notice})),
        )
        con.commit()
    finally:
        con.close()


def main(roads_path, zones_path, out_path):
    roads = gpd.read_file(roads_path)
    zones = gpd.read_file(zones_path)

    # Align CRS before the join; a silent mismatch produces an empty result.
    if roads.crs != zones.crs:
        zones = zones.to_crs(roads.crs)

    combined = gpd.sjoin(roads, zones, how="left", predicate="intersects")
    combined = combined.drop(columns=[c for c in combined.columns if c.startswith("index_")])

    licence = resolve_output_licence(SOURCES)
    notice = build_notice(SOURCES, licence)

    # Per-feature carriers survive format conversion; the metadata table does not.
    combined["license"] = licence
    combined["attribution"] = notice.replace("\n", " | ")

    combined.to_file(out_path, driver="GPKG", layer="roads_with_flood_risk")
    write_gpkg_metadata(out_path, licence, notice)
    print(f"wrote {out_path} under {licence}")


if __name__ == "__main__":
    main(*sys.argv[1:4])

Two details in that script are the whole point of it. resolve_output_licence raises rather than picking a winner when two share-alike terms are present — a combination this script cannot license is one it must refuse to produce. And the licence is written twice, once as a per-feature column that survives conversion to GeoJSON or shapefile and once into gpkg_metadata where a catalogue harvester will look for it, because neither carrier alone reaches both audiences.

Order of operations in the combinationCRS alignment, spatial join, licence resolution and notice writing, with the licence written into two carriers.Align CRSmismatch yields an empty joinjoinSpatial joinleft, intersectsresolveResolve licencerefuse on conflictwriteTwo carrierscolumn and metadata table
Resolving the licence before writing means a refusal costs nothing; resolving after means a file exists that should not.

Validation and Pipeline Integration

Confirm the licence reached both carriers before the product goes anywhere. The two checks read different parts of the file and a product can pass one while failing the other.

# The per-feature carrier, visible to any OGR consumer
ogrinfo -al -so combined.gpkg roads_with_flood_risk | grep -i license

# The dataset-level carrier, which is what a harvester reads
python -c "import sqlite3, json; \
c = sqlite3.connect('combined.gpkg'); \
print(json.loads(c.execute('SELECT metadata FROM gpkg_metadata').fetchone()[0]))"

In CI, assert the verdict rather than the file. A test over resolve_output_licence costs milliseconds and catches the regression that matters — someone adding a third source with a second share-alike term.

def test_odbl_fixes_the_output_licence():
    assert resolve_output_licence(SOURCES) == "ODbL-1.0"


def test_second_share_alike_source_is_refused():
    sources = dict(SOURCES)
    sources["landcover"] = {
        "spdx": "CC-BY-SA-4.0", "holder": "Mapping Collective", "share_alike": True,
    }
    try:
        resolve_output_licence(sources)
    except SystemExit:
        return
    raise AssertionError("two share-alike terms must be refused")

The second test is the one that earns its keep. It is also the assertion that proves the check is capable of rejecting something, which is the only evidence that it is doing any work at all.

What each carrier survives, and who reads itThe per-feature licence column and the GeoPackage metadata record compared by the conversions each survives and the consumer each reaches.Per-feature columngpkg_metadata recordConvert to GeoJSONsurvives as a propertylostConvert to shapefilesurvives, name truncatedlostCopy a single layersurvivesnot copiedRead by a catalogue harvesternot looked forreadSeen by a desktop GIS uservisible in the tablehidden
Neither carrier reaches both audiences, which is why the script writes both.

The table above is the argument for the apparent redundancy in the script. A per-feature column is what a analyst opening the file in a desktop GIS will actually see, and it is the only carrier that survives the format conversions users perform without thinking. The metadata record is what an automated harvester queries, and it is the only carrier a catalogue will ever find. Writing one and not the other produces a product that is correctly licensed for exactly half of its audience, and which half depends on a choice made by whoever wrote the export step.

Long-Term Compliance Best Practices

  • Record the verdict, not just the licence. Store the input identifiers, their SPDX identifiers, the resolved output and the date in a sidecar next to the product. When an upstream register is corrected, the set of products to re-examine is a query rather than an archaeology exercise.
  • Never license the output more permissively than the strictest input. The failure is almost always accidental — a publishing template with a default licence field — and it is indistinguishable from deliberate stripping when someone looks at it later.
  • Keep the notice ordered deterministically. Sorting sources by name means two builds of the same product produce byte-identical notices, which makes a diff meaningful and stops attribution text from appearing in every commit.
  • Treat a rendered tile layer as a separate product. Under ODbL a rendered image is a produced work rather than a derivative database, so the tile layer may carry a different licence than the GeoPackage it was rendered from. Model it as its own product with its own intent, not as a copy of the database’s verdict.
  • Re-resolve upstream licences on a schedule. A verdict is a statement about the terms as they stood on the day it was computed, and municipal portals relicense without announcement.