A downloaded layer has no licence you can find

On this page

Problem statement

The layer is in the project, the map is nearly finished, and nobody can say what licence it is under. The download page has gone, the zip has no licence file, the GeoPackage has no metadata table, and the person who fetched it left in March.

This is not a rare edge case; it is the normal end state of any dataset acquired without a record. The practical consequence is specific: an output built on an unlicensed layer cannot be published, and the longer the layer stays in the project the more expensive removing it becomes.

This guide covers what to check, in the order that costs least, and what to do when the answer is genuinely unavailable.

Quick answer

Look in the places a licence hides before asking anyone:

import pathlib, zipfile
from osgeo import gdal
gdal.UseExceptions()

LICENCE_NAMES = ("LICENSE", "LICENCE", "COPYING", "README", "TERMS", "COPYRIGHT")

def find_licence(path):
    path, found = pathlib.Path(path), {}

    ds = gdal.OpenEx(str(path))
    found["dataset_metadata"] = ds.GetMetadata()                       # 1. inside the file
    found["layer_metadata"] = ds.GetLayer(0).GetMetadata() if ds.GetLayerCount() else {}

    for sidecar in path.parent.glob(path.name.split(".")[0] + ".*"):  # 2. sidecars
        if sidecar.suffix.lower() in {".xml", ".txt", ".json", ".yaml", ".md", ".html"}:
            found.setdefault("sidecars", []).append(sidecar.name)

    for hit in path.parent.rglob("*"):                                  # 3. the folder
        if any(name in hit.name.upper() for name in LICENCE_NAMES):
            found.setdefault("licence_files", []).append(hit.name)

    for z in path.parent.glob("*.zip"):                                # 4. an original zip
        with zipfile.ZipFile(z) as zf:
            members = [m for m in zf.namelist() if any(n in m.upper() for n in LICENCE_NAMES)]
            if members:
                found.setdefault("zip_licence_files", {})[z.name] = members

    return {key: value for key, value in found.items() if value}

Then check the layer's attributes for a source or copyright column, and the project file for the URL it was loaded from.

The six places a licence for a downloaded layer can be found. six places, cheapest first1inside the fileGPKG table, TIFF tags2sidecars and the original zipREADME, .shp.xml3the attributessource or copyright column4fingerprint the schemaosm_id, LSOA21CD5the project or notebookthe URL it came from6ask, with a deadlinethen replace itThe fourth step identifies the publisher, which is most of the answer spatialworkflow.io
Six places, cheapest first; the sixth is asking a person.

Inside the file, sidecars, attributes, identify the source

1. Look inside the file

GeoPackages have a metadata table, GeoTIFFs have tags, FileGDBs have per-item XML, and NetCDF has global attributes. A LICENSE, COPYRIGHT, ATTRIBUTION or SOURCE item may be sitting there.

2. Look for sidecars and the original archive

A .shp.xml, a README.txt in the zip, a terms.pdf that got left in downloads/. If the zip is still around, its contents list often names the publisher even when no licence file survived.

3. Look at the attributes

Many national datasets carry a source, licence or copyright column, or an identifier whose format is distinctive โ€” a UPRN, a GEOID, an INSPIRE ID, an ONS code. That identifies the publisher even when nothing else does.

4. Identify the publisher from the data itself

The CRS, the extent, the schema and the geometry precision narrow it fast. A layer in EPSG:27700 with LSOA21CD columns is ONS; one in EPSG:3035 with NUTS codes is Eurostat; one with osm_id is OpenStreetMap-derived and therefore ODbL.

import geopandas as gpd

LAYERS = [p for p in sorted(pathlib.Path("project/data").iterdir()) if p.suffix in {".shp", ".gpkg", ".geojson"}]
for path in LAYERS:
    gdf = gpd.read_file(path)
    print(f"{path.name:15} {str(gdf.crs):11} {gdf.total_bounds.round(1)}  {sorted(gdf.columns)[:5]}")
