Embedding Attribution in GeoPackage Metadata Tables

Write the attribution into gpkg_metadata with a declared standard URI and MIME type, link it to the layer through gpkg_metadata_reference, and keep a plain-text mirror in a feature column — the metadata tables are what a catalogue reads, and the column is what survives conversion to any other format.

GeoPackage is the only common spatial container with a metadata facility defined by its own specification, which makes it the best place to put an attribution notice and also the place teams most often get wrong. The two tables involved have a foreign-key relationship that most writing code ignores, producing a metadata record that exists in the file and is attached to nothing. This guide is part of automated attribution mapping workflows, under Geospatial Data Licensing & Compliance Fundamentals.

The two tables an attribution record needsgpkg_metadata holds the document; gpkg_metadata_reference attaches it to a scope such as a specific layer.GeoPackage fileattribution lives in two tablesgpkg_metadatathe documentmd_scopedataset or tablemd_standard_uriwhich schema appliesmetadatathe notice itselfgpkg_metadata_referencethe attachmentreference_scopegeopackage or tabletable_namewhich layermd_file_idforeign key to the record
The record without the reference is orphaned: present in the file, attached to nothing, invisible to readers.

Automated Python Implementation

The writer below creates both rows, in the right order, with the constraints the specification requires. It uses sqlite3 directly because the metadata tables are ordinary SQLite tables and no spatial library exposes them usefully.

#!/usr/bin/env python3
"""Write an attribution record into a GeoPackage's metadata tables."""
import datetime
import json
import sqlite3

ISO_URI = "http://www.isotc211.org/2005/gmd"

CREATE_METADATA = """
CREATE TABLE IF NOT EXISTS gpkg_metadata (
    id INTEGER CONSTRAINT m_pk PRIMARY KEY ASC NOT NULL,
    md_scope TEXT NOT NULL DEFAULT 'dataset',
    md_standard_uri TEXT NOT NULL,
    mime_type TEXT NOT NULL DEFAULT 'text/xml',
    metadata TEXT NOT NULL DEFAULT ''
)
"""

CREATE_REFERENCE = """
CREATE TABLE IF NOT EXISTS gpkg_metadata_reference (
    reference_scope TEXT NOT NULL,
    table_name TEXT,
    column_name TEXT,
    row_id_value INTEGER,
    timestamp DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
    md_file_id INTEGER NOT NULL,
    md_parent_id INTEGER,
    CONSTRAINT crmr_mfi_fk FOREIGN KEY (md_file_id) REFERENCES gpkg_metadata(id)
)
"""

def write_attribution(path, layer, notice, licence, holders):
    """Attach an attribution record to one layer of a GeoPackage."""
    document = json.dumps({
        "license": licence,
        "attribution": notice,
        "rights_holders": holders,
        "written": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }, ensure_ascii=False)

    con = sqlite3.connect(path)
    try:
        con.execute("PRAGMA foreign_keys = ON")
        con.execute(CREATE_METADATA)
        con.execute(CREATE_REFERENCE)

        cur = con.execute(
            "INSERT INTO gpkg_metadata (md_scope, md_standard_uri, mime_type, metadata)"
            " VALUES (?, ?, ?, ?)",
            ("dataset", ISO_URI, "application/json", document),
        )
        md_id = cur.lastrowid

        # 'table' scope attaches the record to one layer; 'geopackage' scope
        # would attach it to the file as a whole and takes a NULL table_name.
        con.execute(
            "INSERT INTO gpkg_metadata_reference"
            " (reference_scope, table_name, md_file_id) VALUES (?, ?, ?)",
            ("table", layer, md_id),
        )
        con.commit()
        return md_id
    finally:
        con.close()

def read_attribution(path, layer):
    """Read back every attribution record attached to a layer."""
    con = sqlite3.connect(path)
    try:
        rows = con.execute(
            "SELECT m.metadata FROM gpkg_metadata m"
            " JOIN gpkg_metadata_reference r ON r.md_file_id = m.id"
            " WHERE r.table_name = ? ORDER BY m.id",
            (layer,),
        ).fetchall()
        return [json.loads(r[0]) for r in rows]
    finally:
        con.close()

Three details are worth drawing out, because each one is a way this goes wrong in practice.

The reference row is not optional. A record in gpkg_metadata with no matching row in gpkg_metadata_reference is not attached to anything. GDAL will not surface it, catalogue harvesters will not find it, and the file will nonetheless contain the text — which is why the mistake survives review. Writing both rows in one transaction makes the orphan impossible.

