Creative Commons Licensing for GIS Datasets

Creative Commons licensing is the operational standard for open geospatial data distribution, yet its implementation exposes unique technical challenges absent from traditional media licensing: spatial metadata schemas lack a universal license field, derivative tracking must survive format conversions and coordinate reprojections, and automated compliance pipelines must parse binary spatial files rather than plain text. GIS data managers, open-source maintainers, and government technology teams working within the broader framework of Geospatial Data Licensing & Compliance Fundamentals must embed licenses at the format level, validate them programmatically, and propagate attribution obligations through every transformation step to maintain defensible compliance.

Prerequisites & Environment Configuration

Before implementing automated CC licensing workflows, establish the following baseline:

  1. Python 3.9+, geopandas>=0.13, pyproj>=3.4, lxml>=4.9, rasterio>=1.3, and gdal (via osgeo). These libraries handle spatial I/O, CRS validation, and XML/JSON metadata generation.
  2. License selection policy: internal documentation mapping use cases to specific CC identifiers (CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0, CC-BY-NC-4.0). Non-Commercial and No-Derivatives clauses create integration friction and are generally discouraged for foundational spatial layers.
  3. Metadata schema familiarity: working knowledge of ISO 19115-1, Dublin Core, and GeoJSON community conventions. Spatial datasets require license metadata in both human-readable sidecar files and machine-readable attributes.
  4. Version control + provenance tracking: Git LFS or DVC for large spatial files, with a commit strategy that records license changes alongside data updates. Immutable provenance is essential when downstream consumers rely on specific license versions.

Creative Commons license selection flowchart for GIS datasets Decision tree: start at 'Select CC license', branch on attribution need, commercial use restriction, and ShareAlike requirement, arriving at CC0, CC-BY-NC, CC-BY-SA, or CC-BY. Select CC license Attribution required? No CC0 Public domain Yes Restrict commercial? Yes CC-BY-NC Non-commercial No ShareAlike on derivatives? Yes CC-BY-SA ShareAlike (viral) No CC-BY Attribution only

Concept & Spec Reference

Creative Commons licenses are modular: each variant activates a specific set of permissions and restrictions. In geospatial contexts, the four licenses in active use differ primarily in their effect on spatial joins, commercial API deployments, and catalogue redistribution.

License (SPDX) Attribution Commercial Derivatives Typical GIS Use Case
CC0-1.0 Not required Permitted Permitted Administrative boundaries, reference basemaps, census geometries
CC-BY-4.0 Required Permitted Permitted Government spatial data portals, academic research layers
CC-BY-SA-4.0 Required Permitted Must re-license same OpenStreetMap-derived layers; viral across joined datasets
CC-BY-NC-4.0 Required Prohibited Permitted Academic/municipal data not intended for commercial platforms

Key geospatial-specific points:

  • Database rights: in EU jurisdictions, spatial datasets may attract sui generis database rights independent of copyright. CC licenses address these rights since version 4.0 — earlier versions do not, creating gaps for European open-data portals.
  • NC clause and enterprise GIS: the CC-BY-NC restriction on commercial use conflicts directly with commercial EULA compliance tracking requirements for enterprise GIS stacks that charge users for hosted map services.
  • ShareAlike and OSM derivatives: any dataset derived from OpenStreetMap (licensed ODbL-1.0) carries separate ShareAlike obligations; do not confuse ODbL-1.0 with CC-BY-SA-4.0 — they are not interchangeable.

Normative references (non-hyperlinked per site rules): ISO 19115-1:2014/Amd 1:2018; SPDX License List 3.x; CC License 4.0 International legal text.

Implementation Walkthrough

Step 1 — Inject license into GeoJSON

GeoJSON has no mandated license field, but community conventions treat a root-level "license" key as canonical. Add it before publication:

import json
from pathlib import Path

CC_BY_URI = "https://creativecommons.org/licenses/by/4.0/"

