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.
Runnable solution Jump to heading
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
- 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.
- 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.
- 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.
- Upsert rather than insert. Re-running the pipeline should update the credit, not fail on a constraint or append a second one.
- 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.
- 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.
- Raise, do not warn. The whole point is that nothing can ship without the credit; a warning on a successful build is not read.
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.
Related Jump to heading
- OSM Licensing & ODbL Compliance — the parent topic and what each output owes.
- Recording OSM Data Provenance in a Pipeline — producing the record this credit is rendered from.
- Generating MBTiles from OSM GeoJSON — a tile archive whose metadata this tags.
- Writing OSM Features to GeoParquet with PyArrow — the columnar sink whose schema metadata carries the credit.
- Setting Quality Thresholds That Fail a Build — the same gating discipline for data quality.
Up one level: OSM Licensing & ODbL Compliance.