# Querying

{/* Canonical public URL: /user_guide/querying. Keep section anchors stable throughout 1.x. */}

Graph queries are normal SQL calls. The API has primitive functions for exact
control, workflow wrappers for application-friendly search, expansion, paths,
connections, and summaries, and a GQL-compatible subset for graph pattern
queries, an openCypher compatibility surface for the same supported graph
patterns, and narrow mapped writes over registered tables and edges.

| Section | Use it for |
|---|---|
| [Search](#search) and [Search Modes](#search-modes) | Source-row lookup and text matching |
| [GQL](#gql) and [openCypher Compatibility](#opencypher-compatibility) | The bounded graph-pattern profiles |
| [Business ID Lookup](#business-id-lookup) | Resolving application identifiers |
| [Traversal](#traversal) | Bounded BFS/DFS and multi-start queries |
| [Workflow API](#workflow-api) | Application-oriented find, expand, path, connection, and neighborhood calls |
| [Path Functions](#path-functions) and [Components](#components) | Paths and graph structure summaries |
| [Hydration](#hydration) and [Tenant Scope](#tenant-scope) | Source rows, visibility, and tenancy |

## Search

`graph.search()` searches registered source-table columns. It does not search
the persisted graph artifact; it builds SQL predicates against source rows and
returns verified graph coordinates.

```sql
SELECT *
FROM graph.search(
  property_key := 'name',
  property_value := 'alice',
  table_filter := 'public.users'::regclass,
  mode := 'contains',
  case_sensitive := false,
  max_rows := 100,
  row_offset := 0,
  tenant := NULL,
  hydrate := true
);
```

Return columns:

| Column | Meaning |
|---|---|
| `node_table` | Source table OID |
| `node_id` | Source primary key as text |
| `match_type` | `contains`, `exact`, `prefix`, or `token` |
| `score` | Current score value, currently `1.0` for verified matches |
| `verified` | Whether the source row verified against search criteria |
| `node` | Hydrated source row as JSONB when `hydrate = true` |
| `node_table_name` | Regclass text name |

`graph.search_nodes()` returns the same coordinate data without `node`.

Search results are verified against source rows, deduplicated by
`(node_table, node_id)`, sorted by table OID then node ID, and then paginated.
Each source-table scan is bounded to `row_offset + max_rows` candidates before
the final global merge.

## GQL

`graph.gql()` executes the documented GQL-compatible subset and returns one
JSONB object per row. Node labels resolve to registered source-table names, and
relationship types resolve to registered edge labels.

The generated [GQL 1.0 profile](./gql-profile) is the exact machine-checked
capability inventory. It deliberately does not claim full ISO GQL, openCypher,
or SQL/PGQ conformance.

```sql
SELECT row
FROM graph.gql(
  'MATCH (o:orders)-[:placed_by]->(c:customers)
   WHERE o.status = $status
   RETURN o.id AS order_id, o.status AS status, c.name AS customer
   ORDER BY customer
   LIMIT 20',
  params := '{"status":"paid"}'::jsonb,
  hydrate := true
);
```

Supported read coverage:

| Area | Supported behavior |
|---|---|
| Pattern | One node-only `MATCH` pattern, one graph pattern with two node variables and one relationship pattern, comma-separated fixed single-hop patterns with reused node variables and fixed single-hop path variables, fixed path variables such as `MATCH p=(a)-[]->(b)-[]->(c)`, or top-level single-relationship `OPTIONAL MATCH` |
| Labels and types | Node labels map to registered table names; relationship types map to registered edge labels |
| Direction | Outbound, inbound, and undirected relationship patterns |
| Variable length | Bounded variable-length relationships with an explicit maximum and minimum of at least one hop |
| Predicates | Property comparisons, null checks, membership, boolean combinations, JSONB `$parameters`, and dotted paths into registered JSONB property columns |
| Identity lookup | `id(node) = <scalar>` and unambiguous registered primary-key property equality, such as `u.id = $id`, use direct graph-coordinate lookup instead of scanning a label |
| Return values | Node values, scalar properties, JSONB list/map properties, coordinate-only single-hop relationship identities including supported multi-pattern relationship variables, fixed single-hop multi-pattern path values, bounded relationship path values, aggregate values, and JSON `null` for unmatched optional targets or relationships |
| Projection stages | `WITH` can project node variables and scalar properties into the downstream `RETURN` scope, including aliases, shadowing, and `WITH DISTINCT` row-stream deduplication |
| Deduplication | `RETURN DISTINCT`, `WITH DISTINCT`, and aggregate `DISTINCT` are supported for non-wildcard path reads, including projected rows from supported multi-pattern joins, with a bounded in-memory key set |
| Aggregates | `count`, `sum`, `avg`, `min`, `max`, and `collect` in `RETURN`; non-aggregate return items are implicit grouping keys |
| Path functions | `nodes(path)`, `relationships(path)`, and `length(path)` over named relationship patterns and single-hop wildcard path variables |
| Ordering and pagination | `ORDER BY`, `SKIP`, and `LIMIT` for single-pattern non-wildcard reads; `ORDER BY`, `SKIP`, and `LIMIT` for supported multi-pattern reads; and `SKIP`/`LIMIT` for wildcard path reads, with the hard GQL row cap still enforced; `RETURN DISTINCT` must order by returned scalar expressions |

### GQL JSONB result format

`graph.gql()` and `graph.cypher()` return graph values as ordinary
PostgreSQL `jsonb`, not as a custom graph scalar type. Each SQL result row has
one column named `row`. The value is a JSON object whose keys are the
`RETURN` aliases, or the return expression text when no alias is provided:

```json
{
  "person": {
    "_id": { "table": "users", "id": "u1" },
    "_labels": ["users"],
    "id": "u1",
    "name": "Alice"
  },
  "company_name": "Acme"
}
```

When a query returns a node value, pgGraph serializes it as a JSON object with
stable reserved keys:

```json
{
  "_id": { "table": "users", "id": "u1" },
  "_labels": ["users"],
  "id": "u1",
  "name": "Alice"
}
```

`_id.table` is the registered source-table label and `_id.id` is the source
primary-key value serialized as text. `_labels` currently contains the single
registered table label. With `hydrate := true`, the readable source row is
included as JSON alongside the reserved keys. If a required graph coordinate no
longer has a readable backing source row, the query raises a GQL execution
error instead of fabricating an empty node object. With `hydrate := false`, the
node object contains only `_id` and `_labels`.

Relationship values always include coordinate identity fields:

```json
{
  "_type": "works_at",
  "_start": { "table": "users", "id": "u1" },
  "_end": { "table": "companies", "id": "c1" }
}
```

`_type` is the registered edge label. `_start` and `_end` are endpoint graph
coordinates in the registered schema direction. For bidirectional
registrations, an inbound or reverse traversal may walk a generated reverse
adjacency row, but the returned relationship object still uses the original
registered source endpoint as `_start` and target endpoint as `_end`. When the
relationship is backed by a registered edge row table and `hydrate := true`,
returning the relationship variable also includes readable edge-row columns
that do not use reserved `_` prefixes. pgGraph hydrates those columns by the
relationship source row's registered primary key, not by endpoint lookup, so
parallel edge rows with equal endpoints keep separate properties. If the graph
cannot prove the source-row identity for a mapped relationship, hydration fails
instead of choosing an arbitrary matching edge row. With `hydrate := false`,
relationship values stay coordinate-only. Relationship property expressions
such as `r.weight` remain outside the current subset; return the whole
relationship value to receive hydrated edge-row properties.

Parallel relationship rows with the same type and endpoints remain separate
matches when the graph artifact has source-row relationship identity metadata.
The JSON shape above is intentionally stable and does not expose pgGraph's
internal relationship ID; applications should continue to use the returned
type, endpoints, and hydrated source-row properties as the public value.

Path values are JSON objects with a `_path` member:

```json
{
  "_path": {
    "nodes": [
      { "_id": { "table": "users", "id": "u1" }, "_labels": ["users"] },
      { "_id": { "table": "companies", "id": "c1" }, "_labels": ["companies"] }
    ],
    "relationships": [
      {
        "_type": "works_at",
        "_start": { "table": "users", "id": "u1" },
        "_end": { "table": "companies", "id": "c1" }
      }
    ]
  }
}
```

Fixed path variables can scan every registered edge label and return path
values. Named node variables are supported across the path. Relationship
variables are supported for single-hop path variables:

```sql
SELECT row
FROM graph.gql(
  'MATCH p=(s:users)-[r:works_at]->(e:companies)
   RETURN p, s, r, e, nodes(p) AS ns, relationships(p) AS rs, length(p) AS len',
  hydrate := false
);
```

The supported wildcard forms include outbound `p=()-[]->()`, inbound
`p=()<-[]-()`, undirected `p=()-[]-()`, and fixed multi-segment chains such as
`p=()-[]->()-[]->()`. One or more wildcard segments may use bounded variable
length, such as `p=()-[*1..3]->()-[]->()`, when those variable-length segments
do not bind segment-local node or relationship variables. A single bounded
wildcard segment may also bind names, such as `p=(s)-[r*1..3]->(e)`. Named
relationship variables on that bounded segment are path values, and named target
nodes bind to the emitted endpoint.
Variable-length wildcard paths use bounded walk semantics: nodes and
relationships may repeat within the explicit maximum hop bound, and row caps
are still enforced before projection. These shapes can use concrete node labels
or concrete relationship types as filters, including relationship type
alternation such as `p=()-[:friend|works_at]->()`. `WHERE` may read properties from
named path-node variables only when every possible concrete table for that
variable exposes the same registered property or JSONB property path, including
the same registered nested paths under a JSONB root. Wildcard path-variable
reads intentionally keep later clauses narrow: relationship properties, `WITH`,
`ORDER BY`, `RETURN DISTINCT`, aggregates, optional matches, writes,
relationship variables in multi-segment paths, and named target variables on
multi-segment variable-length boundaries are rejected with typed GQL errors.
Relationship type alternation outside wildcard path-variable reads is outside
the 1.0 profile and fails with an actionable unsupported-feature diagnostic.

The `nodes(path)` function returns the `_path.nodes` array directly,
`relationships(path)` returns the `_path.relationships` array directly, and
`length(path)` returns the relationship count as a JSON number.

Multi-pattern `MATCH` clauses can join fixed single-hop labeled patterns by
reusing node variables:

```sql
SELECT row
FROM graph.gql(
  'MATCH (u:users)-[:works_at]->(c:companies),
         (v:users)-[:works_at]->(c)
   WHERE c.name = $company
   RETURN u.name AS source, v.name AS peer, c.name AS company
   ORDER BY peer',
  params := '{"company":"Acme"}'::jsonb,
  hydrate := true
);
```

Each new node variable in a multi-pattern join needs a concrete label. A reused
node variable may omit the label or repeat the same label, and it joins by the
graph coordinate. Independent patterns produce Cartesian combinations up to the
GQL result cap. This slice supports node and node-property returns, joined
node-property `WHERE` predicates, fixed single-hop relationship variable
returns, fixed single-hop path variables, variable-length relationship variables
as path values, bounded variable-length path variable returns, path functions,
aggregates, `WITH` aliases for nodes, node properties, relationships, paths,
path functions, and supported aggregate inputs, including grouped aggregate
`WITH` projections, `ORDER BY` over joined node properties or returned scalar
aliases, projected-row `RETURN DISTINCT`, direct aggregate `WITH DISTINCT`,
later `WITH DISTINCT` over projected aggregate aliases, final aggregate
expressions over projected aggregate aliases after aggregate `WITH` boundaries,
and `SKIP`/`LIMIT`.
When `RETURN DISTINCT` is combined with `ORDER BY`, the sort keys must be
returned scalar expressions. Optional multi-pattern joins null-extend
unmatched fixed single-hop pattern expansions and return JSON `null` for
missing node, relationship, and path projections. Relationship properties,
relationship/path property aggregate inputs, and numeric aggregates over
relationship/path values remain outside the current multi-pattern subset.

Boolean predicate binding accepts at most 128 nested `AND`, `OR`, or `NOT`
levels, while inline property maps accept at most 512 entries. Queries beyond
either limit fail with a syntax error before execution, keeping PostgreSQL
backend stack and property-map work bounded even under sanitizer builds.

Scalar properties are returned as their JSON representation: strings,
numbers, booleans, arrays, objects, or JSON `null`. Unmatched node-only
`OPTIONAL MATCH` rows project JSON `null` for the node and its properties.
Unmatched relationship `OPTIONAL MATCH` targets and relationships also project
as JSON `null`. A missing key inside a registered JSONB property path projects
as JSON `null`, but it does not satisfy `IS NULL`; explicit JSON `null` does.

For SDKs and application code, graph values can be identified by their
reserved-key shape: objects with `_path` are paths, objects with `_type`,
`_start`, and `_end` are relationships, and objects with `_id` and `_labels`
are nodes. Source columns that would collide with reserved graph keys are
rejected during binding.

By default, GQL returns hydrated node objects when a query returns a node value
such as `RETURN o` or `RETURN c`; PostgreSQL ACL and RLS still decide whether
the source row can be read. To return only identifiers, project the identifier
property directly, for example `RETURN o.id AS order_id`, instead of returning
the whole node. The optional `hydrate := false` SQL argument returns compact
coordinate-only node objects, but it does not bypass PostgreSQL visibility:
pgGraph batch-checks the backing node rows and mapped relationship rows for
single-pattern reads, supported multi-pattern joins, and wildcard path outputs
with persisted relationship identities under the caller's ACL and RLS policies
before exposing coordinates or topology. Required mapped edge tables are
checked before execution, so privileges do not depend on whether the pattern
matches any rows. Unfiltered wildcard paths conservatively preflight every
mapped edge table that can supply one of the selected relationship types; use
an explicit relationship type when a narrower privilege boundary is required.
A stale, hidden, or identity-less mapped source row
raises SQLSTATE `22000` with diagnostic `PG017` rather than returning a partial
result.

Other read semantics:

- Hydrated relationship values for registered edge-row mappings include
  readable edge-row properties plus `_type`, `_start`, and `_end`.
- `OPTIONAL MATCH` null-extends unmatched source rows for node-only and
  single-relationship patterns.
- Aggregate functions ignore JSON `null` inputs for `count(expr)`, `sum`,
  `avg`, `min`, `max`, and `collect`; `count(*)` counts every input row.
- `collect` preserves non-null values in row order.
- Aggregate-only empty inputs return one row with `0`, `null`, or `[]`
  according to the aggregate.
- `DISTINCT` query forms abort if their unique-key working set exceeds the GQL
  result cap.
- Bounded relationship variables return path objects with `_path.nodes` and
  `_path.relationships`.
- Fixed path variables return the same path object shape, with each
  relationship carrying its actual registered edge label.
- Multi-pattern joins reuse node variables by graph coordinate and treat
  independent patterns as Cartesian combinations under the result cap.
- `nodes`, `relationships`, and `length` expose path components directly.

Registered JSONB source columns can be addressed with dotted property paths
such as `u.profile.plan`; returned JSONB arrays and objects keep their JSON
shape. A missing key inside a JSONB property path projects as JSON `null`, but
it does not satisfy `IS NULL`; explicit JSON `null` does.

Outside the 1.0 profile:

- Relationship property expressions such as `r.weight`.
- Multi-pattern relationship properties, relationship/path property aggregate
  inputs, and numeric aggregates over relationship/path values.

### GQL Writes

Supported write coverage is intentionally narrow. `CREATE` can insert one node
row into a registered source table or one directed relationship row into a
registered edge row table. `SET` can update one mapped property on one matched
node, `REMOVE` can clear one mapped property on one matched node, and directed
`DELETE` can remove one matched relationship backed by a registered edge row
table. `DETACH DELETE` can remove one matched node and its incident registered
edge-row relationships. `MERGE` can insert one mapped node or match one mapped
node by registered primary-key identity and run one branch update. These writes
require that the active graph was built with
`mode := 'mutable_overlay'` and `graph.mutable_enabled = on`:

```sql
SELECT row
FROM graph.gql(
  'CREATE (c:customers {id: $id, name: $name}) RETURN c',
  params := '{"id":"c-42","name":"Ada"}'::jsonb
);
```

```sql
SELECT row
FROM graph.gql(
  'MATCH (a:customers {id: $from}), (b:products {id: $to})
   CREATE (a)-[r:purchased {id: $purchase_id}]->(b)
   RETURN r',
  params := '{"from":"c-42","to":"p-9","purchase_id":"e-77"}'::jsonb
);
```

```sql
SELECT row
FROM graph.gql(
  'MATCH (c:customers {id: $id}) SET c.status = $status RETURN c.status AS status',
  params := '{"id":"c-42","status":"active"}'::jsonb
);
```

```sql
SELECT row
FROM graph.gql(
  'MATCH (c:customers {id: $id}) REMOVE c.status RETURN c.status AS status',
  params := '{"id":"c-42"}'::jsonb
);
```

```sql
SELECT row
FROM graph.gql(
  'MATCH (a:customers {id: $from})-[r:purchased]->(b:products {id: $to})
   DELETE r
   RETURN a.id AS customer_id, b.id AS product_id',
  params := '{"from":"c-42","to":"p-9"}'::jsonb
);
```

Endpoint labels and the relationship type may be omitted for relationship
`DELETE` only when the wildcard pattern resolves to exactly one static-label
registered edge-row mapping:

```sql
SELECT row
FROM graph.gql(
  'MATCH (a)-[r]->(b)
   WHERE a.id = $from AND b.id = $to
   DELETE r
   RETURN a.id AS source_id, b.id AS target_id',
  params := '{"from":"c-42","to":"p-9"}'::jsonb
);
```

Dynamic relationship label-column mappings require fully concrete relationship
types. For `CREATE`, pgGraph fills the registered label column with the
relationship type from the GQL pattern. The type must already be present in the
built graph's dynamic label registry; pgGraph does not create graph-only
relationship types.

```sql
SELECT row
FROM graph.gql(
  'MATCH (c:customers {id: $id}) DETACH DELETE c RETURN c.id AS customer_id',
  params := '{"id":"c-42"}'::jsonb
);
```

```sql
SELECT row
FROM graph.gql(
  'MERGE (c:customers {id: $id, name: $name})
   ON CREATE SET c.status = ''new''
   ON MATCH SET c.last_seen_at = $seen_at
   RETURN c.id AS customer_id, c.status AS status',
  params := '{"id":"c-42","name":"Ava","seen_at":"2026-05-31T12:00:00Z"}'::jsonb
);
```

The write is PostgreSQL-first: pgGraph inserts, updates, or deletes the mapped
source row through SPI, PostgreSQL enforces table privileges, constraints,
RLS, triggers, and MVCC, and the GQL result is built from the affected row or
matched graph coordinates.
Writes targeting a registered partitioned table use ordinary PostgreSQL DML,
so PostgreSQL chooses the child partition. This applies to node tables and to
standalone relationship tables, including relationship tables with composite
primary keys. pgGraph resolves foreign-key endpoints through their partition
roots so `CREATE`, relationship `DELETE`, and `DETACH DELETE` retain the
registered parent-table identity. A rejected constraint or user trigger aborts
before pgGraph records a transaction-local projection delta.
After the DML statement and its triggers finish, pgGraph re-reads the affected
source row. Trigger changes to ordinary returned properties therefore remain
authoritative. pgGraph rejects and rolls back a write when a trigger side effect
removes or changes the returned source identity, changes a registered node
primary key or tenant identity during `SET`, `REMOVE`, or `MERGE ON MATCH`, or
changes a registered endpoint or dynamic relationship label during relationship
`CREATE`. This prevents PostgreSQL source identity from diverging from the
transaction-local graph coordinates.
For backend-local mutable projections, concurrent mapped writes follow
PostgreSQL isolation rules. READ COMMITTED readers can observe a committed
write on their next statement; REPEATABLE READ and SERIALIZABLE readers retain
their transaction snapshot. This source/graph agreement applies to node and
relationship `CREATE`, `SET`, `REMOVE`, relationship `DELETE`, `DETACH DELETE`,
and `MERGE`, including the values returned inside the writer transaction.
Durable new-node identity and reload are supported;
the persisted isolation matrix also covers repeated builds, with each build
publishing a new base-only projection generation bound to the replacement base
artifact checksum.
For `SET`, `REMOVE`, relationship `DELETE`, and `DETACH DELETE`, pgGraph locks
the matched PostgreSQL source row at the final write boundary and re-checks
the matched GQL predicate before mutating. If another transaction changes the
source row so the predicate no longer matches, the statement fails instead of
overwriting the concurrent change. The failure uses SQLSTATE `22000` and leaves
no transaction-local graph delta. Concurrent sessions racing a relationship
`CREATE` with the same registered source identity follow the PostgreSQL unique
constraint: one insert commits and the other receives SQLSTATE `23505`.
Concurrent relationship deletes similarly produce one committed delete; a
writer whose matched source row has become stale receives SQLSTATE `22000`.
Within the same transaction, node-only `MATCH`, relationship traversal,
multi-pattern joins, wildcard path reads, and later relationship `CREATE`
statements can read newly created nodes through backend-local temporary graph
node indexes. Mapped relationship `CREATE` also records the inserted edge row's
source identity in the transaction-local overlay, so later relationship reads in
the same transaction can enforce visibility and hydrate the created edge row by
its registered primary key. `SET` updates registered non-key, non-tenant
property columns and refreshes any registered typed filter index value for that
backend. `REMOVE` sets a mapped scalar column to SQL `NULL` or removes a
registered dotted key from a JSONB property column; repeated `REMOVE` calls are
idempotent. Labels map to registered source tables, so label removal is not part
of the supported write model. `CREATE` for relationships inserts exactly one
registered edge row after matching and locking one source endpoint and one
target endpoint; the new edge is visible through the transaction-local overlay.
`DELETE` tombstones the matching graph edge by its registered source-row
primary-key identity in the current backend after PostgreSQL deletes exactly
one edge row. Parallel relationship rows with equal endpoints and type are not
removed as siblings; endpoint nodes are not deleted. `DETACH DELETE` enumerates registered
incident edge-row tables, deletes matching forward and reverse edge rows first,
then deletes the
matched node row and tombstones the node and deleted graph edges in the current
backend. If any step fails, PostgreSQL aborts the statement and the source rows
remain authoritative; pgGraph does not retain partial transaction-local
tombstones. `DETACH DELETE` rejects incident relationships that are
not backed by registered edge row tables because pgGraph has no safe generic
policy for rewriting arbitrary endpoint-table foreign keys. `MERGE` is
supported for one mapped node. The registered primary key columns are the match
identity, tenant scope participates when configured, `ON CREATE` values are
evaluated only for inserted rows, and `ON MATCH` values are evaluated only for
matched rows. pgGraph locks an existing matched row with `FOR UPDATE` only when
`ON MATCH SET` can update it; insert races use PostgreSQL
`ON CONFLICT DO NOTHING` on the registered identity, so concurrent sessions
racing the same key converge on one PostgreSQL source row. `MERGE` requires
`SELECT` and `INSERT` on the mapped source table; `UPDATE` is required only when
the statement includes `ON MATCH SET`. `CREATE`, `SET`, `REMOVE`, `DELETE`,
`DETACH DELETE`, and `MERGE` are rejected on `csr_readonly`
projections. GQL writes participate in PostgreSQL savepoints and PL
subtransactions: releasing a savepoint retains its transaction-local graph
overlay, while rolling it back restores the overlay visible before that
savepoint. Multi-row graph writes and creating new
PostgreSQL schema objects from GQL are not part of this release's GQL subset.
For bulk ingestion in 1.0, use set-based DML against the registered PostgreSQL
source tables, commit, and call `graph.apply_sync()` once. This avoids
application-level one-row call loops while retaining PostgreSQL constraints,
triggers, ACLs, RLS, MVCC, and incremental mutable-overlay sync. See the
[current limitation and workaround](/known-issues#gql-writes-accept-one-parameter-object-per-call)
and the proposed
[batched mutation design](/contributor_guide/batched-graph-mutations).

Use `graph.gql_explain(query)` to inspect parse, bind, logical, and physical
planning stages without executing the query. Node-only matches constrained by
`id(node) = <scalar>` or by an unambiguous registered primary-key property
equality explain as `NodeLookup(...)`; other node-only matches explain as
`NodeScan(...)`.

## Business ID Lookup

Use `graph.get_node()` when application code already has a registered
business identifier and needs the corresponding graph coordinate or source row:

```sql
SELECT *
FROM graph.get_node(
  graph_name := 'default',
  label := 'users',
  id := 'u1',
  hydrate := true
);
```

Return columns:

| Column | Meaning |
|---|---|
| `graph_id`, `graph_name` | Graph metadata for the named graph |
| `node_table`, `node_table_name` | Registered source table |
| `node_id` | Registered primary key as text |
| `node_idx` | Backend-local graph node index for the loaded projection |
| `node` | Hydrated source row when `hydrate = true`; `NULL` when `hydrate = false` |

`graph.get_node()` returns zero rows when the id is absent or the backing
source row is not visible to the caller. Invalid graph names or labels raise a
typed pgGraph error. The lookup enforces graph privileges and source-table
`SELECT`; source-table RLS still decides whether the row is visible.

Use `graph.get_neighbors()` to resolve the same business identifier first and
then expand one hop:

```sql
SELECT node_id, depth, node
FROM graph.get_neighbors(
  graph_name := 'default',
  label := 'users',
  id := 'u1',
  direction := 'outgoing',
  edge_types := ARRAY['friend'],
  hydrate := false
);
```

The returned columns match `graph.traverse()`. Tenant scope is applied before
neighbor expansion when tenant filtering is configured. Composite primary keys
use the same JSONB-array text encoding that registration and build use, for
example `["region-1", "customer-42"]`.

## openCypher Compatibility

`graph.cypher()` is a separate compatibility surface for openCypher-shaped text
that maps into the same pgGraph execution model as `graph.gql()`. It is not a
full openCypher-compatible database API. Supported overlapping reads and mapped writes
lower into the shared GQL IR, so labels still resolve to registered PostgreSQL
source tables, relationship types still resolve to registered edge labels, and
PostgreSQL remains authoritative for ACLs, RLS, constraints, triggers, and
MVCC.

```sql
SELECT row
FROM graph.cypher(
  'MATCH (o:orders)-[:placed_by]->(c:customers)
   RETURN o.id AS order_id, c.name AS customer
   ORDER BY customer
   LIMIT 20',
  hydrate := true
);
```

Use `graph.cypher_explain(query)` to inspect the shared plan selected for a
Cypher-compatible query. Use `graph.cypher_compatibility()` to inspect the
current compatibility matrix. Cypher-only constructs such as `CALL`, `YIELD`,
`UNWIND`, `FOREACH`, `LOAD CSV`, `START`, `UNION`, and Cypher DDL are rejected
because they do not map to pgGraph's PostgreSQL-first model.

## Search Modes

| Mode | SQL predicate shape |
|---|---|
| `contains` | Match substring with LIKE/ILIKE escaping |
| `exact` | Match normalized whole value |
| `prefix` | Match values beginning with the query string |
| `token` | Match tokenized value boundaries |

All modes validate that `property_key` is registered for the target table set.

Case-insensitive search normalizes the source expression with
`lower(<column>::text)`. Add matching expression indexes on high-volume
search columns so PostgreSQL can use the same expression shape as pgGraph's
predicate:

```sql
CREATE INDEX users_name_lower_idx
ON public.users (lower(name::text));
```

For substring-heavy `contains` search, use PostgreSQL trigram indexing on the
same normalized expression:

```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX users_name_lower_trgm_idx
ON public.users USING gin (lower(name::text) gin_trgm_ops);
```

Create these indexes on the registered property columns and tables that are
actually searched. Case-sensitive `exact` and `prefix` searches can use normal
indexes when their predicate shape matches the source column and table filter.

## Traversal

```sql
SELECT *
FROM graph.traverse(
  seed_table := 'public.users'::regclass,
  seed_id := 'u1',
  max_depth := 2,
  edge_types := ARRAY['friend'],
  direction := 'any',
  node_tables := ARRAY['public.users'::regclass],
  filter := NULL,
  tenant := NULL,
  strategy := 'bfs',
  uniqueness := 'node_global',
  include_start := true,
  hydrate := true,
  max_rows := 1000,
  row_offset := 0,
  max_nodes := COALESCE(NULLIF(current_setting('graph.max_nodes', true), '')::int, 100000),
  max_frontier := COALESCE(NULLIF(current_setting('graph.max_frontier', true), '')::int, 100000)
);
```

Return columns:

| Column | Meaning |
|---|---|
| `root_table`, `root_id` | Start node coordinate |
| `node_table`, `node_id` | Reached node coordinate |
| `depth` | Hop count from the start node |
| `path` | JSONB array of `{table, id}` path coordinates |
| `edge_path` | JSONB array of edge labels along the path |
| `node` | Hydrated source row when requested |
| `root_table_name`, `node_table_name` | Regclass text names |
| `capped` | `true` when `max_nodes` or `max_frontier` stopped expansion for that root before the frontier was naturally exhausted |

<Callout type="warning">
`capped := true` means nodes reachable within `max_depth` may be missing from
the result for that root, not just from `row_offset`/`max_rows` pagination.
Reaching `max_depth` itself is a complete, correct answer and does not set
`capped`. Raise `max_nodes`/`max_frontier` and retry, or treat the result as a
partial neighborhood rather than the full one.
</Callout>

Traversal applies graph traversal limits first, then table filters and
hydration filters, then sorts by depth, node table, and node ID for a single
root before applying `row_offset` and `max_rows`. Multi-start traversal sorts by
root table, root ID, depth, node table, and node ID before pagination.

Source-table ACLs and RLS are applied before projected topology becomes
observable. A hidden seed or target behaves as nonexistent, a hidden
intermediate blocks visible nodes behind it, and a hidden relationship row
cannot connect otherwise visible endpoints. Shortest-path selection therefore
returns the best caller-visible path, or no path. Component sizes, statistics,
path counts, and aggregates are computed from caller-visible topology rather
than post-filtering unrestricted results. Hydrated and coordinate-only forms
use the same visibility boundary.

Visibility is evaluated against source rows at query time, while topology still
comes from the synchronized projection. Newly committed rows or relationships
do not become traversable until trigger sync is applied, the selected freshness
mode catches up, or the graph is rebuilt. The visible result is the intersection
of that projected snapshot and the caller's current source-row visibility.

`uniqueness := 'node_global'` returns each reached node at most once across the
merged multi-start result set. When more than one root reaches the same node,
the row that sorts first by the multi-start ordering is kept. Use
`uniqueness := 'node_per_root'` when the same reached node should be returned
once for each root that found it. For a single start node, both modes produce
the same node-level result set.

Traversal filters are structured JSON predicates built with helper functions
such as `graph.eq()`, `graph.not_in()`, `graph.between()`, and
`graph.is_null()`. Text filters can also use `graph.contains_text()` and
`graph.prefix_text()` for substring and prefix checks. Registered filter
columns are pushed down into the graph filter index; predicates on other
registered node columns are checked against hydrated source rows.

```sql
SELECT *
FROM graph.traverse(
  seed_table := 'public.users'::regclass,
  seed_id := 'u1',
  filter := graph.all(ARRAY[
    graph.in('status', '["active", "trial"]'::jsonb),
    graph.is_not_null('created_at')
  ])
);
```

### Direction

| Direction | Behavior |
|---|---|
| `any` | Uses the forward CSR store; bidirectional registrations create both traversable edge directions |
| `out` | Uses the forward CSR store |
| `in` | Uses the reverse CSR store |

For unidirectional edges, `direction := 'out'` follows the registered source to
target direction and `direction := 'in'` follows the reverse store. For
bidirectional edges, traversal can use either stored adjacency, but relationship
values returned by GQL remain oriented to the registered schema source and
target.

### Strategy

| Strategy | Behavior |
|---|---|
| `bfs` | Breadth-first traversal; default |
| `dfs` | Depth-first traversal |
| `weighted` | Rejected for traversal; use `graph.weighted_shortest_path()` |

### Multi-Start Traversal

Parallel-array overload:

```sql
SELECT *
FROM graph.traverse(
  ARRAY['public.users'::regclass, 'public.users'::regclass],
  ARRAY['u1'::text, 'u2'::text],
  max_depth := 1,
  hydrate := false
);
```

Composite overload:

```sql
SELECT *
FROM graph.traverse(
  ARRAY[
    graph.node_ref('public.users'::regclass, 'u1'),
    graph.node_ref('public.users'::regclass, 'u2')
  ]::graph.node_ref[],
  max_depth := 1,
  hydrate := false
);
```

## Workflow API

Workflow functions wrap the primitives with smaller call shapes and stable
application-friendly output.

### `graph.find()`

```sql
SELECT *
FROM graph.find(
  'name',
  'Alice',
  table_name := 'public.users'::regclass,
  mode := 'exact',
  max_rows := 20
);
```

Adds `rank` and returns hydrated rows by default.

### `graph.expand()`

Expand from a known node:

```sql
SELECT *
FROM graph.expand(
  'public.users'::regclass,
  'u1',
  max_depth := 2,
  target_table := 'public.users'::regclass,
  where_node := graph.gt('age', 40),
  include_start := false
);
```

Adds `rank`, `readable_path`, and `truncated`. `truncated` is `true` when
`max_rows` capped the returned page or when the underlying traversal's
`max_nodes`/`max_frontier` circuit breaker stopped expansion early, so the
neighborhood searched may be incomplete beyond `max_depth`. The same
`truncated` semantics apply to `graph.find_related()` and
`graph.neighborhood()` below.

### `graph.find_related()`

Search for a seed, traverse from each verified match, filter, hydrate, and page:

```sql
SELECT *
FROM graph.find_related(
  property_key := 'name',
  property_value := 'Alice',
  source_table := 'public.users'::regclass,
  max_depth := 2,
  target_table := 'public.users'::regclass,
  where_node := graph.gte('age', 40),
  include_counts := true
);
```

Use `candidate_limit` to bound work before final pagination.

### `graph.path()`

Readable shortest path wrapper:

```sql
SELECT *
FROM graph.path(
  'public.users'::regclass,
  'u1',
  'public.users'::regclass,
  'u2',
  max_depth := 20
);
```

### `graph.connection()`

Search both endpoints and return the first reachable path:

```sql
SELECT *
FROM graph.connection(
  source_key := 'name',
  source_value := 'Alice',
  target_key := 'name',
  target_value := 'Bob',
  source_table := 'public.users'::regclass,
  target_table := 'public.users'::regclass,
  source_k := 3,
  target_k := 3,
  max_depth := 6
);
```

### `graph.neighborhood()`

Summarize reachable nodes by depth and table before hydration:

```sql
SELECT *
FROM graph.neighborhood(
  'name',
  'Alice',
  source_table := 'public.users'::regclass,
  max_depth := 4,
  sample_k := 5,
  node_limit := 10000
);
```

## Path Functions

Unweighted shortest path:

```sql
SELECT *
FROM graph.shortest_path(
  'public.users'::regclass,
  'u1',
  'public.users'::regclass,
  'u9',
  max_depth := 20,
  hydrate := true
);
```

Restrict an unweighted path to one or more relationship types by using the
seven-argument overload. The type array is last so legacy calls that pass an
untyped `NULL` for `max_depth` remain unambiguous:

```sql
SELECT *
FROM graph.shortest_path(
  'public.users'::regclass,
  'u1',
  'public.users'::regclass,
  'u9',
  20,
  true,
  ARRAY['friend', 'member_of']::text[]
);
```

Weighted shortest path:

```sql
SELECT *
FROM graph.weighted_shortest_path(
  'public.places'::regclass,
  'a',
  'public.places'::regclass,
  'd'
);
```

The weighted overload accepts the type array as its fifth argument:

```sql
SELECT *
FROM graph.weighted_shortest_path(
  'public.places'::regclass,
  'a',
  'public.places'::regclass,
  'd',
  ARRAY['road', 'rail']::text[]
);
```

An empty array matches no relationships. Unknown relationship labels fail with
SQLSTATE `22023` and diagnostic `PG005`. Type filters compose with caller RLS:
hidden nodes and relationship rows remain unavailable even when their label is
included.

Unweighted shortest path includes pending trigger-sync edge overlays after
`graph.apply_sync()`. Weighted shortest path requires merged weighted CSR data;
when edge overlays are pending, it fails with SQLSTATE `0A000` and diagnostic
`PG018` until `graph.vacuum()` or
`graph.maintenance()` rebuilds the weighted graph.

Weighted path return columns:

| Column | Type | Meaning |
|---|---|---|
| `step` | `INTEGER` | Zero-based path step |
| `node_table` | `OID` | Node table OID for this step |
| `node_table_name` | `TEXT` | Readable table name for this step |
| `node_id` | `TEXT` | Node primary key for this step |
| `edge_label` | `TEXT` | Incoming edge label, NULL for step `0` |
| `edge_weight` | `BIGINT` | Incoming edge weight, NULL for step `0` |
| `step_cost` | `BIGINT` | Cumulative cost at this step |
| `total_cost` | `BIGINT` | Total path cost repeated on each row |

## Components

Connected components are global O(V+E) algorithms and require graph-admin
privileges.

```sql
SELECT * FROM graph.component_stats();
SELECT * FROM graph.components(max_rows := 20);
SELECT * FROM graph.largest_component(max_rows := 100, hydrate := false);
SELECT * FROM graph.component(123, max_rows := 100, hydrate := true);
SELECT * FROM graph.isolated_nodes(max_rows := 100);
```

<Callout type="warning">
Component functions touch the whole graph. Run them on a read replica or during
controlled windows for large production graphs.
</Callout>

Component identifiers and pagination are derived after caller visibility is
applied. Two callers with different RLS policies can therefore observe
different component membership, sizes, rankings, isolated nodes, and page
contents from the same physical projection.

## Hydration

Hydration converts graph coordinates back to source rows as JSONB. It is useful
for application responses but costs extra SQL work.

Use `hydrate := false` when:

| Situation | Reason |
|---|---|
| Counting rows | Avoid unnecessary source-row reads |
| Joining manually | Coordinates are enough |
| Large traversals | Keep query memory and SPI work bounded |
| Internal composition | Later steps decide what to hydrate |

## Tenant Scope

If any registered table has `tenant_column`, tenant-scoped queries use the
tenant value to filter graph membership. Pass `tenant` explicitly:

```sql
SELECT *
FROM graph.traverse(
  'public.users'::regclass,
  'u1',
  tenant := 'acct_123'
);
```

Or configure a session GUC fallback:

```sql
SET graph.tenant_setting = 'app.tenant_id';
SET app.tenant_id = 'acct_123';
```

When `graph.enforce_tenant_scope = true`, tenanted graph queries without
explicit or session tenant fail.