The scope determines what the record describes. reference_scope of table with a table_name attaches to one layer; geopackage with a NULL table_name attaches to the file. A multi-layer GeoPackage whose layers carry different licences needs one record per layer, and using file scope for it asserts something false about the layers that do not share those terms.

The MIME type must match the content. Declaring text/xml and storing JSON produces a record that a conformant reader will try to parse as XML and reject. If the notice is JSON, say so; if an ISO 19139 document is wanted, generate one and declare it.

Writing both rows in one transactionThe writer inserts the metadata document, takes the generated id, inserts the reference row, and commits — so an orphaned record cannot exist.Writergpkg_metadatagpkg_metadata_referenceINSERT document, scope, URI, MIMEgenerated idINSERT scope, table, md_file_idCOMMIT — both rows or neither
One transaction, two rows. Committing between them is how orphans are created.

Validation and Pipeline Integration

Verify from outside the writing code, because a bug in the writer will otherwise verify itself.

# GDAL surfaces attached metadata; an orphaned record produces no output here
ogrinfo -al -so combined.gpkg roads | sed -n '/Metadata/,/^$/p'

# The join is the real test: a record with no reference row will not appear
python -c "
import sqlite3
con = sqlite3.connect('combined.gpkg')
print(con.execute('SELECT count(*) FROM gpkg_metadata').fetchone()[0], 'records')
print(con.execute('SELECT count(*) FROM gpkg_metadata m JOIN gpkg_metadata_reference r'
                  ' ON r.md_file_id = m.id').fetchone()[0], 'attached')
"

In CI, assert the counts match. An assertion that records exist is satisfied by an orphan; an assertion that every record is attached is not.

def test_every_metadata_record_is_attached(tmp_path):
    path = str(tmp_path / "t.gpkg")
    sqlite3.connect(path).close()
    write_attribution(path, "roads", "City (ODbL-1.0)", "ODbL-1.0", ["City"])
    con = sqlite3.connect(path)
    total = con.execute("SELECT count(*) FROM gpkg_metadata").fetchone()[0]
    attached = con.execute(
        "SELECT count(*) FROM gpkg_metadata m"
        " JOIN gpkg_metadata_reference r ON r.md_file_id = m.id"
    ).fetchone()[0]
    con.close()
    assert total == attached == 1

What Survives a Conversion

The metadata tables are a GeoPackage feature and they do not travel. Any conversion — to GeoJSON, to shapefile, to a PostGIS table, or a layer copy into a new GeoPackage with ogr2ogr — leaves them behind, because the target either has no equivalent structure or because the copy operates on features rather than on the container.

Regenerating the record at each exportThe feature column carries the notice through processing, and the metadata record is regenerated at the point of publication.Source layernotice in a columntransformProcessingcolumn survives, table does notexportRegenerate recordread the column, write the tablepublishPublished GeoPackageboth carriers agree
Treating the column as the working carrier and the record as an export artifact keeps the two from disagreeing.

That is the entire argument for the plain-text mirror in a feature column. A license column costs a few bytes per feature, is carried by every format that has attributes, and is visible to the analyst who opens the file in a desktop GIS and will never look at a metadata table. It is redundant with the metadata record by design: the two carriers reach different consumers and neither is sufficient alone, a point developed further in attribution stacking for multi-source basemaps.

Where a pipeline routinely converts between formats, the practical arrangement is to treat the feature column as the source of truth during processing and to regenerate the metadata record at each export. Reading the notice back out of the column and writing it into the container at the point of publication means the record is always consistent with the data, rather than being a fact recorded once at the start of a chain that subsequently modified everything else.

Long-Term Compliance Best Practices

  • Write the record at export, not at ingestion. A record written when the file was created describes a file that later processing changed.
  • Use dataset scope for the notice and series for a collection. Scope values are enumerated by the specification; inventing one produces a file that fails conformance checks for no benefit.
  • Include the write timestamp inside the document. The reference table has one, but it records when the attachment was made rather than when the notice was composed, and the two diverge when records are re-attached.
  • Never update a metadata record in place. Insert a new one and attach it; the history of what the file claimed is worth more than the small saving in file size.
  • Check for orphans in the same CI job that writes them. Orphaned records are silent and cumulative, and a single count comparison catches every one.