Mapping IFC Property Sets to PostGIS Columns

To map IFC property sets into PostGIS, flatten each element’s property sets with ifcopenshell.util.element.get_psets(), then load them into a table that pairs a small set of stable core columns (global_id, ifc_class, name) with a single JSONB column holding the variable property sets — plus a geometry(GeometryZ, <srid>) column for the projected footprint. This hybrid layout survives the schema drift that makes one-column-per-property designs brittle, while still letting you promote a whitelist of hot properties into typed columns. This page extends the IFC4x3 Schema Mapping reference into the persistence layer, where flattened building data becomes a queryable spatial database.

How ifcopenshell Handles Property Sets

An IFC element carries its descriptive data in property sets (IfcPropertySet) and quantity sets (IfcElementQuantity), each a named bag of key/value properties attached through inverse relationships. Traversing those relationships by hand is verbose, so ifcopenshell.util.element.get_psets(element) does it for you: it returns a dict keyed by pset name, whose values are dicts of property name to value. A wall might return {"Pset_WallCommon": {"IsExternal": True, "FireRating": "REI 60"}, "Qto_WallBaseQuantities": {"NetVolume": 4.2}}.

The problem is that this shape is unbounded and producer-dependent. Revit, ArchiCAD, Civil 3D, and infrastructure authoring tools each emit different pset names, and IFC4x3 adds infrastructure-specific sets that never appear in a building model. A relational schema with one column per property would need hundreds of nullable columns and would break every time a new authoring tool appeared. The durable answer is to keep a stable spine — the handful of columns every element has — and store the variable remainder as JSONB, which PostGIS/PostgreSQL can index and query with containment operators.

Flattening an IFC Element into a Hybrid PostGIS Row Diagram: an IFC element on the left fans out into three targets. Its GlobalId, class, and name become stable typed columns; its property sets are flattened by get_psets into a JSONB column indexed with GIN; its reprojected shape becomes a geometry column indexed with GiST. IFC Element IfcWall, IfcSlab... Core typed columns global_id, ifc_class, name JSONB psets column get_psets(element) geometry(GeometryZ, srid) reprojected WKB PostGIS GIN + GiST indexes

The geometry side is separate but equally important: IFC coordinates live in a local engineering system, so the shape must be georeferenced and reprojected before it lands in a geometry(GeometryZ, <srid>) column. That reprojection is a full workflow of its own, covered by the ifcopenshell Workflow extraction pipeline; here we assume you already have a WKB payload in the target CRS and focus on the relational load.

Production-Ready Script

This script reads elements from an IFC model, flattens their property sets, builds a stable core row plus a JSONB pset payload, and inserts into a PostGIS table with a projected geometry column. It uses psycopg2 with execute_values for batched inserts and passes geometry as WKB through ST_GeomFromWKB.

# ifcopenshell>=0.8.0 | psycopg2>=2.9 | PostGIS 3.x | python>=3.9
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.element as ue
import psycopg2
from psycopg2.extras import Json, execute_values
from typing import Any, Iterator

TARGET_SRID = 27700  # projected CRS you reprojected IFC coords into; not a placeholder

DDL = """
CREATE TABLE IF NOT EXISTS ifc_elements (
    id          BIGSERIAL PRIMARY KEY,
    global_id   TEXT UNIQUE NOT NULL,
    ifc_class   TEXT NOT NULL,
    name        TEXT,
    psets       JSONB NOT NULL DEFAULT '{}'::jsonb,
    geom        geometry(GeometryZ, %(srid)s)
);
CREATE INDEX IF NOT EXISTS ifc_elements_psets_gin ON ifc_elements USING GIN (psets);
CREATE INDEX IF NOT EXISTS ifc_elements_geom_gist ON ifc_elements USING GIST (geom);
"""

INSERT_SQL = """
INSERT INTO ifc_elements (global_id, ifc_class, name, psets, geom)
VALUES %s
ON CONFLICT (global_id) DO UPDATE
    SET psets = EXCLUDED.psets, geom = EXCLUDED.geom
"""

# execute_values template: geometry passed as WKB bytes -> ST_GeomFromWKB.
TEMPLATE = "(%s, %s, %s, %s, ST_SetSRID(ST_GeomFromWKB(%s), %s))"


def build_rows(ifc_path: str) -> Iterator[tuple[Any, ...]]:
    """Yield insert-ready rows (global_id, ifc_class, name, Json(psets), wkb)."""
    model = ifcopenshell.open(ifc_path)
    settings = ifcopenshell.geom.settings()
    # Emit geometry in world coordinates so the WKB is placement-resolved.
    settings.set(settings.USE_WORLD_COORDS, True)

    for element in model.by_type("IfcProduct"):
        if element.Representation is None:
            continue  # spatial containers without shape are skipped

        # Flatten every property/quantity set into nested dicts.
        psets = ue.get_psets(element)

        # Try to build WKB; some elements have no valid body representation.
        wkb: bytes | None = None
        try:
            shape = ifcopenshell.geom.create_shape(settings, element)
            wkb = shape.geometry.brep_data  # placeholder; see note on WKB below
        except RuntimeError:
            wkb = None

        yield (
            element.GlobalId,
            element.is_a(),
            getattr(element, "Name", None),
            Json(psets),
            psycopg2.Binary(wkb) if wkb is not None else None,
        )


