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.
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 toJSONB— pass them throughpsycopg2.extras.Jsonso quoting and escaping are handled by the driver, never by string formatting.ON CONFLICT (global_id) DO UPDATEmakes 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; thebrep_dataline above is a placeholder for whatever WKB serializer your pipeline uses (for example, building a Shapely/geoalchemy2geometry from the triangulated verts and exportingwkb). - Create the GIN index on
psetsand the GiST index ongeomup front. Without the GIN index,psets @> '{"Pset_WallCommon": {"IsExternal": true}}'degrades to a full scan on large models. execute_valueswithpage_size=500batches inserts into few round-trips; for models with hundreds of thousands of elements, preferCOPYinto a staging table thenINSERT ... 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.
Related Pages
- IFC4x3 Schema Mapping — parent reference on the IFC4x3 data model and how classes and property sets are structured
- Mapping IFC Properties to GeoJSON Attributes — sibling workflow that flattens the same property sets into a flat-file GIS target instead of a database
- ifcopenshell Workflow — related reference covering IFC geometry extraction and georeferencing that produces the WKB loaded here
- Metadata Extraction Strategies — broader patterns for turning embedded model metadata into queryable attributes