Automating ODbL Attribution in Derived Products Jump to heading

Attribution fails the same way every time: it is a step in a checklist, the checklist is followed for the first three releases, and the fourth ships without it. Making it a property of the build removes the failure mode entirely.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

Attribution has two halves. The text is a short credit naming OpenStreetMap and its contributors, conventionally with a link where the medium allows. The placement is wherever a user of that artefact will encounter it, which differs per format and is the part that actually requires engineering.

The principle that makes it reliable is that the credit travels with the data, not with the documentation. A README is detached from the file the moment somebody copies the file elsewhere; metadata embedded in the artefact is not. Every format in common use has somewhere to put it — tile archives have a metadata table, columnar files have schema metadata, databases have tables, and documentation has headers.

The second principle is that the string is generated rather than typed. Deriving it from the provenance record means it names the actual source and date, and it cannot drift out of step with the data when a source changes.

Where attribution goes in each output format A grid of five output formats against where the credit belongs in each and whether it survives a copy. A tile archive carries it in the metadata table and survives. A columnar data file carries it in the schema metadata and survives. A database carries it in a provenance table and survives within that database. A rendered image carries it drawn into the image or in the embedded metadata, and only the drawn form survives. Documentation carries it in a header, which does not survive the file being separated from the document. Five formats, five places, two that do not survive Where it goes Survives a copy Tile archive metadata table yes Columnar file schema metadata yes Database provenance table within it Rendered image drawn, or file metadata only if drawn Documentation a header no The bottom two rows are why documentation alone is not adequate attribution for anything somebody might copy.
Every format above has a place for the credit; the engineering is remembering to use all of them.

Runnable solution Jump to heading

python
from __future__ import annotations

import json
import logging
import sqlite3
from dataclasses import dataclass
from pathlib import Path

import pyarrow as pa
import pyarrow.parquet as pq

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.licence.attribution")

CREDIT_URL = "https://www.openstreetmap.org/copyright"


class MissingAttribution(RuntimeError):
    """An artefact is about to ship without the credit it owes."""


@dataclass(frozen=True)
class Provenance:
    source_name: str        # e.g. "Geofabrik poland-latest"
    source_date: str        # ISO date the extract describes
    digest: str             # the published checksum actually consumed

    def credit(self) -> str:
        return (f"© OpenStreetMap contributors, {CREDIT_URL} — "
                f"derived from {self.source_name} ({self.source_date}), "
                f"ODbL 1.0")

    def as_metadata(self) -> dict[str, str]:
        return {
            "attribution": self.credit(),
            "licence": "ODbL-1.0",
            "source_name": self.source_name,
            "source_date": self.source_date,
            "source_digest": self.digest,
        }


def tag_mbtiles(path: Path, prov: Provenance) -> None:
    """Tile archives advertise attribution to clients through metadata."""
    conn = sqlite3.connect(path)
    try:
        for key, value in prov.as_metadata().items():
            conn.execute(
                "INSERT INTO metadata (name, value) VALUES (?, ?) "
                "ON CONFLICT(name) DO UPDATE SET value = excluded.value",
                (key, value))
        conn.commit()
    finally:
        conn.close()
    logger.info("tagged %s", path.name)


def tag_parquet(src: Path, dst: Path, prov: Provenance) -> None:
    """Rewrite with schema metadata: the credit travels inside the file."""
    table = pq.read_table(src)
    existing = table.schema.metadata or {}
    merged = dict(existing)
    merged.update({k.encode(): v.encode()
                   for k, v in prov.as_metadata().items()})
    pq.write_table(table.replace_schema_metadata(merged), dst)
    logger.info("wrote %s with schema metadata", dst.name)


def tag_database(conn: sqlite3.Connection, prov: Provenance) -> None:
    conn.execute("""
        CREATE TABLE IF NOT EXISTS data_provenance (
            key TEXT PRIMARY KEY, value TEXT NOT NULL,
            recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
        )""")
    for key, value in prov.as_metadata().items():
        conn.execute("INSERT INTO data_provenance (key, value) VALUES (?, ?) "
                     "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
                     (key, value))
    conn.commit()


# --- the gate ---------------------------------------------------------------

def check_mbtiles(path: Path) -> bool:
    conn = sqlite3.connect(path)
    try:
        row = conn.execute(
            "SELECT value FROM metadata WHERE name = 'attribution'").fetchone()
    finally:
        conn.close()
    return bool(row and "OpenStreetMap" in row[0])


def check_parquet(path: Path) -> bool:
    meta = pq.read_schema(path).metadata or {}
    value = meta.get(b"attribution", b"").decode()
    return "OpenStreetMap" in value


CHECKS = {".mbtiles": check_mbtiles, ".pmtiles": None, ".parquet": check_parquet}


def gate(artefacts: list[Path]) -> None:
    """Fail the build on anything shipping without a credit."""
    missing: list[Path] = []
    for artefact in artefacts:
        check = CHECKS.get(artefact.suffix)
        if check is None:
            # An unknown format is NOT a pass: it is a gap in this gate.
            logger.error("no attribution check for %s — add one", artefact.name)
            missing.append(artefact)
            continue
        if not check(artefact):
            missing.append(artefact)
    if missing:
        raise MissingAttribution(
            "artefacts without attribution: "
            + ", ".join(p.name for p in missing))
    logger.info("attribution present on all %d artefact(s)", len(artefacts))


