Short answer
A robust schema for faults and structures models the fault trace as a LineString carrying classification and confidence fields, and stores orientation measurements (strike/dip, trend/plunge) as separate point features linked by a foreign key. The fields that matter most are fault type, certainty, dip with an unambiguous dip direction, displacement, and full provenance (source, scale, date). Enforce ranges and domains with database constraints so a 540-degree dip azimuth or a blank fault type can never be saved.
The rest of this article specifies the geometry model, the attribute schema with valid value sets, the constraint code in PostGIS and GeoPackage, and the conversion pitfalls that quietly corrupt structural data on handoff.
Why structural data needs more than a line
A fault is not just a line on a map. It has a type, a sense of movement, a dip that may change along strike, a confidence level that varies between mapped segments, and a surface expression that may be observed in one place and concealed under cover elsewhere. A schema that records only geometry and a name throws away exactly the information a structural geologist or a fault-displacement-hazard analyst later needs.
Two design facts shape everything:
- Orientation varies along a fault. Dip and dip direction measured at one outcrop do not describe the whole trace. So orientation belongs on point measurements, not as a single attribute on the line.
- Certainty is a first-class attribute. Observed, inferred, and concealed fault segments must be distinguishable, because downstream models weight them differently. Folding certainty into symbology and losing it from the data is a common, costly mistake.
Geometry model
Use two related layers:
fault_lines— geometry type LineString (or MultiLineString where a trace is split by cover). One feature per mapped fault segment.structural_measurements— geometry type Point. One feature per field reading, with afault_idforeign key when the reading is associated with a fault, or standalone for bedding/foliation.
Keeping these separate avoids two recurring failures: mixed geometry types in one layer (which shapefiles cannot hold and web renderers handle badly), and the false implication that a single dip value describes an entire trace.
Attribute schema for fault lines
| Field | Type | Notes |
|---|---|---|
fault_id | text/uuid | Stable primary key, never reused |
name | text | Optional named structure |
fault_type | text (domain) | normal, reverse, thrust, strike-slip, oblique, unknown |
certainty | text (domain) | observed, inferred, concealed |
expression | text (domain) | exposed, covered, interpreted-geophysics |
dip | real | 0–90 degrees, representative or null |
dip_dir | real | 0–360 azimuth of dip direction |
slip_sense | text | hanging-wall down/up, dextral, sinistral |
displacement_m | real | Estimated throw/heave, units in metres |
source | text | Map, survey, or publication |
src_scale | integer | Source map scale denominator (e.g. 50000) |
mapped_date | date | When the feature was mapped |
The dip / dip_dir pair is preferred over plain strike because strike alone has a 180-degree ambiguity ("strike 090" could dip north or south). If you must store strike, use the right-hand-rule convention and document it.
Attribute schema for structural measurements
Each point reading should carry the geometry kind and the angles:
structure_type: bedding, foliation, joint, fault-plane, lineation, fold-axis.- For planar features:
strike(right-hand rule) anddip(0–90), ordip_diranddip. - For linear features:
trend(0–360) andplunge(0–90). fault_idforeign key (nullable),source,observed_date.
Store angles as numeric attributes, never baked only into rotated symbols. Symbology is a presentation choice; the numbers must survive reprojection, re-styling, and export.
Enforcing the schema in PostGIS
PostGIS lets you make invalid data unstorable, which is far stronger than relying on editors to behave.
CREATE TABLE fault_lines (
fault_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text,
fault_type text NOT NULL
CHECK (fault_type IN ('normal','reverse','thrust',
'strike-slip','oblique','unknown')),
certainty text NOT NULL
CHECK (certainty IN ('observed','inferred','concealed')),
dip real CHECK (dip BETWEEN 0 AND 90),
dip_dir real CHECK (dip_dir >= 0 AND dip_dir < 360),
displacement_m real,
source text,
src_scale integer,
mapped_date date,
geom geometry(LineString, 2154) NOT NULL
);
CREATE INDEX fault_lines_gix ON fault_lines USING GIST (geom);
The typmod column geometry(LineString, 2154) rejects any geometry that is not a LineString in EPSG:2154, and the CHECK constraints reject out-of-range angles and unknown fault types at insert time. Use ST_SetSRID(geom, 2154) when loading rows that lack an SRID, and ST_Transform(geom, 2154) when bringing in data from another CRS.
Enforcing it in GeoPackage and on export
GeoPackage rides on SQLite, whose typing is permissive, so the same guarantees take more effort. Two practical options:
- Declare value domains in the
gpkg_data_columnsand constraint tables so QGIS shows enumerated drop-downs forfault_typeandcertainty. - Validate on export with
ogr2ogrand a SQL filter, refusing rows that violate the ranges, rather than trusting the container.
# export only schema-valid faults to a delivery GeoPackage, keeping the CRS
ogr2ogr -f GPKG faults_v1.gpkg PG:"dbname=project" \
-sql "SELECT * FROM fault_lines
WHERE dip IS NULL OR dip BETWEEN 0 AND 90" \
-nln fault_lines -a_srs EPSG:2154
Worked example: a normal fault with varying dip
- Digitise the trace as one LineString in
fault_lines:fault_type='normal',certainty='observed'where exposed; split into a second segment withcertainty='concealed'under alluvium. - Record three field readings in
structural_measurementsalong the trace — dip 62/dip_dir 270, 58/272, 65/268 — each with thefault_idforeign key. - The line's representative
dipis left null (it varies); analysts read the point measurements for detail. - Export to GeoPackage for the client and to GeoJSON (reprojected to EPSG:4326) for the web viewer.
The structure now answers "where, what kind, how confident, and how does it dip along strike" — none of which a bare line could.
Common pitfalls and why they happen
- Sending only the
.shp. A shapefile is six-plus sibling files (.shp,.shx,.dbf,.prj,.cpg, …); drop the.prjand the CRS is lost, drop the.dbfand all attributes vanish. Deliver GeoPackage to avoid the whole class of problem. - Strike without convention. Plain strike is 180-degree ambiguous; without the right-hand rule the dip direction is guesswork. Store dip_dir explicitly.
- Truncated field names. Shapefile DBF caps field names at 10 characters, so
displacement_msilently becomesdisplaceme. Conversion to/from shapefile corrupts the schema; GeoPackage and PostGIS have no such limit. - Certainty hidden in symbology. When inferred/concealed live only as line styles, the distinction is lost on export and in analysis. Keep it as data.
- Encoding loss. Non-ASCII characters in
sourceornamecorrupt without a.cpg/UTF-8 declaration. Set encoding explicitly on conversion.
Validation
- After any conversion, reopen in a clean QGIS session and confirm geometry type, CRS (EPSG code), field names, and feature count match the source.
- Query for out-of-range angles:
SELECT * FROM fault_lines WHERE dip > 90 OR dip_dir >= 360;— should return zero rows. - Confirm every measurement's
fault_idresolves to an existing fault (referential integrity). - Ship a metadata record stating CRS, schema, source scale, and date alongside the data.
Bathyl perspective
We treat the structural schema as the deliverable, not an afterthought to the geometry. Enforcing fault type, certainty, and orientation ranges at the database level means the data a client receives is queryable and defensible, not a pile of lines someone has to re-interpret. A dataset is finished when another team can trust and extend it, not when it merely opens.
Related reading
- Data Schema for Geological Units
- Shapefile vs GeoPackage vs GeoJSON
- Why Your GIS Layers Do Not Line Up
- Spatial data products