Extracting IFC Property Sets with ifcopenshell

The direct answer: call ifcopenshell.util.element.get_psets(element) on any element and you receive a nested dictionary of the shape {pset_name: {property_name: value}}, with descriptive property sets and Qto_ quantity sets both resolved into plain Python types. This helper walks the IsDefinedBy relationship chain for you, unwraps typed IFC values, and — with its default settings — merges property sets inherited from the element’s type definition. It replaces dozens of lines of brittle manual traversal. For the wider set of parsing and geometry tasks this fits into, see the ifcopenshell Workflow guide.


How ifcopenshell Handles Property Sets

In the IFC schema, properties are not stored on an element as ordinary attributes. They hang off it through an objectified relationship. An IfcElement carries an inverse attribute IsDefinedBy, which points to one or more IfcRelDefinesByProperties relationships; each of those references an IfcPropertySet (for descriptive data) or an IfcElementQuantity (for measured quantities). The property set, in turn, holds a list of HasProperties, and each property is itself a typed entity.

Traversing that chain by hand is where most extraction code goes wrong. ifcopenshell.util.element.get_psets(element) does the traversal and returns a dictionary. A single external wall might resolve to:

{
    "Pset_WallCommon": {"id": 1423, "FireRating": "REI60", "IsExternal": True},
    "Qto_WallBaseQuantities": {"id": 1487, "Length": 5400.0, "NetVolume": 1.62},
}

Two things about that result surprise people. First, every inner dict contains an id key holding the STEP line number of the IfcPropertySet or IfcElementQuantity — useful for round-tripping, but noise when you flatten. Second, the values are already native Python types: the helper unwraps the IfcLabel, IfcBoolean, and IfcVolumeMeasure wrappers so you never touch .wrappedValue.

What the API does not do:

  • It does not attach units. NetVolume above is a bare 1.62 — a magnitude in the file’s native unit, not a pint-style quantity.
  • It does not, by default, separate quantities from descriptive properties. Pass psets_only=True or qtos_only=True to split them.
  • It does not fully flatten every exotic property type. IfcPropertyTableValue and deeply nested IfcComplexProperty structures need the verbose=True mode or a manual fallback (covered below).

The property types you will meet, in rough order of frequency:

  • IfcPropertySingleValue — one NominalValue. The common case; flattens to a scalar.
  • IfcPropertyEnumeratedValue — a chosen set of values from an enumeration; flattens to a list.
  • IfcPropertyListValue — an ordered list of values; flattens to a list.
  • IfcPropertyTableValue — paired DefiningValues/DefinedValues arrays (a lookup curve); best kept as parallel arrays.
  • IfcComplexProperty — a named group of nested sub-properties; flattens to a nested dict.

The diagram traces the relationship path the helper resolves, including the type-level inheritance branch.

IFC property set resolution path An IfcElement links through IsDefinedBy and IfcRelDefinesByProperties to an IfcPropertySet holding single, enumerated, and table properties; a parallel branch inherits property sets from the element's IfcTypeObject via IsTypedBy. IfcElement the instance IfcRelDefinesByProperties via IsDefinedBy IfcPropertySet HasProperties SingleValue Enumerated TableValue IfcTypeObject inherited via IsTypedBy get_psets(element) resolves both the solid instance path and the dashed inherited type path

Production-Ready Script

The script below reads every IfcElement, merges descriptive and quantity sets into one wide record per element keyed by GlobalId, and writes newline-delimited JSON. If pandas and pyarrow are present it also writes a columnar Parquet file, which is the better target for a warehouse or PostGIS staging table.

# ifcopenshell>=0.8.0, Python 3.9+
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.unit
import json
from pathlib import Path
from typing import Any


def _clean_set(props: dict) -> dict:
    """Drop the internal 'id' key that get_psets injects per set."""
    return {k: v for k, v in props.items() if k != "id"}


