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:

  1. fault_lines — geometry type LineString (or MultiLineString where a trace is split by cover). One feature per mapped fault segment.
  2. structural_measurements — geometry type Point. One feature per field reading, with a fault_id foreign 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

FieldTypeNotes
fault_idtext/uuidStable primary key, never reused
nametextOptional named structure
fault_typetext (domain)normal, reverse, thrust, strike-slip, oblique, unknown
certaintytext (domain)observed, inferred, concealed
expressiontext (domain)exposed, covered, interpreted-geophysics
dipreal0–90 degrees, representative or null
dip_dirreal0–360 azimuth of dip direction
slip_sensetexthanging-wall down/up, dextral, sinistral
displacement_mrealEstimated throw/heave, units in metres
sourcetextMap, survey, or publication
src_scaleintegerSource map scale denominator (e.g. 50000)
mapped_datedateWhen 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) and dip (0–90), or dip_dir and dip.
  • For linear features: trend (0–360) and plunge (0–90).
  • fault_id foreign 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_columns and constraint tables so QGIS shows enumerated drop-downs for fault_type and certainty.
  • Validate on export with ogr2ogr and 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

  1. Digitise the trace as one LineString in fault_lines: fault_type='normal', certainty='observed' where exposed; split into a second segment with certainty='concealed' under alluvium.
  2. Record three field readings in structural_measurements along the trace — dip 62/dip_dir 270, 58/272, 65/268 — each with the fault_id foreign key.
  3. The line's representative dip is left null (it varies); analysts read the point measurements for detail.
  4. 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 .prj and the CRS is lost, drop the .dbf and 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_m silently becomes displaceme. 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 source or name corrupt 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_id resolves 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

Sources