Short answer
A geological unit layer is a polygon coverage: map units that tile the study area with no gaps and no overlaps, each carrying a coded lithology, a stratigraphic age with numeric bounds, and full provenance. The schema decisions that matter are using controlled vocabularies (ideally aligned to GeoSciML / CGI terms and the ICS time scale) backed by lookup tables, and enforcing coverage topology so the map stays self-consistent. A unit dataset that allows free-text lithology and overlapping polygons will drift into inconsistency the moment two people edit it.
This article specifies the polygon coverage model, the attribute schema with controlled vocabularies, topology enforcement in QGIS/ArcGIS/PostGIS, and the conversion pitfalls that corrupt geological maps on handoff.
Why unit polygons are different from ordinary layers
Geological units differ from generic GIS polygons in two ways that drive the schema.
- They form a coverage. Across a mapped area, every point belongs to exactly one unit (allowing for water, cover, and "unmapped" as explicit units). That means gaps and overlaps are errors, not just untidy geometry — a gap implies an unmapped hole; an overlap implies two units claiming the same ground.
- Their attributes are categorical and standardised. Lithology and age come from established vocabularies, not free text. "Granite", "granitic", and "grnt" typed by three mappers will fragment any query, legend, or join. Controlled values are the only way the map stays queryable.
Polygon coverage model
Model units as a single layer of Polygon / MultiPolygon features, one feature per contiguous unit occurrence (or one per unit with multipart geometry, depending on how you want to attribute outcrops). The defining constraint is coverage: the union of all polygons equals the mapped extent, with no internal gaps or overlaps.
Include explicit units for everything, so the coverage is genuinely complete:
- water bodies,
- Quaternary/superficial cover where bedrock is concealed,
- "unmapped" or "unknown" rather than leaving holes.
This avoids the silent gap that later reads as missing data.
Attribute schema for geological units
| Field | Type | Notes |
|---|---|---|
unit_id | uuid/text | Stable primary key |
map_code | text | Legend/map-unit symbol (e.g. J2, Kgr) |
unit_name | text | Formal or informal unit name |
lith_code | text (FK) | Lithology code → lookup table (CGI term) |
age_code | text (FK) | Chronostratigraphic code → ICS lookup |
age_min_ma | real | Younger bound, mega-annum |
age_max_ma | real | Older bound, mega-annum |
description | text | Field/petrographic description |
confidence | text (domain) | mapped, inferred, interpreted |
source | text | Survey, map sheet, publication |
src_scale | integer | Source scale denominator |
mapped_date | date | Compilation date |
Keep lith_code and age_code as foreign keys into lookup tables rather than storing the full term string in every polygon. That is what guarantees consistency: change a term once in the lookup and every polygon follows; and a typo cannot enter because the value must already exist in the parent table.
Align with GeoSciML and standard vocabularies
You do not have to invent the vocabulary. For interoperability:
- Lithology maps to the CGI Simple Lithology vocabulary used by GeoSciML, the OGC/IUGS standard for exchanging geological map data.
- Age maps to the ICS International Chronostratigraphic Chart stages, which also supplies the numeric
age_min_ma/age_max_mabounds.
Even if your project keeps a lean internal schema, store the mapping from your lith_code to the corresponding CGI term in the lookup table. That single column lets the dataset be exported as GeoSciML or understood by another organisation without re-coding the geology.
Enforcing the schema in PostGIS
Foreign keys and a typmod geometry column make invalid units unstorable.
CREATE TABLE lithology (
lith_code text PRIMARY KEY,
cgi_term text NOT NULL, -- mapped to CGI Simple Lithology
label text NOT NULL
);
CREATE TABLE geol_units (
unit_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
map_code text NOT NULL,
unit_name text,
lith_code text NOT NULL REFERENCES lithology(lith_code),
age_min_ma real CHECK (age_min_ma >= 0),
age_max_ma real CHECK (age_max_ma >= age_min_ma),
confidence text CHECK (confidence IN ('mapped','inferred','interpreted')),
source text,
src_scale integer,
geom geometry(MultiPolygon, 2154) NOT NULL
);
CREATE INDEX geol_units_gix ON geol_units USING GIST (geom);
The REFERENCES lithology(lith_code) clause is the key line: a polygon with an unknown lithology code is rejected on insert. The CHECK on age bounds prevents age_max_ma < age_min_ma. Load geometry with ST_Transform(geom, 2154) when the source CRS differs.
Enforcing coverage topology
Coverage integrity is geometric, not just attribute-level. Check for gaps and overlaps before delivery.
In PostGIS, overlaps are detectable directly:
-- any pair of units that overlap is an error
SELECT a.unit_id, b.unit_id
FROM geol_units a JOIN geol_units b
ON a.unit_id < b.unit_id
WHERE ST_Overlaps(a.geom, b.geom);
Gaps are found by comparing the union of all units against the study boundary (ST_Difference(boundary, ST_Union(geom)) should be empty). In QGIS, build a Topology with the rules "must not have gaps" and "must not overlap" via the Topology Checker plugin; in ArcGIS Pro, create a geodatabase topology with the same two rules and validate it. Snapping during digitising prevents most slivers; a snapping tolerance set to the source scale's positional tolerance is usually right.
Worked example: a three-unit sheet
- Lookup tables loaded:
lithology(granite, schist, alluvium → CGI terms),chronostrat(Carboniferous, Quaternary → ICS bounds). - Digitise polygons with snapping on; assign
lith_codeandage_codefrom drop-downs sourced by the lookups. - Add an explicit
alluvium/coverunit so the map has no holes. - Run the overlap query and the gap check; fix slivers by snapping vertices.
- Export to GeoPackage (EPSG:2154) for the client and to GeoSciML or GeoJSON (EPSG:4326) for exchange/web.
The result is a complete coverage where every polygon's lithology and age resolve to a standard term and the geometry tiles the area cleanly.
Common pitfalls and why they happen
- Free-text lithology. Without a lookup, synonyms and typos fragment the legend and break joins. The cause is convenience during digitising; the fix is coded fields with foreign keys.
- Gaps read as missing geology. A sliver gap between two units looks like unmapped ground to a downstream user. It comes from digitising without snapping; topology rules catch it.
- Overlaps double-count area. Two units claiming the same ground inflate area statistics and confuse symbology. Detect with
ST_Overlapsbefore delivery. - Shapefile field truncation.
descriptionbecomesdescriptioandage_max_macollides withage_min_maat 10 characters in a DBF. Deliver GeoPackage; shapefile mangles the schema. - Dropping the
.prj. Sending an incomplete shapefile loses the CRS, so the next user guesses the projection and the map lands in the wrong place. GeoPackage carries CRS internally.
Validation
- Run the overlap query and the gap (boundary-minus-union) check; both must come back clean.
- Confirm every
lith_codeandage_coderesolves to a lookup row (no orphan codes). - Reopen the exported GeoPackage in a clean QGIS session and verify CRS, field names, encoding, and feature count.
- Ship a metadata record stating CRS, source scale, vocabulary version (CGI/ICS), and date.
Bathyl perspective
We treat a geological unit dataset as a coverage with a controlled vocabulary, not a bag of coloured polygons. Foreign-key-backed lithology and age plus enforced topology mean the map a client receives can be queried, joined, and exchanged as GeoSciML without anyone re-typing the geology. The dataset is finished when another team can trust and extend it, not merely when it renders.
Related reading
- Data Schema for Faults and Structures
- Preparing GIS Data for Web Visualization
- Why Your GIS Layers Do Not Line Up
- Spatial data products