counties.shp    EPSG:4269   [-109.1   37.  -102.    41. ]  ['ALAND', 'AWATER', 'COUNTYFP', 'COUNTYNS', 'GEOID']
countries.shp   EPSG:4326   [-180.   -90.   180.    83.6]  ['ABBREV', 'ABBREV_LEN', 'ADM0_A3', 'ADM0_A3_AR', 'ADM0_A3_BD']
lsoa.gpkg       EPSG:27700  [479688.5 292664.8 506294.8 318923.4]  ['LSOA21CD', 'LSOA21NM', 'geometry']
nuts2.geojson   EPSG:3035   [-2824090.8 -3079372.8 10029369.2  6406780. ]  ['CAPT', 'CC_STAT', 'CNTR_CODE', 'COAST_TYPE', 'EFTA_STAT']
roads.shp       EPSG:4326   [-0.8 52.5 -0.4 52.8]  ['bridge', 'code', 'fclass', 'geometry', 'layer']

5. Check the project and the history

A QGIS project stores the path a layer was loaded from; a notebook records the URL; a shell history or a download folder holds the file's origin. Version control on the pipeline is the most reliable of these.

6. Ask, with a deadline

If the publisher is identified, the licence is usually one page away. If the publisher is not identified, ask internally once, with a date after which the layer is removed.

7. If you cannot resolve it, replace it

Treat an unlicensed layer as unusable. In most cases an open equivalent exists โ€” administrative boundaries, addresses, roads, land cover all have open sources โ€” and swapping it is cheaper than defending a publication built on it.

8. Make the next download self-documenting

A fetch function that records the URL, the date, the licence and the checksum costs ten lines and ends this problem permanently.

Column names and CRSs that identify a datasetโ€™s publisher. the data names its publishersignaturepublisherlicenceosm_idOpenStreetMap derivativeODbL-1.0LSOA21CDONS / UK censusOGL-UK-3.0UPRNOS AddressBaseproprietaryGEOIDUS Census TIGERpublic domainNUTS_IDEurostat GISCOsee GISCO termsFEATURECLANatural EarthCC0-1.0nobody else calls a column FEATURECLASchema, CRS and extent together are usually conclusive spatialworkflow.io
The data usually identifies its own publisher, which is most of the way to its licence.

Fingerprinting a layer and asking the portal

The data used on this page

A project folder of the kind this page is about, built from five real downloads with their provenance stripped the way it usually is: files renamed, READMEs left behind in downloads/. Natural Earth's 1:110m countries, Geofabrik's OpenStreetMap roads for Rutland (with its original zip still beside it), ONS 2021 LSOA boundaries for Rutland, the US Census Bureau's Colorado counties with their ISO metadata sidecar, and Eurostat's NUTS 2 regions from GISCO.

import io, pathlib, shutil, zipfile
import geopandas as gpd
import requests

data = pathlib.Path("project/data")
data.mkdir(parents=True, exist_ok=True)

def get(url, **params):
    reply = requests.get(url, params=params or None, timeout=300)
    reply.raise_for_status()
    return reply.content

if not (data / "countries.shp").exists():
    with zipfile.ZipFile(io.BytesIO(get("https://naciscdn.org/naturalearth/110m/cultural/"
                                        "ne_110m_admin_0_countries.zip"))) as zf:
        for member in zf.namelist():
            if member.split(".")[-1] in {"shp", "shx", "dbf", "prj", "cpg"}:
                (data / ("countries." + member.split(".")[-1])).write_bytes(zf.read(member))

if not (data / "roads.shp").exists():
    blob = get("https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip")
    (data / "rutland-latest-free.shp.zip").write_bytes(blob)
    with zipfile.ZipFile(io.BytesIO(blob)) as zf:
        for ext in ["shp", "shx", "dbf", "prj", "cpg"]:
            (data / f"roads.{ext}").write_bytes(zf.read(f"gis_osm_roads_free_1.{ext}"))

if not (data / "lsoa.gpkg").exists():
    gpd.read_file(io.BytesIO(get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Lower_layer_Super_Output_Areas_December_2021_Boundaries_EW_BGC_V5/FeatureServer/0/query",
        where="LSOA21NM LIKE 'Rutland%'", outFields="LSOA21CD,LSOA21NM", outSR=27700, f="geojson"))
    ).to_file(data / "lsoa.gpkg")

if not (data / "counties.shp").exists():
    with zipfile.ZipFile(io.BytesIO(get("https://www2.census.gov/geo/tiger/GENZ2024/shp/"
                                        "cb_2024_us_county_500k.zip"))) as zf:
        zf.extractall("downloads/census")
    us = gpd.read_file("downloads/census/cb_2024_us_county_500k.shp")
    us[us["STUSPS"] == "CO"].to_file(data / "counties.shp")
    shutil.copy("downloads/census/cb_2024_us_county_500k.shp.iso.xml", data / "counties.shp.iso.xml")

