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

FieldTypeNotes
unit_iduuid/textStable primary key
map_codetextLegend/map-unit symbol (e.g. J2, Kgr)
unit_nametextFormal or informal unit name
lith_codetext (FK)Lithology code → lookup table (CGI term)
age_codetext (FK)Chronostratigraphic code → ICS lookup
age_min_marealYounger bound, mega-annum
age_max_marealOlder bound, mega-annum
descriptiontextField/petrographic description
confidencetext (domain)mapped, inferred, interpreted
sourcetextSurvey, map sheet, publication
src_scaleintegerSource scale denominator
mapped_datedateCompilation 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_ma bounds.

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

  1. Lookup tables loaded: lithology (granite, schist, alluvium → CGI terms), chronostrat (Carboniferous, Quaternary → ICS bounds).
  2. Digitise polygons with snapping on; assign lith_code and age_code from drop-downs sourced by the lookups.
  3. Add an explicit alluvium/cover unit so the map has no holes.
  4. Run the overlap query and the gap check; fix slivers by snapping vertices.
  5. 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_Overlaps before delivery.
  • Shapefile field truncation. description becomes descriptio and age_max_ma collides with age_min_ma at 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_code and age_code resolves 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

Sources