> ## Documentation Index
> Fetch the complete documentation index at: https://docs.speckle.systems/llms.txt
> Use this file to discover all available pages before exploring further.

# Assess specklepy breaking changes

> Before-and-after specklepy examples so you can judge 2026.9 migration impact.

<Note>
  This is 2026.9 preview documentation. Coverage here is incremental: a page exists only where
  2026.9 differs or is newly documented.
</Note>

Use this page to judge what a specklepy script must change for 2026.9. It is not a replacement for
the [specklepy](/developers/sdks/python/introduction) guides, and it is not a full API reference.

Prior to 2026.9, the path is still **PAT → `operations.receive` → walk a `Base` tree**. In 2026.9
that receive is a compatibility shim. The new path is **`operations.receive3`**, which returns a
disposable `Model` with columnar properties and typed relations. Dates, compatibility mode, and who
must act are on
[Data model migration for developers](/developers/migration/data-model-migration). The object-model
change itself is on [Object model in 2026.9](/next/developers/object-model/overview).

<Warning>
  Versions published by a 2026.9 connector need specklepy **2026.9.0b3** or later, installed with
  the `bundle` extra. An older build 404s on that data. Versions from older connectors keep working
  on the specklepy you have today. See [Who's
  affected](/developers/migration/data-model-migration#whos-affected).
</Warning>

<Note>
  Prefer `operations.receive3` for new scripts. `operations.receive` stays callable on legacy
  object-graph versions, and on bundle-only versions it returns a projected `Base` tree.
</Note>

## What changes for a script

| Concern                 | Prior to 2026.9                                | In 2026.9                                                                                            |
| ----------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Package                 | `pip install specklepy`                        | `pip install "specklepy[bundle]==2026.9.0b3"`. The `bundle` extra pulls in pyarrow                   |
| Load a version          | `operations.receive` with `referenced_object`  | `operations.receive3` with account plus project, model, and version ids. Returns `Model`             |
| Filter and count        | Walk `elements`, then `DataObject.properties`  | `model.objects`, `objects_with`, and `get_string` / `get_double`                                     |
| Nested property walk    | `properties["x"]` then `["y"]`                 | Dotted path (`get_string("x.y")`) or `PropertyView.under`                                            |
| Level, material, groups | Unpack proxy `objects` lists                   | Accessors on `ModelObject` (`level`, `host`, `collection`)                                           |
| Publish from a script   | `operations.send` then `client.version.create` | `operations.send3` with a `BundleBuilder`. The version is visible only after ingestion completes     |
| Transports              | `send` / `receive` with `ServerTransport`      | Frozen legacy surface. A bundle reference through `receive` needs an authenticated `ServerTransport` |

## Update specklepy first

Install a 2026.9 specklepy that can receive bundle-only versions, including the `bundle` extra.
**2026.9.0b3** is the first public build that does this. A later stable **2026.9.0** replaces the
beta pin; the calls on this page do not change.

```bash theme={null}
pip install "specklepy[bundle]==2026.9.0b3"
```

On a bundle-only version, `version.referenced_object` is a bundle reference such as
`bundle.<projectId>.<modelId>.<versionId>`, not a content hash. An older specklepy treats that
string as an object id, requests it from the objects API, and receives a 404 whose body tells you
to upgrade.

<Warning>
  Do not fetch `version.referenced_object` from the objects REST endpoints. Bundle references always
  404 there, including after you upgrade. Pass ids to `operations.receive3`, or pass the reference
  to `operations.receive` and let specklepy dispatch.
</Warning>

## Load a model

Prior to 2026.9, you resolve `referenced_object` and call `operations.receive`. In 2026.9,
`operations.receive3` takes an `Account` plus project, model, and version ids and returns a
disposable `Model`. Use it as a context manager: it owns the downloaded bundle files on disk until
the `with` block ends.

Auth and version lookup stay on [Authentication](/developers/sdks/python/getting-started/authentication)
and the [Quickstart](/developers/sdks/python/getting-started/quickstart). The snippets below assume
`account`, `client`, `transport`, `project_id`, `model_id`, and `version_id` are already in scope.
Build `account` with `Account.from_token` and a PAT, or `get_default_account()`.

<Tabs>
  <Tab title="Prior to 2026.9 (receive)">
    ```python Prior to 2026.9 lines icon="python" theme={null}
    version = client.version.get(version_id, project_id)
    root = operations.receive(version.referenced_object, remote_transport=transport)
    ```
  </Tab>

  <Tab title="2026.9 (receive3)">
    ```python 2026.9 lines icon="python" theme={null}
    with operations.receive3(account, project_id, model_id, version_id) as model:
        pass
    ```
  </Tab>
</Tabs>

<Tip>
  Close the model (leave the `with` block, or call `model.close()`) so the download directory is
  deleted. Parsed objects stay usable after close. Geometry you have not touched yet cannot be read
  afterwards.
</Tip>

## Count and filter objects

Prior to 2026.9, you walk a `Base` tree. In 2026.9, objects are a flat list. Properties are
path-keyed (`"category"`, `"Constraints.Base Offset"`). Relations are accessors, not Proxy lists.

<Tabs>
  <Tab title="Prior to 2026.9">
    ```python Prior to 2026.9 lines icon="python" theme={null}
    from specklepy.objects.data_objects import DataObject

    def iter_elements(obj):
        yield obj
        for child in getattr(obj, "elements", None) or []:
            yield from iter_elements(child)


    counts = {}
    for obj in iter_elements(root):
        if not isinstance(obj, DataObject):
            continue
        key = (obj.properties or {}).get("category") or "Unknown"
        counts[key] = counts.get(key, 0) + 1

    by_level = {}
    for obj in iter_elements(root):
        if not isinstance(obj, DataObject):
            continue
        key = (obj.properties or {}).get("level") or "Unknown"
        by_level.setdefault(key, []).append(obj)

    walls = [
        o
        for o in iter_elements(root)
        if isinstance(o, DataObject) and (o.properties or {}).get("category") == "Walls"
    ]
    ```
  </Tab>

  <Tab title="2026.9">
    ```python 2026.9 lines icon="python" theme={null}
    counts = {}
    for obj in model.objects:
        key = obj.get_string("category") or obj.name or "Unknown"
        counts[key] = counts.get(key, 0) + 1

    by_level = {}
    for obj in model.objects:
        key = obj.level.name if obj.level else "Unknown"
        by_level.setdefault(key, []).append(obj)

    walls = [o for o in model.objects_with("category") if o.get_string("category") == "Walls"]
    ```
  </Tab>
</Tabs>

Look up one object with `model.object_by_application_id(id)`. `application_id` is the only identity
a bundle object has. Do not treat `Base.id` as a content hash on bundle-only versions.

## Replace property walks

Prior to 2026.9, nested parameters are dictionaries you walk: `obj.properties["x"]` then `["y"]`.
In 2026.9 those paths are flat. `PropertyView` is the type that replaces that walk.
`ModelObject.properties` is already this view.

* **`get_string` / `get_double` / `get_bool`:** typed lookup. `None` if the path is missing or the
  value is another type. Instance, then type, then root-scalar precedence.
* **Indexer (`obj["path"]`):** untyped lookup, same precedence.
* **`properties.under("Constraints")`:** the subtree under that prefix, with the prefix stripped.
* **`to_nested()`:** the old nested-dictionary tree. Allocates. Use only when a caller still
  requires that shape.

Prefer `get_string` / `get_double` for a single path. Use `under` when you need a group.

The snippets below assume `wall` is already in scope: a `DataObject` from the tree, or a
`ModelObject` from `model.objects`.

<Tabs>
  <Tab title="Prior to 2026.9">
    ```python Prior to 2026.9 lines icon="python" theme={null}
    groups = (wall.properties or {}).get("parameters") or {}
    x = groups.get("x") or {}
    y = x.get("y")
    ```
  </Tab>

  <Tab title="2026.9">
    ```python 2026.9 lines icon="python" theme={null}
    y = wall.get_string("parameters.x.y")
    offset = wall.get_double("Constraints.Base Offset")

    constraints = wall.properties.under("Constraints")
    for key, value in constraints.items():
        print(f"{key}: {value}")
    ```
  </Tab>
</Tabs>

<Warning>
  If the script reads `parameter["value"]` or other per-parameter metadata from the received tree,
  that shape is gone on bundle-only versions. Read the value from a dotted path, not from a metadata
  wrapper.
</Warning>

## Replace Proxy walks

Prior to 2026.9, grouping by level or material means indexing `applicationId`s and resolving proxy
`objects` lists. In 2026.9 those links are typed relations. See
[Relations in 2026.9](/next/developers/object-model/relations) for the names.

<Tabs>
  <Tab title="Prior to 2026.9">
    ```python Prior to 2026.9 lines icon="python" theme={null}
    by_app_id = {}
    for obj in iter_elements(root):
        app_id = getattr(obj, "applicationId", None)
        if app_id:
            by_app_id[app_id] = obj
    ```
  </Tab>

  <Tab title="2026.9">
    ```python 2026.9 lines icon="python" theme={null}
    by_level = {}
    for obj in model.objects:
        key = obj.level.name if obj.level else "Unknown"
        by_level.setdefault(key, []).append(obj)

    hosted = [o for o in model.objects if o.host is not None]
    ```
  </Tab>
</Tabs>

A missing nested `elements` tree or Proxy `objects` list on 2026.9 data is expected. Do not unpack
Proxies to rebuild containment.

## What operations.receive still does

`operations.receive` remains callable. It is the compatibility path, not the 2026.9 default.

* **Legacy object-graph versions:** behaviour is unchanged. You still pass `referenced_object` as a
  content hash.
* **Bundle-only versions:** `referenced_object` is a bundle reference
  (`bundle.<projectId>.<modelId>.<versionId>`). A 2026.9 specklepy dispatches on that prefix and
  returns a best-effort `Base` tree (`Model.to_base()`, DataObject idiom, `version = 4` on the
  root). That projection does not scale to the model sizes the bundle format is built for, and it
  is not a lossless round trip. It needs an authenticated `ServerTransport` for the reference's
  project.
* **Older specklepy:** fetching that `referenced_object` from the objects API returns **404** with
  upgrade guidance. Upgrade specklepy rather than parsing the 404 body.

<Warning>
  Receiving a bundle-only version as a `Base` tree and sending it again with `operations.send` is
  not a supported copy workflow. The tree is a baked view, not the authored graph. Receive, derive
  new data, then send remains ordinary supported use.
</Warning>

## Publish from a script

Most analysis scripts only receive. If you currently call `operations.send` then
`client.version.create`, the 2026.9 replacement is `operations.send3` with a `BundleBuilder`.
`operations.send` stays accepted and starts emitting deprecation warnings. There is no removal date.

`send3` returns a reserved version id immediately. Queries, the version URL, and `version_created`
webhooks fire when ingestion completes. Poll for the version, or watch the project's model
ingestions, if you need to follow up in the same process.

## FAQ

<AccordionGroup>
  <Accordion title="Do I have to rewrite an operations.receive script on day one?">
    Upgrade specklepy first so versions from a 2026.9 connector do not 404. Versions from older
    connectors keep working on `operations.receive`. Rewrite to `operations.receive3` when you need
    relations, columnar property lookups, or models that are too large to materialize as a `Base`
    tree.
  </Accordion>

  <Accordion title="Why does receive3 return Model instead of Base?">
    `Model` is the bundle itself: objects, path-keyed properties, and relation accessors, with
    geometry parsed only when you ask. A `Base` tree inflates every object and mesh into Python
    objects. That is why `operations.receive` is the compatibility path for the new format, not the
    default.
  </Accordion>

  <Accordion title="What happens if I forget to close Model?">
    Downloaded parquet files stay under a scratch directory until `close` runs. Always use a `with`
    block. Objects and already-loaded geometry stay in memory after close; geometry you have not
    touched yet cannot be read afterwards.
  </Accordion>

  <Accordion title="Can I keep using ServerTransport?">
    Yes for hash-era data, until that path is retired. For bundle-only versions,
    `operations.receive` still needs an authenticated `ServerTransport` so it can dispatch to
    `receive3`. Move server-facing scripts to `operations.receive3`.
  </Accordion>

  <Accordion title="Why did parameter['value'] stop working?">
    On a bundle-only receive, parameter metadata is collapsed to scalars on dotted paths. Read the
    value with `get_double` or `obj["Width"]`, not from a metadata wrapper. That is a contracted
    receive-fidelity change, not a bug in your filter.
  </Accordion>
</AccordionGroup>