def inject_geojson_license(path: Path, license_uri: str = CC_BY_URI) -> None:
    """Add or replace the root-level license key in a GeoJSON file."""
    with open(path, "r", encoding="utf-8") as fh:
        data = json.load(fh)
    data["license"] = license_uri
    with open(path, "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)

Rationale: root-level injection is preferred over per-feature properties because license terms apply to the whole dataset, not individual features.

Step 2 — Embed license in GeoPackage metadata table

GeoPackage’s gpkg_metadata table stores ISO 19115 or Dublin Core XML records linked to layers via gpkg_metadata_reference. A minimal Dublin Core block satisfies discovery requirements:

import sqlite3
from pathlib import Path

DUBLIN_CORE_TMPL = """<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://purl.org/dc/elements/1.1/">
  <identifier>{dataset_id}</identifier>
  <rights>{license_uri}</rights>
  <license>{license_uri}</license>
</metadata>"""

def embed_gpkg_license(gpkg_path: Path, dataset_id: str, license_uri: str) -> None:
    """Insert a Dublin Core license record into gpkg_metadata."""
    xml_content = DUBLIN_CORE_TMPL.format(
        dataset_id=dataset_id, license_uri=license_uri
    )
    conn = sqlite3.connect(str(gpkg_path))
    try:
        conn.execute(
            "INSERT OR REPLACE INTO gpkg_metadata "
            "(id, md_scope, md_standard_uri, mime_type, metadata) "
            "VALUES (1, 'dataset', 'http://dublincore.org/documents/dces/', "
            "'text/xml', ?)",
            (xml_content,)
        )
        conn.execute(
            "INSERT OR REPLACE INTO gpkg_metadata_reference "
            "(reference_scope, md_file_id) VALUES ('geopackage', 1)"
        )
        conn.commit()
    finally:
        conn.close()

Rationale: modifying gpkg_metadata is the only standards-compliant path; writing to layer attribute columns is non-standard and breaks inter-operability with QGIS and ArcGIS metadata readers.

Step 3 — Embed license in GeoTIFF via GDAL

Raster datasets require GDAL metadata tags. The ImageDescription tag and the GDAL_METADATA domain are the two injection points:

from osgeo import gdal
from pathlib import Path

def embed_geotiff_license(tif_path: Path, license_uri: str) -> None:
    """Write CC license URI into GeoTIFF GDAL metadata domain."""
    ds = gdal.Open(str(tif_path), gdal.GA_Update)
    if ds is None:
        raise FileNotFoundError(f"GDAL could not open: {tif_path}")
    ds.SetMetadataItem("LICENSE", license_uri)
    ds.SetMetadataItem("LICENSE", license_uri, "GDAL_METADATA")
    ds.FlushCache()
    ds = None  # close dataset

Rationale: FlushCache() writes metadata without re-encoding pixel data, preserving compression and georeferencing intact.

Step 4 — Preserve license across geopandas operations

geopandas does not persist custom attributes through GeoDataFrame.to_crs() or spatial overlay calls. Preserve the license by re-injecting it on every output write:

import geopandas as gpd
from pathlib import Path

def reproject_with_license(
    src: Path, dst: Path, target_crs: str, license_uri: str
) -> None:
    """Reproject a vector file and restore the license field in the output."""
    gdf = gpd.read_file(src)
    gdf_reprojected = gdf.to_crs(target_crs)
    # Re-inject as a dataset-level property in GeoJSON, or as a column for GPKG
    if dst.suffix == ".geojson":
        gdf_reprojected.to_file(dst, driver="GeoJSON")
        inject_geojson_license(dst, license_uri)
    elif dst.suffix == ".gpkg":
        gdf_reprojected.to_file(dst, driver="GPKG", layer="data")
        embed_gpkg_license(dst, dataset_id=dst.stem, license_uri=license_uri)
    else:
        raise ValueError(f"Unsupported output format: {dst.suffix}")

Rationale: making license injection the last step of every write operation is more reliable than attempting to propagate it through intermediate GeoDataFrame objects.

Validation & CI Integration

Manual metadata checks fail at scale. A robust validation pipeline parses spatial files, checks CC URIs against an allowlist, and halts publication on failure.

import json
import logging
import sqlite3
from pathlib import Path
from typing import Optional

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

ALLOWED_CC_URIS: set[str] = {
    "https://creativecommons.org/publicdomain/zero/1.0/",
    "https://creativecommons.org/licenses/by/4.0/",
    "https://creativecommons.org/licenses/by-sa/4.0/",
    "https://creativecommons.org/licenses/by-nc/4.0/",
}

def validate_geojson_license(path: Path) -> dict[str, Optional[str]]:
    """Validate the CC license field in a GeoJSON file."""
    try:
        with open(path, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        uri = data.get("license") or data.get("properties", {}).get("license")
        if not uri:
            logging.warning("Missing license in %s", path.name)
            return {"status": "missing", "uri": None, "file": str(path)}
        if uri not in ALLOWED_CC_URIS:
            logging.error("Unrecognized CC URI in %s: %s", path.name, uri)
            return {"status": "invalid", "uri": uri, "file": str(path)}
        return {"status": "valid", "uri": uri, "file": str(path)}
    except (json.JSONDecodeError, KeyError) as exc:
        logging.error("Parse error in %s: %s", path.name, exc)
        return {"status": "parse_error", "uri": None, "file": str(path)}

def validate_gpkg_license(path: Path) -> dict[str, Optional[str]]:
    """Check whether any gpkg_metadata row references a valid CC URI."""
    try:
        conn = sqlite3.connect(str(path))
        try:
            cur = conn.execute(
                "SELECT metadata FROM gpkg_metadata LIMIT 20"
            )
            rows = [r[0] or "" for r in cur.fetchall()]
        finally:
            conn.close()
        for uri in ALLOWED_CC_URIS:
            if any(uri in row for row in rows):
                return {"status": "valid", "uri": uri, "file": str(path)}
        return {"status": "missing", "uri": None, "file": str(path)}
    except Exception as exc:
        logging.error("GPKG read error in %s: %s", path.name, exc)
        return {"status": "read_error", "uri": None, "file": str(path)}

def run_compliance_check(directory: Path) -> list[dict]:
    """Scan a directory tree and return per-file validation results."""
    results = []
    for p in directory.rglob("*"):
        if p.suffix == ".geojson":
            results.append(validate_geojson_license(p))
        elif p.suffix == ".gpkg":
            results.append(validate_gpkg_license(p))
    return results

def assert_all_valid(directory: Path) -> None:
    """Raise RuntimeError if any spatial file lacks a valid CC license."""
    results = run_compliance_check(directory)
    failures = [r for r in results if r["status"] != "valid"]
    if failures:
        for f in failures:
            logging.error("License failure [%s]: %s", f["status"], f["file"])
        raise RuntimeError(
            f"{len(failures)} file(s) failed license validation — see log above."
        )

Pre-commit hook — add to .pre-commit-config.yaml:

repos:
  - repo: local
    hooks:
      - id: spatial-license-check
        name: Validate CC licenses in spatial files
        language: python
        entry: python -c "from validate_licenses import assert_all_valid; from pathlib import Path; assert_all_valid(Path('data/'))"
        pass_filenames: false
        types: []

For broader spatial data schema linting in CI, combine this license check with attribute-schema validation under the same pre-commit phase so a single git commit catches both structural and license failures.

ogrinfo spot-check — verify a GeoJSON file’s metadata attributes directly:

ogrinfo -al -so layers/admin_boundaries.geojson | grep -i license

Derivative & Lineage Management

When datasets undergo spatial transformations — buffering, clipping, rasterization, or attribute joins — the original license imposes downstream obligations. CC-BY-SA requires derivative works to carry the same license, which directly conflicts with proprietary data blending.

Lineage tracking — maintain a derivation_chain array in dataset metadata to record parent URIs and transformation types:

import json
from pathlib import Path
from datetime import datetime, timezone

def append_derivation_step(
    geojson_path: Path,
    parent_uri: str,
    transform: str,
    inherited_license: str,
) -> None:
    """Record a derivation step in the dataset's metadata."""
    with open(geojson_path, "r", encoding="utf-8") as fh:
        data = json.load(fh)
    chain = data.get("derivation_chain", [])
    chain.append({
        "parent": parent_uri,
        "transform": transform,
        "license": inherited_license,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })
    data["derivation_chain"] = chain
    data["license"] = inherited_license  # propagate
    with open(geojson_path, "w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)

Boundary isolation — use spatial indexes to keep CC-BY-SA layers physically separate from proprietary layers in the same workspace. A pre-publish script should scan for mixed-license spatial joins and halt if incompatible licenses are detected.

Attribution propagation through spatial joins — for detailed per-feature attribution tracking when joining CC-BY layers with other sources, see how to track CC-BY-SA attribution in shapefiles.

For production environments serving many layers simultaneously, integrate with automated attribution mapping workflows to centralise attribution string generation, multi-language rendering, and fallback handling for datasets with incomplete metadata.

Pitfalls & Resolution Table

Pitfall Root Cause Resolution Strategy
License field stripped on reproject gdf.to_crs() discards non-geometry metadata Always call the injection function as the last step after every write; never rely on in-memory GeoDataFrame attributes to survive disk round-trips
EU database rights gap CC 3.0 and earlier do not explicitly cover sui generis database rights Upgrade all European-origin datasets to CC 4.0 identifiers; audit any CC 3.0 layers in the inventory
Broken ISO 19115 XML sidecars Invalid UTF-8 encoding or schema violations Pre-validate XML with lxml.etree.XMLSchema before writing; enforce encoding="UTF-8" on all file handles
Shapefile field truncation .dbf 10-char field name + 254-char value limit Store full CC URIs in .xml sidecar only; use .dbf for short codes (CC-BY-4.0) and cross-reference the sidecar in documentation
NC clause in commercial SaaS pipeline Commercial platform ingests CC-BY-NC layer without licence-awareness Implement a licence-aware API gateway that reads the license field and filters or blocks CC-BY-NC datasets for tenants operating commercially
ShareAlike contamination CC-BY-SA layer joined to a non-SA proprietary layer Run boundary-isolation checks in CI; fail the build if gpkg_metadata or GeoJSON derivation_chain shows incompatible license pairs in a merged dataset
ODbL/CC-BY-SA confusion Engineers treat OSM-derived data as CC-BY-SA Maintain a license registry mapping each dataset ID to its exact SPDX identifier; validate registry entries against the allowlist on every pipeline run