def load_to_postgis(ifc_path: str, dsn: str) -> None:
    conn = psycopg2.connect(dsn)
    try:
        with conn, conn.cursor() as cur:
            cur.execute(DDL, {"srid": TARGET_SRID})
            rows = list(build_rows(ifc_path))
            # Append the SRID to each row for the ST_SetSRID call in TEMPLATE.
            values = [(gid, cls, name, js, wkb, TARGET_SRID)
                      for (gid, cls, name, js, wkb) in rows]
            execute_values(cur, INSERT_SQL, values, template=TEMPLATE, page_size=500)
        print(f"Loaded {len(rows)} IFC elements into PostGIS (SRID {TARGET_SRID}).")
    finally:
        conn.close()


if __name__ == "__main__":
    load_to_postgis(
        "model.ifc",
        "dbname=gis user=gis password=gis host=localhost port=5432",
    )

Key implementation notes:

  • get_psets(element) returns nested dicts that map cleanly to JSONB — pass them through psycopg2.extras.Json so quoting and escaping are handled by the driver, never by string formatting.
  • ON CONFLICT (global_id) DO UPDATE makes the load idempotent: re-running an updated model refreshes existing rows instead of raising a unique-violation, which matters for incremental digital-twin syncs.
  • The geometry is passed as WKB bytes and wrapped in ST_SetSRID(ST_GeomFromWKB(...)). Producing correct 3D WKB from an IFC shape is the extraction step’s job; the brep_data line above is a placeholder for whatever WKB serializer your pipeline uses (for example, building a Shapely/geoalchemy2 geometry from the triangulated verts and exporting wkb).
  • Create the GIN index on psets and the GiST index on geom up front. Without the GIN index, psets @> '{"Pset_WallCommon": {"IsExternal": true}}' degrades to a full scan on large models.
  • execute_values with page_size=500 batches inserts into few round-trips; for models with hundreds of thousands of elements, prefer COPY into a staging table then INSERT ... SELECT.

Compatibility Matrix

Component Supported range Notes
ifcopenshell >=0.8.0 ifcopenshell.util.element.get_psets is stable; 0.8+ has the current geom.settings API.
psycopg2 >=2.9 Json and execute_values live in psycopg2.extras; 2.9+ for modern PostgreSQL.
PostGIS 3.x geometry(GeometryZ, srid), ST_GeomFromWKB, GIN/GiST indexing all present.
PostgreSQL 12+ JSONB and GIN operator classes; 12+ for generated columns if you promote properties.
Python 3.9+ Uses typing and dict-ordering guarantees.
IFC schema IFC2x3, IFC4, IFC4x3 get_psets works across schemas; IFC4x3 adds infrastructure-specific psets.

Fallback Strategies

1. Schema drift: JSONB versus typed columns

New authoring tools introduce new pset names on every project. Keep the variable remainder in JSONB so a new pset never requires a migration, and promote only a stable whitelist of hot properties (for example IsExternal, FireRating, LoadBearing) into typed columns via generated columns or an ETL step. This hybrid gives B-tree speed on hot filters and drift-tolerance everywhere else.

2. Unit-bearing values

An IFC length or area is expressed in the project’s declared unit, which is not always metres. Read the IfcUnitAssignment and either normalise every quantity to SI before insert or store the unit string next to the value inside JSONB. A bare number with no unit silently corrupts every downstream SUM and comparison.

3. Type coercion into JSONB

IFC property values arrive as booleans, enumerations, measures, and occasionally lists. JSONB preserves booleans and numbers natively, but IFC enum tokens and ifcopenshell measure wrappers must be coerced to plain Python scalars first, or Json() will serialize an opaque repr. Normalise values in a small helper before building the payload.

4. SRID declaration and reprojection

Declaring the column as geometry(GeometryZ, <srid>) does not reproject anything — it only asserts the CRS of the bytes you insert. IFC geometry is local; georeference and reproject it into the target EPSG code first, then insert. Mismatched SRIDs make ST_Transform and spatial joins fail silently or raise. See the georeferencing steps in the extraction workflow before loading.

5. Elements without geometry

Spatial-structure elements (IfcBuildingStorey, IfcSpace boundaries) and abstract products may have no body representation. Insert them with a NULL geometry rather than skipping them, so property queries still find them, and let the GiST index simply omit the null rows.

FAQ

Should IFC property sets go into JSONB or typed columns in PostGIS?

Use JSONB for the wide, variable set of producer-specific property sets, and promote a small whitelist of frequently-queried properties into typed columns. This hybrid keeps the schema stable against pset drift while letting hot filters use B-tree indexes. A GIN index on the JSONB column keeps containment queries such as psets @> '{...}' fast without a migration every time a new authoring tool appears.

How do I preserve units when flattening IFC properties?

IFC property values are unit-bearing: a length is expressed in the model’s declared project unit, not necessarily metres. Read the IfcUnitAssignment from the file and either normalise every value to SI before insert or store the unit string alongside the value in JSONB. Storing a bare number without its unit corrupts any downstream aggregation or comparison.

What SRID should the PostGIS geometry column use?

Use the projected CRS you reprojected the IFC coordinates into, never a placeholder like 0 or 4326-by-default. IFC geometry is in a local engineering coordinate system; georeference and reproject it first, then declare the resulting EPSG code as the column SRID so ST_Transform, distance queries, and spatial joins all behave correctly.