def _flatten(psets: dict, qtos: dict) -> dict[str, Any]:
    """
    Merge property sets and quantity sets into one flat dict.
    Keys are 'SetName.PropertyName' so collisions across sets stay distinct.
    List/array values (enumerations, table columns) are preserved as-is.
    """
    flat: dict[str, Any] = {}
    for set_name, props in {**psets, **qtos}.items():
        for prop_name, value in _clean_set(props).items():
            flat[f"{set_name}.{prop_name}"] = value
    return flat


def extract_property_sets(ifc_path: str, element_query: str = "IfcElement") -> list[dict]:
    """
    Extract all property sets and quantity sets for the queried elements.

    Returns one record per element:
        GlobalId (str), IfcType (str), Name (str | None), plus one key
        per flattened 'PsetName.Property' entry.
    """
    model = ifcopenshell.open(ifc_path)

    # Length unit scale: multiply length-type quantities to reach meters.
    # Areas scale by scale**2 and volumes by scale**3 (applied downstream).
    length_scale = ifcopenshell.util.unit.calculate_unit_scale(model)

    records: list[dict] = []
    for element in model.by_type(element_query):
        # psets_only / qtos_only split descriptive data from measured quantities.
        # should_inherit=True (default) folds in the element type's property sets.
        psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
        qtos = ifcopenshell.util.element.get_psets(element, qtos_only=True)

        record: dict[str, Any] = {
            "GlobalId": element.GlobalId,
            "IfcType": element.is_a(),
            "Name": getattr(element, "Name", None),
            "_length_unit_scale": length_scale,
        }
        record.update(_flatten(psets, qtos))
        records.append(record)

    return records


def write_ndjson(records: list[dict], path: str) -> None:
    with open(path, "w", encoding="utf-8") as fh:
        for rec in records:
            fh.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")


def write_parquet(records: list[dict], path: str) -> bool:
    """Write a columnar Parquet table. Returns False if pandas is unavailable."""
    try:
        import pandas as pd  # pandas>=2.0.0, pyarrow>=14.0.0
    except ImportError:
        return False
    # A sparse, wide schema: every distinct 'Pset.Property' becomes a column.
    # pandas fills absent keys with NaN, which is the correct sparse semantics.
    pd.DataFrame(records).to_parquet(path, engine="pyarrow", index=False)
    return True


if __name__ == "__main__":
    recs = extract_property_sets("model.ifc", element_query="IfcElement")
    print(f"Extracted property data for {len(recs)} elements")

    write_ndjson(recs, "psets.ndjson")
    if write_parquet(recs, "psets.parquet"):
        print("Wrote psets.parquet (columnar)")
    else:
        print("pandas/pyarrow not installed — NDJSON only")

Key implementation notes:

  • The _clean_set helper strips the id key. Skip it and every element emits a meaningless integer column such as Pset_WallCommon.id, which pollutes both the JSON and the Parquet schema.
  • Descriptive and quantity data are read in two calls (psets_only and qtos_only) rather than one. That keeps a Qto_ length from silently sharing a column name with a same-named descriptive property, and it lets you apply unit scaling to quantities alone later.
  • default=str on json.dumps is a safety net for the rare property value that resolves to a non-JSON-native type (for example a nested tuple from an IfcPropertyTableValue). It serializes deterministically instead of raising.
  • The wide, sparse schema is deliberate. Different element classes carry different property sets, so most cells are empty. Parquet stores that sparsity efficiently; a normalized long table (GlobalId, key, value) is the alternative when you feed a relational store — see Mapping IFC Property Sets to PostGIS Columns for that shape.

To carry these attributes onto geometry rather than a flat table, pair this extraction with Batch Converting IFC to GeoJSON with ifcopenshell, which attaches the same flattened records as feature properties.

Compatibility Matrix

