Chapter 13 — Querying the graph¶
Goal: stop reading your data model in the Fusion UI and start asking it questions in code. By the end you will have answered, with real queries against your own graph: which work orders touch the failing pump, what do they cost, and what is still open?
You have spent twelve chapters building a knowledge graph. You have never once queried it directly — every check so far has been "look at it in Fusion" or "the SDK helper returned something." That is the gap this chapter closes. Everything an application, a dashboard, or an Atlas AI agent does to your model, it does through these endpoints.
📝 [WRITE] docs/notebooks/07_query_the_graph.ipynb — this chapter runs entirely in a
notebook. Recreate it cell by cell as you read, the same way you did in
Chapter 07.
13.1 [INFO] Five endpoints, and which one you actually want¶
Everything below sits on the Data Modeling instances API. Reaching for the wrong one is the most common reason a "slow model" is actually a slow query.
| Endpoint | SDK call | Use it when |
|---|---|---|
/list |
instances.list(...) |
You want instances of one view, optionally filtered. The simplest thing that works — start here |
/byids |
instances.retrieve(...) |
You already know the exact (space, externalId) pairs |
/query |
instances.query(...) |
You need to traverse — start at a pump, walk to its work orders, then to their operations, in one round trip |
/search |
instances.search(...) |
A human typed a word and you want ranked text matches |
/aggregate |
instances.aggregate(...) |
You want a count or a statistic, not the rows. Never fetch 10,000 nodes to count them |
/sync |
instances.sync(...) |
You already hold a copy of the data and only want what changed |
⚠️ [COMMON MISTAKE] Using /query for everything because it is the most powerful. A
traversal query has a cost a flat list does not. If you are not following a
relationship, do not pay for one.
📚 [DOCS] https://docs.cognite.com/cdf/dm/dm_concepts/dm_querying
13.2 [ACTION] Connect, and count what you built¶
🟢 [ACTION] First cell — the same client bootstrap you used in Chapter 07:
import os
from cognite.client import CogniteClient
from cognite.client.config import ClientConfig
from cognite.client.credentials import OAuthClientCredentials, OAuthInteractive
def cdf_client(client_name: str = "dm-handson") -> CogniteClient:
"""Same helper as Chapter 07 section 7.3. CogniteClient() with no arguments does NOT
read .env -- the SDK dropped implicit construction in v8."""
base = os.environ.get("CDF_URL") or f"https://{os.environ['CDF_CLUSTER']}.cognitedata.com"
scopes = [s for s in os.environ.get("IDP_SCOPES", f"{base}/.default").split(",") if s]
if os.environ.get("LOGIN_FLOW", "interactive").lower() == "interactive":
creds = OAuthInteractive(authority_url=os.environ["IDP_AUTHORITY_URL"],
client_id=os.environ["IDP_CLIENT_ID"], scopes=scopes)
else:
creds = OAuthClientCredentials(token_url=os.environ["IDP_TOKEN_URL"],
client_id=os.environ["IDP_CLIENT_ID"],
client_secret=os.environ["IDP_CLIENT_SECRET"],
scopes=scopes)
return CogniteClient(ClientConfig(client_name=client_name,
project=os.environ["CDF_PROJECT"],
base_url=base, credentials=creds))
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(Path.cwd().parents[1] / ".env") # repo-root .env
client = cdf_client() # see Chapter 07 section 7.3
YOURNAME = os.environ["PARTICIPANT"] # e.g. "ALICE"
INSTANCE_SPACE = f"isp_{YOURNAME}_TRN"
EDM_SPACE = f"ssp_{YOURNAME}_TrainingCore_edm"
SDM_SPACE = f"ssp_{YOURNAME}_MaintenanceInsight_sdm"
MODEL_VERSION = "v1.0.0"
print(client.config.project, INSTANCE_SPACE)
🔧 [CHANGE] PARTICIPANT must be set in your .env, or replace os.environ[...]
with your literal name.
Now the cheapest possible question: how many assets did I create?
🟢 [ACTION]
from cognite.client.data_classes.data_modeling import ViewId
from cognite.client.data_classes.aggregations import Count
from cognite.client.data_classes import filters as flt
ASSET = ViewId("cdf_cdm", "CogniteAsset", "v1")
res = client.data_modeling.instances.aggregate(
view=ASSET,
aggregates=Count("externalId"),
filter=flt.SpaceFilter(INSTANCE_SPACE, "node"),
)
print(res)
✅ [VERIFY] You should see a count of 8 — the eight rows of
rwt_Training_TRN_Assets.Table.csv from Chapter 04.
⚡ [OPTIMIZE] Note what this did not do: it never transferred eight nodes to your
laptop. /aggregate computes server-side. At eight rows that is irrelevant; at eight
million it is the difference between a dashboard that loads and one that times out.
💡 [GOOD TO KNOW] SpaceFilter is doing real work here. Without it you would be
counting every CogniteAsset in the project — including all fifteen other
participants'. Your space is your scope.
13.3 [INFO] Why /query has three parts¶
list and aggregate take arguments. /query takes a small program, and the
shape of that program is the thing worth learning:
Query(
with_ = { ... } # 1. the result sets: WHAT to find, and how to walk between them
select = { ... } # 2. for each result set, WHICH properties to return
parameters = {} # 3. optional: values injected at run time
)
with_is a named dictionary of result set expressions. Each one is either a set of nodes or a set of edges. Crucially, a result set can startfrom_another one — that chaining is how traversal works.selectdecides what comes back. A result set that appears inwith_but not inselectis still computed (it may be a stepping stone) but its properties are not transferred. Use that deliberately.parameterslets you write the query once and run it with different values.
ℹ️ [INFO] A result set that is neither selected nor chained from is dead weight — it
still costs a little to plan. Delete it rather than leaving it commented out.
13.4 [ACTION] Your first real query — find the failing pump¶
🟢 [ACTION]
from cognite.client.data_classes.data_modeling.query import (
Query, NodeResultSetExpression, Select, SourceSelector,
)
q = Query(
with_={
"pump": NodeResultSetExpression(
filter=flt.And(
flt.SpaceFilter(INSTANCE_SPACE, "node"),
flt.Equals(["node", "externalId"], "21-PA-2001A"),
),
limit=1,
)
},
select={
"pump": Select([SourceSelector(ASSET, ["name", "description", "tags"])])
},
)
result = client.data_modeling.instances.query(q)
for node in result["pump"]:
print(node.external_id, "|", node.properties[ASSET])
✅ [VERIFY] One node, external ID 21-PA-2001A, description
Crude oil export pump A.
⚠️ [COMMON MISTAKE] Writing ASSET.as_property_ref("externalId") here. It looks
right and it fails:
Property identifier cannot be one of the reserved values:
[space, externalId, createdTime, lastUpdatedTime, deletedTime, ...]
externalId is not a view property — it belongs to the node itself, alongside
space and createdTime. Reference those with the literal two-element path
["node", "externalId"]. Use as_property_ref(...) only for properties your view
actually declares — workOrderNumber, status, assets.
13.5 [ACTION] Which work orders touch the failing pump?¶
This is the question the whole course has been building toward. Your work orders carry
assets, a direct relation pointing at the pump. You want the reverse: given the pump,
find everything pointing at it.
Your first instinct will be to traverse inwards. Try it, so you meet the error:
WORKORDER = ViewId(EDM_SPACE, "WorkOrder", MODEL_VERSION)
q = Query(
with_={
"pump": NodeResultSetExpression(
filter=flt.Equals(["node", "externalId"], "21-PA-2001A"), limit=1),
"orders": NodeResultSetExpression(
from_="pump",
through=WORKORDER.as_property_ref("assets"),
direction="inwards", limit=100),
},
select={"orders": Select([SourceSelector(WORKORDER, ["workOrderNumber"])])},
)
client.data_modeling.instances.query(q)
🚧 [LIMITS] You cannot walk a list of direct relations backwards. assets is a
list — one activity can touch several assets — and DMS does not maintain a reverse index
for list membership. Traversing outwards (order → its assets) is fine. Inwards is not.
This is the single most important structural decision in the whole chapter, so it is worth stating plainly:
| What you need | Model it as | Reverse traversal |
|---|---|---|
| A simple pointer, reverse lookups rare | Direct relation (single) | Works |
| One-to-many, and you mostly read forwards | List of direct relations | Not traversable inwards |
| Reverse navigation is a first-class operation | Edge | Works from both ends |
| Reverse navigation on a single direct relation | Direct relation + a reverse direct relation property on the target view | Works |
🟢 [ACTION] What actually works here is a filter, not a traversal — ask which work
orders contain the pump in their list:
PUMP = {"space": INSTANCE_SPACE, "externalId": "21-PA-2001A"}
orders = client.data_modeling.instances.list(
sources=WORKORDER,
space=INSTANCE_SPACE,
limit=-1,
filter=flt.ContainsAny(WORKORDER.as_property_ref("assets"), [PUMP]),
)
for n in orders:
p = n.properties[WORKORDER]
print(f" {p['workOrderNumber']:<9} {str(p['status']):<12} "
f"{p.get('actualCost')} {p.get('currency')}")
✅ [VERIFY] Exactly one order: WO-1001, IN_PROGRESS, 18500 EUR — the seal
replacement on the failing pump. WO-1002 and WO-1003 point at the separator, so they
are correctly absent.
⚠️ [COMMON MISTAKE] Using Equals instead of ContainsAny on a list property. It
fails with "Invalid value for list property". Equals compares the whole value;
ContainsAny asks whether the list contains one of your candidates.
⚡ [OPTIMIZE] This is why Chapter 03 put a btree index on the
asset direct relation. Filtering an unindexed relation is a full scan.
Walking a single direct relation backwards — the row that says "Works"¶
WorkOrder.assets is a list, so it is a dead end inwards. But
EquipmentHealthProfile.asset is a single direct relation, and that one reverses.
This is the fourth row of the table above, and the reason
Chapter 03 section 3.12 had you declare healthProfile on your Asset
view.
🟢 [ACTION] Same query shape that just failed — one property, not a list:
EHP = ViewId(SDM_SPACE, "EquipmentHealthProfile", MODEL_VERSION)
q = Query(
with_={
"pump": NodeResultSetExpression(
filter=flt.Equals(["node", "externalId"], "21-PA-2001A"), limit=1),
"profile": NodeResultSetExpression(
from_="pump",
through=EHP.as_property_ref("asset"), # the FORWARD property
direction="inwards", limit=10),
},
select={"profile": Select([SourceSelector(EHP, ["ratedPowerKw", "sealType"])])},
)
res = client.data_modeling.instances.query(q)
for n in res["profile"]:
print(n.external_id, n.properties[EHP])
✅ [VERIFY] One node — ehp_21-PA-2001A — with its rated power and seal type. No
Cannot traverse lists error, because asset is singular.
💡 [GOOD TO KNOW] Notice what through names: the forward property on the view
that does the pointing (EquipmentHealthProfile.asset), not the healthProfile
property you declared on Asset. /query walks the underlying relation directly.
🚧 [LIMITS] So what did declaring the reverse direct relation in Chapter 03 actually
buy you, if /query works without it? Be precise about this, because it is widely
misunderstood:
Raw /query traversal |
Declared reverse direct relation | |
|---|---|---|
| Works at all | yes | yes |
| Shows up in GraphQL / the data model's schema | no | yes |
| Navigable in Fusion, Canvas, Search | no | yes |
| An Atlas AI agent can discover and follow it | no | yes |
| Costs storage | no | no |
The declaration does not enable the traversal — it publishes it. Anything that reads your model rather than hand-writing queries against it (an application, a UI, an agent) can only see connections the model declares. That is the whole argument for spending a schema change on something that stores nothing.
13.6 [ACTION] The operations on that pump — and a surprise¶
Chapter 05 section 5.6 loaded work-order operations as
CogniteActivity nodes. Same shape of question, same filter:
ACTIVITY = ViewId("cdf_cdm", "CogniteActivity", "v1")
acts = client.data_modeling.instances.list(
sources=ACTIVITY, space=INSTANCE_SPACE, limit=-1,
filter=flt.ContainsAny(ACTIVITY.as_property_ref("assets"), [PUMP]))
for n in sorted(acts, key=lambda x: x.external_id):
print(" ", n.external_id)
✅ [VERIFY] You get four nodes, and one of them is WO-1001 — a work order, not
an operation.
ℹ️ [INFO] That is not a bug. Your WorkOrder view implements CogniteActivity, so
every work order is a Cognite activity. Querying the parent view returns both concepts.
Whenever you query a CDM view, you are querying everything that implements it.
🟢 [ACTION] To count operations alone, subtract the work orders:
wo_ids = {n.external_id for n in client.data_modeling.instances.list(
sources=WORKORDER, space=INSTANCE_SPACE, limit=-1)}
all_acts = client.data_modeling.instances.list(
sources=ACTIVITY, space=INSTANCE_SPACE, limit=-1)
ops = [a for a in all_acts if a.external_id not in wo_ids]
print("activities:", len(all_acts), "| work orders:", len(wo_ids), "| operations:", len(ops))
✅ [VERIFY] activities: 9 | work orders: 3 | operations: 6. Hold on to that 6 —
Chapter 14 opens with it.
13.7 [INFO] hasData, and the filter you did not write¶
Deploy a view, populate nothing, and the view shows 0. Everyone accepts that. The
confusing version is: deploy a view, populate it, and it still shows 0.
If no filter is specified, a default
hasDatafilter is applied to the views being queried. There is an implicit AND between every container the view references — the node must have data in all of them.
Your EquipmentHealthProfile view is exactly the shape that triggers this. It
implements CogniteDescribable, so it references two containers: your own
EquipmentHealthProfile, and cdf_cdm:CogniteDescribable. A node with parsed
datasheet specs but no name has data in one container, not both — so it does not
match, and the view looks empty.
🟢 [ACTION] Prove it to yourself:
from cognite.client.data_classes.data_modeling.instances import InvolvedContainers
EHP = ViewId(SDM_SPACE, "EquipmentHealthProfile", MODEL_VERSION)
print("through the view :", len(client.data_modeling.instances.list(
sources=EHP, space=INSTANCE_SPACE, limit=-1)))
print("in the registry :", client.data_modeling.instances.inspect(
nodes=(INSTANCE_SPACE, "ehp_21-PA-2001A"),
involved_containers=InvolvedContainers()))
⚠️ [COMMON MISTAKE] Calling inspect() with only nodes=. It raises
"Must pass at least one of 'involved_views' or 'involved_containers'" — you have to
tell it which of the two answers you want. InvolvedContainers() is the one that
settles a hasData argument, because containers are where data actually lives.
✅ [VERIFY] inspect() is the ground truth — it reports which containers the node
actually has data in, regardless of any view. If the node appears in inspect but not
in the view listing, you have met the hasData filter.
⚠️ [COMMON MISTAKE] "Fixing" this by putting a bare hasData filter on the view to
force the instances to appear. Do not. A standalone hasData filter is ignored by
the /inspect endpoint that Canvas and Search rely on, so you get instances that show
up in one application and vanish in another. The real fix is to populate the missing
container — which is why Chapter 10 writes name and
description alongside the specs.
📚 [DOCS] https://docs.cognite.com/cdf/dm/dm_concepts/dm_querying#hasdata-filter
13.8 [ACTION] Aggregate — the shape of the backlog¶
🟢 [ACTION] Count work orders by type:
for bucket in client.data_modeling.instances.aggregate(
view=WORKORDER,
aggregates=Count("externalId"),
group_by="orderType",
filter=flt.SpaceFilter(INSTANCE_SPACE, "node"),
):
print(bucket)
✅ [VERIFY] Two buckets — PM01 with 2, PM02 with 1.
⚠️ [COMMON MISTAKE] Grouping by status. It is the obvious thing to want, and it
fails:
Property 'status' of type enum cannot be used in group by.
Supported types are [text, text[], direct, direct[], int32, int64, float32, float64, numeric, boolean]
group_by does not accept enum properties. You modelled status as an enum in
Chapter 03 — which is the right call for data quality, because it
constrains what can be written. The cost is that you cannot group on it. Either count
each value with a filter, or model the property as text if grouping matters more than
validation. Know which trade you are making.
🟢 [ACTION] Counting one enum value with a filter instead:
open_wo = client.data_modeling.instances.list(
sources=WORKORDER, space=INSTANCE_SPACE, limit=-1,
filter=flt.Equals(WORKORDER.as_property_ref("status"), "OPEN"))
print("open:", [n.external_id for n in open_wo])
✅ [VERIFY] ['WO-1002'].
🟢 [ACTION] And a statistic rather than a count:
from cognite.client.data_classes.aggregations import Avg, Max
print(client.data_modeling.instances.aggregate(
view=WORKORDER,
aggregates=[Avg("actualCost"), Max("actualCost")],
filter=flt.SpaceFilter(INSTANCE_SPACE, "node"),
))
✅ [VERIFY] Average 30250.0, max 42000.0 — computed from WO-1001 and WO-1003 only,
because WO-1002 has no actualCost. A null is not a zero, and aggregates skip it
rather than dragging the mean down.
🚧 [LIMITS] /aggregate and /search are eventually consistent — a few seconds
can pass before a write shows up. If you write then immediately aggregate and the number
looks stale, wait and re-run before you start debugging.
13.9 [ACTION] Search — when a human typed the word¶
🟢 [ACTION]
hits = client.data_modeling.instances.search(
view=WORKORDER,
query="seal",
properties=["name", "description"],
filter=flt.SpaceFilter(INSTANCE_SPACE, "node"),
limit=10,
)
for h in hits:
print(h.external_id, "|", h.properties[WORKORDER]["workOrderNumber"])
✅ [VERIFY] WO-1001 — "Replace mechanical seal on 21-PA-2001A."
💡 [GOOD TO KNOW] search ranks by relevance; list with a Prefix filter matches
exactly. Search is for people, filters are for programs. Do not use search to build
a pipeline — the ranking is not a contract.
13.10 [ACTION] Sync — read once, then only the changes¶
A dashboard that re-reads your whole model every 30 seconds is how you turn a small
model into a support ticket. /sync hands you a cursor and then returns only what
changed since it.
🟢 [ACTION]
from cognite.client.data_classes.data_modeling.query import QuerySync
sq = QuerySync(
with_={"orders": NodeResultSetExpression(
# SpaceFilter alone would sync EVERY node in the space -- assets, files,
# operations, all of it. HasData narrows it to instances that actually
# carry WorkOrder data. This is the explicit form of the implicit filter
# from 13.7, used deliberately.
filter=flt.And(
flt.SpaceFilter(INSTANCE_SPACE, "node"),
flt.HasData(views=[WORKORDER]),
),
limit=100)},
select={"orders": Select([SourceSelector(WORKORDER, ["workOrderNumber", "status"])])},
)
first = client.data_modeling.instances.sync(sq)
print("initial:", len(first["orders"]), "orders")
sq.cursors = first.cursors # carry the cursor forward
again = client.data_modeling.instances.sync(sq)
print("since then:", len(again["orders"]), "changed")
✅ [VERIFY] The first call returns 3 orders; the second returns 0, because
nothing has changed. Edit one work order in Fusion, run the second call again, and
exactly one comes back.
💡 [GOOD TO KNOW] /sync also returns instances that have been deleted, carrying
a non-null deletedTime. That is how you keep a downstream copy honest — and it is the
mechanism Chapter 14 uses to undo an accidental delete.
🚧 [LIMITS] Sync one space at a time. Broad filters across many spaces perform
badly, and the endpoint does not support arbitrary sorting — results come back in
transaction order, not by lastUpdatedTime.
13.11 [LIMITS] What will bite you at scale¶
| Limit | Number | What to do |
|---|---|---|
| Default result set size | 100 | Set limit explicitly; you rarely want the default |
| Maximum per result set | 10,000 | Page with cursors, or narrow the filter |
| Query timeout | 408 Request Timeout |
Reduce max_distance, add filters, split the query |
| Traversal execution | Nested-loop, breadth-first | Fine to a few hundred thousand paths; fully connected graphs and loops will not finish |
| Search / aggregate freshness | Eventually consistent | Wait a few seconds after a write |
⚡ [OPTIMIZE] max_distance is the single biggest lever on a traversal. If you know
the answer is one hop away, say max_distance=1 — the planner stops instead of
exploring your whole graph breadth-first.
The space= trap — a wrong answer with no error¶
⚠️ [COMMON MISTAKE] The schema list endpoints take one space, not a list:
client.data_modeling.containers.list(space=...) # space: str | None
client.data_modeling.views.list(space=...) # space: str | None
client.data_modeling.data_models.list(space=...) # space: str | None
Hand them a list and you get no error and no warning — just the results for one of your spaces. Your model spans two:
edm, sdm = f"ssp_{YOURNAME}_TrainingCore_edm", f"ssp_{YOURNAME}_MaintenanceInsight_sdm"
# WRONG — silently returns only one space's views
views = client.data_modeling.views.list(limit=-1, space=[edm, sdm], include_global=False)
print(sorted(v.external_id for v in views)) # ['WorkOrder'] -- where did the other two go?
# RIGHT — list once, filter in Python
views = [v for v in client.data_modeling.views.list(limit=-1, include_global=False)
if v.space in {edm, sdm}]
print(sorted(v.external_id for v in views)) # ['Asset', 'EquipmentHealthProfile', 'WorkOrder']
💡 [GOOD TO KNOW] instances.list does accept a sequence of spaces — which is
exactly why this catches people. Two neighbouring APIs, two different contracts. When a
list-shaped argument gives you a suspiciously short answer, check the signature before you
believe it.
13.12 ✅ Gate¶
Do not proceed until all of these are true:
- [ ]
aggregatereturns 8 assets in your instance space - [ ] section 13.5's inward traversal fails, and you can say why a list cannot be walked backwards
- [ ] The
ContainsAnyfilter returns exactlyWO-1001 - [ ] section 13.6 gives 9 activities / 3 work orders / 6 operations, and you can explain the 9
- [ ]
inspect()in section 13.7 tells you which containers your EHP node has data in - [ ]
group_by="status"fails because it is an enum;orderTypegives two buckets - [ ] The second
synccall returns 0 changes - [ ] You can say, in one sentence each, when you would use
list,queryandaggregate
You can now ask your model anything. Next you will use exactly these tools to find what is wrong with it.