if not (data / "nuts2.geojson").exists():
    (data / "nuts2.geojson").write_bytes(get("https://gisco-services.ec.europa.eu/distribution/v2/nuts/"
                                             "geojson/NUTS_RG_60M_2024_3035_LEVL_2.geojson"))

print(sorted(p.name for p in data.iterdir()))
['counties.cpg', 'counties.dbf', 'counties.prj', 'counties.shp', 'counties.shp.iso.xml', 'counties.shx', 'countries.cpg', 'countries.dbf', 'countries.prj', 'countries.shp', 'countries.shx', 'lsoa.gpkg', 'nuts2.geojson', 'nuts2.geojson.source.json', 'roads.cpg', 'roads.dbf', 'roads.prj', 'roads.shp', 'roads.shx', 'rutland-latest-free.shp.zip']

Run the search on every layer

for path in LAYERS:
    print(path.name, find_licence(path) or "nothing found")
counties.shp {'layer_metadata': {'DBF_DATE_LAST_UPDATE': '2026-09-30'}, 'sidecars': ['counties.shp.iso.xml'], 'zip_licence_files': {'rutland-latest-free.shp.zip': ['README']}}
countries.shp {'layer_metadata': {'DBF_DATE_LAST_UPDATE': '2022-05-08'}, 'zip_licence_files': {'rutland-latest-free.shp.zip': ['README']}}
lsoa.gpkg {'zip_licence_files': {'rutland-latest-free.shp.zip': ['README']}}
nuts2.geojson {'sidecars': ['nuts2.geojson.source.json'], 'zip_licence_files': {'rutland-latest-free.shp.zip': ['README']}}
roads.shp {'zip_licence_files': {'rutland-latest-free.shp.zip': ['README']}}

Read the results closely, because four of the five hits are the same one. The README inside the Geofabrik zip is the only licence-like file in the folder, so it is reported for every layer โ€” and it is about the roads only: it says they are OpenStreetMap data under the ODbL, but nothing ties roads.shp to that zip except that they sit side by side. The one file-specific find is the Census sidecar. The .dbf header's date is a small clue too: 2022-05-08 for the countries is the date Natural Earth built the file, while the counties show the day they were rewritten here. The sidecar's constraints read:

import re
xml = (data / "counties.shp.iso.xml").read_text(encoding="utf-8", errors="replace")
for text in re.findall(r"<gmd:otherConstraints>\s*<gco:CharacterString>(.*?)</gco:CharacterString>", xml, re.S):
    print("-", " ".join(text.split())[:110])
- Access constraints: None
- Use Constraints: The intended display scale for this file is 1:500,000. This file should not be displayed at s

No licence at all: US federal works are in the public domain by statute (17 U.S.C. ยง 105), so the Census Bureau does not attach one. That is the fingerprint's job.

Example 1 โ€” fingerprint the layer

import geopandas as gpd, re

FINGERPRINTS = [
    (re.compile(r"^(osm_id|osm_way_id)$", re.I), "OpenStreetMap derivative โ€” ODbL-1.0"),
    (re.compile(r"^(LSOA\d\dCD|MSOA\d\dCD|OA\d\dCD)$"), "ONS boundaries โ€” OGL-UK-3.0"),
    (re.compile(r"^UPRN$", re.I), "carries OS UPRNs โ€” open identifier, check the rest"),
    (re.compile(r"^GEOID\d*$"), "US Census TIGER โ€” public domain"),
    (re.compile(r"^NUTS_ID$"), "Eurostat / GISCO โ€” see GISCO terms"),
    (re.compile(r"^(SCALERANK|FEATURECLA)$", re.I), "Natural Earth โ€” public domain"),
]

def fingerprint(path):
    gdf = gpd.read_file(path, rows=1)
    hits = [label for rx, label in FINGERPRINTS for c in gdf.columns if rx.match(c)]
    return {"crs": str(gdf.crs), "suggests": sorted(set(hits))}

for path in LAYERS:
    print(f"{path.name:15}", fingerprint(path))