Component Supported Range Notes
Python 3.9 – 3.12 Uses PEP 585 builtin generics (list[dict]); 3.9 needs from __future__ import annotations only for runtime-evaluated hints
ifcopenshell ≥ 0.8.0 get_psets / get_pset are stable since 0.7.0; the verbose, psets_only, qtos_only, and should_inherit keyword arguments require ≥ 0.7.0
ifcopenshell (0.7.x) Works Same util.element API; the 0.8 packaging renamed some geometry settings but left util.element unchanged
pandas ≥ 2.0.0 Optional; only needed for the Parquet path
pyarrow ≥ 14.0.0 Parquet engine; fastparquet also works but handles sparse wide frames less predictably
IFC schema IFC2x3, IFC4, IFC4x3 Property set mechanism is schema-stable; IFC4 adds IfcElementQuantity sub-quantity types like IfcPhysicalComplexQuantity

Fallback Strategies

1. IfcPropertyTableValue collapses to a single value or None

Table properties store two parallel arrays — DefiningValues and DefinedValues — that describe a lookup curve (for example, a thermal transmittance against temperature). The flat helper cannot represent both columns as one scalar. Read them directly and keep them as arrays:

# ifcopenshell>=0.8.0
def read_table_properties(element) -> dict[str, dict]:
    tables: dict[str, dict] = {}
    for rel in getattr(element, "IsDefinedBy", []):
        if not rel.is_a("IfcRelDefinesByProperties"):
            continue
        pset = rel.RelatingPropertyDefinition
        if not pset.is_a("IfcPropertySet"):
            continue
        for prop in pset.HasProperties or []:
            if prop.is_a("IfcPropertyTableValue"):
                tables[prop.Name] = {
                    "defining": [v.wrappedValue for v in prop.DefiningValues or []],
                    "defined": [v.wrappedValue for v in prop.DefinedValues or []],
                }
    return tables

2. Nested IfcComplexProperty structures flatten ambiguously

A complex property groups sub-properties under one name. Enable verbose mode so the helper returns nested dicts you can descend into, then decide whether to dot-join the nested keys:

# ifcopenshell>=0.8.0 — verbose returns richer structures for nested properties
psets = ifcopenshell.util.element.get_psets(element, verbose=True)

3. You need only instance properties, not inherited type properties

By default the helper folds in property sets defined on the element’s IfcTypeObject. When you are auditing what an author set on the instance itself, disable inheritance:

instance_only = ifcopenshell.util.element.get_psets(element, should_inherit=False)

4. Unit-bearing quantities look thousands of times too large

Qto_ lengths are raw magnitudes in the file’s native unit. A model authored in millimeters reports a Length of 5400.0 for a 5.4 m wall. Apply the scale factor, remembering the exponent for areas and volumes:

# ifcopenshell>=0.8.0
scale = ifcopenshell.util.unit.calculate_unit_scale(model)  # 0.001 for mm
length_m = raw_length * scale
area_m2 = raw_area * scale ** 2
volume_m3 = raw_volume * scale ** 3

5. Fetching one known property is faster than reading every set

When you need a single value — say IsExternal — do not flatten the whole element. Use get_pset with the prop argument, which returns just that value or None:

# ifcopenshell>=0.8.0
is_external = ifcopenshell.util.element.get_pset(
    element, "Pset_WallCommon", prop="IsExternal"
)

FAQ

Why does each property set dict contain an id key?

get_psets injects an id key holding the STEP line number of the IfcPropertySet or IfcElementQuantity itself. It is metadata, not a property. Drop keys named id before flattening, or you will emit a spurious integer column per set.

Does get_psets include type-level property sets?

Yes. With should_inherit left at its default of True, get_psets merges property sets attached to the element’s IfcTypeObject with those attached to the instance. Instance values override type values on key collisions. Pass should_inherit=False to read only instance-level sets.

How do I separate quantities from descriptive properties?

Call get_psets(element, psets_only=True) for IfcPropertySet data and get_psets(element, qtos_only=True) for IfcElementQuantity data. Quantity sets are conventionally named with a Qto_ prefix and carry lengths, areas, and volumes in the project’s base units.

What units do quantity values use?

get_psets returns the raw stored magnitude, not a unit. Read ifcopenshell.util.unit.calculate_unit_scale(model) to convert length quantities to meters, and remember area and volume scale by the square and cube of that factor respectively.