if __name__ == "__main__":
    prov = Provenance("Geofabrik poland-latest", "2026-09-16", "a1b2c3d4")
    logger.info(prov.credit())

Step-by-step walkthrough Jump to heading

  1. Derive the credit from provenance. The string names the actual source and its date, so it cannot describe an extract the pipeline stopped using two releases ago.
  2. Write structured metadata, not just prose. Alongside the human-readable credit, the licence identifier, source name, date and digest go in as separate keys, so a consumer can process them.
  3. Use each format’s own mechanism. A tile archive’s metadata table, a columnar file’s schema metadata and a database table are each the place that format’s consumers look.
  4. Upsert rather than insert. Re-running the pipeline should update the credit, not fail on a constraint or append a second one.
  5. Rewrite the columnar file. Schema metadata cannot be modified in place, so the tagging step produces a new file — which is also a natural place to make the tagging unavoidable.
  6. Treat an unknown format as a failure. A gate that silently passes formats it does not recognise stops being a gate the first time somebody adds an output type. Failing loudly forces the check to be extended.
  7. Raise, do not warn. The whole point is that nothing can ship without the credit; a warning on a successful build is not read.
Where the credit is generated and where it lands Four steps. The provenance record captured at ingestion holds the source name, its date and the checksum actually consumed. The credit step renders that record into a human-readable string and a set of structured metadata keys. The embed step writes both into each artefact using that format's own metadata mechanism rather than into a separate document. The gate step reads every artefact back and fails the build when the credit is absent or when the format has no check defined. Generated once, embedded everywhere, verified at the end provenance captured at ingestion source, date, digest credit rendered from it text plus structured keys embed per-format metadata inside the artefact gate read it back unknown format fails Reading the credit back rather than trusting the write is what catches a format whose metadata mechanism silently discarded it.
The last step is the only one that turns this from a convention into a guarantee.
Why attribution belongs in the build rather than in a checklist Four approaches ranked by how reliably they survive. A verbal convention survives until the person who holds it is on leave. A written checklist survives until somebody ships without reading it, which is usually the third or fourth release. A build step that adds the credit survives unless somebody adds an output the step does not know about. A gate that reads every artefact back and fails on anything missing survives indefinitely, because a new output type without a check is itself a failure. Four approaches, one of which actually holds A convention Somebody knows to do it fails when they are away A checklist Written down, followed fails around release four A build step Adds the credit automatically fails on a new output A gate Reads it back, fails on absence holds indefinitely Only the last approach turns a new, unhandled output type into a build failure rather than into a silently uncredited artefact.
The difference between the third and fourth rows is one function, and it is the difference between usually and always.

Verification Jump to heading

  • Every artefact reports a credit. Run the gate over a full release and confirm it passes with a count matching the artefact list.
  • A stripped artefact fails. Remove the metadata from one file and confirm the gate rejects it.
  • An unknown format fails. Add an artefact with an unhandled extension and confirm the gate refuses rather than passing it.
  • The credit names the right source. Compare the embedded source name and date against what the pipeline actually consumed.
  • Re-running updates rather than duplicates. Run the tagging twice and confirm one credit, not two.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Credit missing from a copied file Attribution only in documentation Embed it in the artefact’s own metadata
Credit names a stale source String hard-coded rather than generated Render it from the provenance record
Gate passes a new output type Unknown formats treated as fine Fail on any format without a defined check
Second credit appended per run Insert without conflict handling Upsert on the metadata key
Parquet tagging silently ignored Schema metadata written to the wrong object Replace the schema metadata and rewrite the file
Build green, artefact uncredited Gate warns instead of failing Raise on any missing credit
Only one artefact checked Artefact list assembled by hand Enumerate the release’s outputs programmatically

Specification reference Jump to heading

The Open Database Licence requires that any public use of the database, or of a produced work created from it, is accompanied by a notice attributing the database to OpenStreetMap contributors and identifying the licence. The OSM Foundation’s attribution guidance describes acceptable placements for interactive maps, printed products and data distributions. See the OpenStreetMap copyright page for the credit text and the attribution guidelines for placement.

Frequently Asked Questions Jump to heading

Is a credit in the README enough?

Not for anything somebody might copy. A README is detached from the data the moment a file is moved into another system, and the copy then carries no indication of where it came from. Every common format has somewhere to put metadata inside the artefact — a tile archive’s metadata table, a columnar file’s schema metadata, a database table — and using it means the credit survives the journeys files actually take.

Why generate the credit rather than write it once?

Because a hard-coded string describes whatever source was current when somebody typed it, and pipelines change sources. Rendering the credit from the provenance record means it names the extract actually consumed, with its date, every time. It also means one fewer thing to remember when a source changes, which is precisely the kind of forgetting that produces a misleading credit.

Should an unrecognised output format pass the gate?

No. A gate that passes what it does not understand stops being a gate as soon as somebody adds a new output type, and that addition is exactly when a check is most likely to be forgotten. Failing on an unknown extension is mildly annoying once, when the format is introduced, and prevents a silent gap afterwards.

Does attribution have to be visible on a rendered map?

Yes, on the map itself rather than somewhere a user would have to go looking. The requirement is that people using the work are made aware of its source, and a credit three pages away in a terms document does not achieve that. For an interactive map a corner credit with a link to the copyright page is the established form, and it is what reviewers expect to see.

Up one level: OSM Licensing & ODbL Compliance.