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.
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.
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.
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.
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'slicenseInfoholds 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.
Internal links
- Open data licences explained for spatial data โ what the identifier you find means
- How to check licence compatibility before combining datasets โ the gate that surfaced the problem
- How to read the metadata already inside your spatial files โ searching inside the file
- Attribution requirements explained: OpenStreetMap, Copernicus and national data โ what to do once you know
- Spatial metadata explained: what a dataset must tell you โ the record that prevents this
- GIS data sources explained โ finding an open replacement
- How to cache downloaded GIS data in Python โ where the fetch wrapper belongs
- How to explore an unfamiliar spatial dataset in Python โ fingerprinting in more depth
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.