counties.shp    {'crs': 'EPSG:4269', 'suggests': ['US Census TIGER โ€” public domain']}
countries.shp   {'crs': 'EPSG:4326', 'suggests': ['Natural Earth โ€” public domain']}
lsoa.gpkg       {'crs': 'EPSG:27700', 'suggests': ['ONS boundaries โ€” OGL-UK-3.0']}
nuts2.geojson   {'crs': 'EPSG:3035', 'suggests': ['Eurostat / GISCO โ€” see GISCO terms']}
roads.shp       {'crs': 'EPSG:4326', 'suggests': ['OpenStreetMap derivative โ€” ODbL-1.0']}

Every one of the five is identified, including the three the file search found nothing for. A schema is a strong signal: nobody else calls a column featurecla โ€” and Natural Earth writes it in lower case, which is why the pattern needs re.I; without it, the countries layer matched nothing. A UPRN column is a weaker signal than it looks: the identifier itself has been open (OS Open UPRN, OGL) since 2020, while the address data that often travels with it is not.

Five renamed layers: what the file search, the sidecar and the schema fingerprint found for each. what identified each layerfile searchsidecarfingerprintcountries.shpโœ•roads READMEโœ•noneโœ“Natural Earthroads.shp!zip READMEโœ•noneโœ“OSM, ODbLlsoa.gpkgโœ•roads READMEโœ•noneโœ“ONS, OGLcounties.shpโœ•roads README!no licenceโœ“Censusnuts2.geojsonโœ•roads READMEโœ•noneโœ“GISCOThe schema named every publisher; the files named one, loosely spatialworkflow.io
Measured on this page. The zip README was reported for every layer because it was the only licence file in the folder.

Ask the portal

Once the publisher is known, its catalogue often answers the rest. The ONS layer came from an ArcGIS feature service, whose JSON names the catalogue item that holds the terms:

import re

service = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
           "Lower_layer_Super_Output_Areas_December_2021_Boundaries_EW_BGC_V5/FeatureServer")
item_id = requests.get(service, params={"f": "json"}, timeout=60).json()["serviceItemId"]
item = requests.get(f"https://www.arcgis.com/sharing/rest/content/items/{item_id}",
                    params={"f": "json"}, timeout=60).json()
print(item["title"])
print("licenseInfo:", re.sub(r"<[^>]+>|\s+", " ", item["licenseInfo"]).strip())
print("accessInformation:", item["accessInformation"])

ckan = requests.get("https://catalog.data.gov/api/3/action/package_search", params={"q": "county"}, timeout=60)
print("catalog.data.gov CKAN API:", ckan.status_code)
Lower layer Super Output Areas (December 2021) Boundaries EW BGC (V5)
licenseInfo: https://www.ons.gov.uk/methodology/geography/licences
accessInformation: Office for National Statistics
catalog.data.gov CKAN API: 404

Not every catalogue keeps its API: the CKAN endpoint that data.gov tutorials still point at no longer answers.

Example 2 โ€” the download wrapper that prevents the problem

import requests, hashlib, json, pathlib, datetime

def fetch(url, dest, licence, attribution, name=None, timeout=300):
    dest = pathlib.Path(dest)
    dest.parent.mkdir(parents=True, exist_ok=True)
    r = requests.get(url, timeout=timeout)
    r.raise_for_status()
    dest.write_bytes(r.content)

    record = {
        "name": name or dest.stem,
        "url": url,
        "fetched": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
        "sha256": hashlib.sha256(r.content).hexdigest(),
        "bytes": len(r.content),
        "licence": licence,
        "attribution": attribution,
        "etag": r.headers.get("ETag"),
        "last_modified": r.headers.get("Last-Modified"),
    }
    pathlib.Path(str(dest) + ".source.json").write_text(json.dumps(record, indent=2))
    return dest

fetch("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_60M_2024_3035_LEVL_2.geojson",
      "project/data/nuts2.geojson", licence="Eurostat GISCO terms (administrative units)",
      attribution="ยฉ EuroGeographics for the administrative boundaries")
record = json.loads(pathlib.Path("project/data/nuts2.geojson.source.json").read_text())
print({k: record[k] for k in ("url", "bytes", "licence", "last_modified")})
{'url': 'https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_60M_2024_3035_LEVL_2.geojson', 'bytes': 399477, 'licence': 'Eurostat GISCO terms (administrative units)', 'last_modified': 'Fri, 25 Sep 2026 12:43:10 GMT'}

