Short answer
A geological cross section in GIS is two linked datasets, not one. The line of section is a 2D polyline on the map, in the project's planar CRS. The interpreted section — units, contacts, faults, drillhole intercepts — lives in a separate, non-georeferenced coordinate space where the horizontal axis is distance along the trace and the vertical axis is elevation. A shared section identifier joins the two so an analyst can click a trace on the map and open the matching panel of subsurface geology.
The mistake most teams make is treating the section as a static image (a PDF or a PNG) and pinning it to the map as a decoration. The moment the section is raster, you lose the ability to query a unit at depth, snap a fault to a mapped contact, or re-cut the section when the surface map changes. The durable approach keeps the section as vector geometry with attributes.
The two coordinate spaces you must keep separate
Every cross-section workflow involves a deliberate transform between map space and section space.
Map space is your normal GIS world: easting/northing in a projected CRS such as UTM Zone 31N (EPSG:32631) or a national grid like British National Grid (EPSG:27700). The line of section is a LineString here.
Section space is a flat 2D plane. The horizontal coordinate is distance along the trace (chainage), measured in metres from the section's start point. The vertical coordinate is elevation relative to a datum — usually metres above the same vertical reference as your DEM (for example EGM2008 or a national orthometric datum). Section space is not georeferenced. A point at section coordinate (4200, 320) means "4,200 m along the line, 320 m elevation"; it has no latitude.
The transform from map space to section-distance is a projection of each map point onto the section polyline. In PostGIS this is exactly what ST_LineLocatePoint and ST_LineInterpolatePoint give you:
-- distance along section line (0..1 fraction → metres)
SELECT ST_LineLocatePoint(s.geom, ST_ClosestPoint(s.geom, w.geom))
* ST_Length(s.geom) AS chainage_m
FROM section_lines s, wells w
WHERE s.section_id = 'A-A''';
Keeping the two spaces explicit is what lets you re-derive section coordinates if the trace is ever moved, rather than re-drawing the whole interpretation by hand.
Modelling the data: line of section plus section geology
A workable schema has at minimum three feature classes plus a lookup table:
- SectionLines — polyline in map CRS. Attributes:
section_id(e.g.A-A'),azimuth,start_label,end_label,vertical_exaggeration_used,datum. - SectionContacts — polylines in section space. Attributes:
section_id,type(contact, fault, intrusive contact, unconformity),confidence,source. - SectionUnitPolys — polygons in section space representing the geological units below ground. Attributes:
section_id,map_unit(foreign key to the legend),confidence. - DescriptionOfMapUnits — the shared legend table used by both the surface map and the sections, so a unit symbol means the same thing everywhere.
Because section geology is stored as polygons and lines with real attributes, you can run topology checks (do unit polygons tile the section without gaps or overlaps?), symbolise by map_unit using the same colour ramp as the map, and label faults from their attributes rather than baking text into an image.
How GeMS handles it
The USGS Geologic Map Schema (GeMS) treats a cross section as a near-clone of the surface map. Each section gets its own feature dataset — for section A–A′ you would have CrossSectionA containing CSAMapUnitPolys, CSAContactsAndFaults, and an CSAOrientationPoints class, all in section space. A CrossSection table records the section line geometry and label, and the units reference the same DescriptionOfMapUnits rows the surface map uses. This is the cleanest published convention for the "section behaves like a small map" idea, and adopting its naming makes your data portable to anyone who already reads GeMS.
If you are not bound to GeMS, you can simplify the schema, but keep the principle: sections share the legend with the map and live in their own coordinate space.
Worked example: cutting a section against a DEM
Suppose you have a trace A-A' and want a topographic profile to drape your interpretation under.
- Densify the trace so the profile follows the ground at DEM resolution. In QGIS, Densify by interval with a spacing near your cell size (e.g. 10 m for a 10 m DEM).
- Sample elevation along the densified vertices. The QGIS Processing tool Drape (set Z value from raster) writes the DEM value to each vertex Z; alternatively GDAL:
driven over the vertex list.gdallocationinfo -valonly -geoloc dem.tif <x> <y> - Convert (chainage, elevation) to section space. Chainage is cumulative 2D length along the trace from the start; elevation is the sampled Z. Write these as the topographic profile polyline in section space.
- Set vertical exaggeration at draw time, not in the data. If you store true metres in both axes and apply a 2× or 5× exaggeration only in the map/layout renderer, the stored geometry stays measurable. A common error is multiplying stored elevations by the exaggeration factor, which silently corrupts any later distance or dip calculation.
- Hang interpretation off the profile. Project drillhole collars onto the line with
ST_LineLocatePoint, plot intercepts at (chainage, collar_elevation − depth), then draw contacts between holes.
A quick sanity check: a contact that dips 30° true should, at 5× vertical exaggeration, appear much steeper. Apparent dip relates to true dip by tan(apparent) = tan(true) × VE only when the section is perpendicular to strike — if anyone reads dips off an exaggerated section, this is the formula that keeps them honest.
Common pitfalls and why they happen
- Apparent vs true dip confusion. A section oblique to strike shows a flatter apparent dip than the true dip; vertical exaggeration then distorts it further. Record the section azimuth so apparent dips can be corrected.
- Section image pinned to the map. Rasterising the section throws away every attribute. It happens because exporting a PDF is the path of least resistance; the cost shows up the first time someone needs to query a unit at depth.
- Vertical datum drift. Drillhole collars surveyed against an orthometric datum mixed with a DEM in ellipsoidal heights can offset intercepts by tens of metres. Confirm both elevations share one vertical reference before plotting.
- Exaggeration baked into stored coordinates. This is the silent corruptor described above — keep VE a render-time setting.
- No shared legend. When the section's units are a separate colour list, the map and section disagree and reviewers lose trust. Bind both to one
DescriptionOfMapUnits.
QA and validation
Before a section ships, confirm: the trace start/end labels match the section view orientation (left = start); section-space polygons tile with no gaps (run a topology gaps/overlaps check); every section unit exists in the shared legend; drillholes projected onto the line fall within a sensible offset band (flag any collar more than, say, 250 m from the trace); and the stored vertical exaggeration field matches what the layout actually applied.
Bathyl perspective
We treat a cross section as a queryable subsurface dataset that stays bound to its surface map through a section ID and a shared legend. When the geologist updates a contact on the map, the section can be re-cut and re-validated instead of redrawn from memory, and any reviewer can interrogate dip, depth, and confidence directly from the geometry.
Related reading
- Cross Sections in Interactive Maps
- CRS for Geological Cross Sections
- Remote Sensing for Geological Mapping
- Geological visualization