Making licence a required positional argument is the whole design. A download that cannot state its licence does not happen.

Example 3 โ€” audit a project for unlicensed layers

import pathlib, json

def unlicensed(root="project/data"):
    out = []
    for p in sorted(pathlib.Path(root).rglob("*")):
        if p.suffix.lower() not in {".gpkg", ".shp", ".geojson", ".tif", ".parquet", ".fgb"}:
            continue
        rec = pathlib.Path(str(p) + ".source.json")
        meta = pathlib.Path(str(p).rsplit(".", 1)[0] + ".meta.json")
        licence = None
        for candidate in (rec, meta):
            if candidate.exists():
                licence = json.loads(candidate.read_text()).get("licence")
                if licence:
                    break
        if not licence:
            out.append(str(p))
    return out

missing = unlicensed()
print(f"{len(missing)} layer(s) with no recorded licence:\n  " + "\n  ".join(missing))
4 layer(s) with no recorded licence:
  project/data/counties.shp
  project/data/countries.shp
  project/data/lsoa.gpkg
  project/data/roads.shp

In CI, end with raise SystemExit(...) instead of print so the build fails. The list only ever gets longer if nothing stops it.

A portal is not a licence

Why "it was on an open data portal" is not a licence

Portals host datasets under several licences, including restrictive ones, and many host third-party data whose terms differ from the portal's. The licence is a property of the dataset, and "it was public" is a statement about access, not about rights.

Why an unlicensed layer blocks publication rather than merely worrying you

Any output derived from it inherits an unknown obligation. If the layer turns out to be share-alike, your output may need relicensing; if it is non-commercial, the output may not be distributable at all; if it is proprietary, you may have no right to publish anything. Since you cannot know which, the only defensible position is to treat the output as unpublishable until the licence is resolved.

Why fingerprinting works so well

Spatial datasets carry the publisher's conventions in their schema, CRS and extent. Column names in particular are effectively signatures โ€” FEATURECLA is Natural Earth, LSOA21CD is ONS, osm_id is an OSM extract โ€” and identifying the publisher is nearly always enough to find the licence.

Why the fix is in the fetch function

Every instance of this problem starts with a download that recorded nothing. A wrapper that requires the licence and writes a .source.json beside the file makes the record automatic, and makes the absence of a record a visible anomaly rather than the norm.

A download wrapper that makes an unlicensed layer impossible. fix it at the fetchfetch(url,licence=โ€ฆ)required argumentwrite the fileplus its bytes hashwrite .source.jsonurl, date, etagCI audits formissinglist only shrinksA download that cannot state its licence does not happen spatialworkflow.io
Making the licence a required argument is the whole design.

Inherited licences, partial licence files and archives

  • Derived layers inherit. A clip of an unlicensed layer is unlicensed.
  • A licence file in a zip may cover only part of it. Multi-source archives are common.
  • Wayback Machine has the download page. Often the fastest route to the terms.
  • Portals often expose the licence through an API. An ArcGIS-hosted layer names its item in the service JSON (serviceItemId), and the item's licenseInfo holds the terms, as the ONS example above shows.
  • Attribution in the data is not a licence. A copyright column tells you who, not what you may do.
  • Internal data needs a licence too. "Ours" is not a distribution term.
  • Record the licence version and the date. Terms change.
  • Do not guess from similar datasets. The same publisher uses different licences for different products.

FAQ

Where should I look for a dataset's licence?

Inside the file's metadata, in sidecar and README files, in the original zip, in the attributes, in the project file that recorded the source URL, and finally by asking whoever downloaded it.

Can I use a layer if I cannot find its licence?

Not for anything you publish. The output inherits an unknown obligation, and the possibilities include share-alike, non-commercial and proprietary.

How do I work out who published it?

Fingerprint the data: the CRS, the extent and especially the column names. osm_id, LSOA21CD, FEATURECLA and NUTS_ID each identify a publisher unambiguously.

Does being on an open data portal make it open?

No. Portals host datasets under many licences, including restrictive ones and third-party data with different terms.

What if nobody can tell me?

Replace the layer. Open equivalents exist for boundaries, addresses, roads and land cover, and swapping is cheaper than defending a publication built on an unknown.

How do I stop this happening again?

Wrap your downloads in a function that requires a licence argument and writes a .source.json next to the file with the URL, date, checksum and licence.