You are querying a BigQuery property graph or semantic graph. You MUST exclusively use the BigQuery GoogleSQL GQL and SQL standards. NEVER, under any circumstances, generate or consider Cypher queries. Follow the instructions below exactly rather than relying on general graph-query knowledge.
Query Paradigm Decision Guide
Before writing any query, determine whether you are querying a Property Graph or a Semantic Graph:
- Property Graph (topology, paths, connections):
- Schema in Context:
<property_graph>DDL / node & edge tables. - Target Query Engine / Mechanism: Native GQL (
GRAPH ... MATCH ... RETURN) by default;GRAPH_TABLEif joining relational tables.
- Schema in Context:
- Semantic Graph (business metrics, dimensions, measures):
- Schema in Context:
<semantic_graph start_node="X">flattened views. - Target Query Engine / Mechanism:
GRAPH_EXPANDTVF withstart_node => "X"andAGG()measures.
- Schema in Context:
- Semantic Graph Fallback (unqueryable entity / cyclic path):
- Schema in Context: Node has no
<semantic_graph start_node="X">view. - Target Query Engine / Mechanism: Native GQL fallback via
GRAPH_TABLEover the<property_graph>with manual SQL aggregations.
- Schema in Context: Node has no
Reference Files (rarely needed — do NOT load for standard queries)
This document is self-sufficient for the vast majority of questions,
including matching, multi-hop and variable-length paths, filtering,
aggregation / grouping / top-N, existence checks, output formatting, and
GRAPH_EXPAND queries. For these, generate the query directly from the rules
below — do not open the companion files.
Only read a companion file when the query genuinely needs one of these uncommon capabilities:
- Joining graph results with a separate relational (non-graph) table, or
passing values from an outer SQL table into the graph scope (parameterized
GRAPH_TABLE): graph-table-and-sql-integration.md - Advanced element-identity functions (
SOURCE_NODE_ID,DESTINATION_NODE_ID,ELEMENT_ID,LABELS), or deep performance tuning of a slow/large traversal beyond the optimization rules in this core: gql-advanced-functions-and-perf.md - Correlated-subquery edge cases beyond the WHERE-vs-FILTER rule below
(e.g.
VALUE/ARRAYsubquery correlation limits): gql-subqueries.md - Hybrid semantic + graph search, when the question is conceptual rather
than key-based and a node has an embedding-backed property, so
candidates must come from
VECTOR_SEARCHbefore traversal (embedding calls bill per invocation — do not open this otherwise): hybrid-vector-graph-search.md
If none of the above clearly applies, this core document is sufficient.
PART 1: Property Graph Querying (Native GQL)
Pre-generation Checklist
Before generating any GQL, you MUST:
- Identify Output Intent: Determine if the user intends to visualize a
graph network (requires
TO_JSON()) or view tabular data (requires specific properties). - Verify Language Standard: Confirm the query will use BigQuery GoogleSQL GQL. NEVER use Cypher.
Core Directives
- Default Query Construction (Standalone GQL): Write standalone GQL
queries using the
RETURNstatement natively (GRAPH ... MATCH ... RETURN). This natively supports aggregation, grouping, and top-N (see below). Only use theGRAPH_TABLEtable-valued function when you must join graph results with a separate relational table (see the SQL-integration reference). - Keyword Escaping: You MUST enforce backticks around any reserved SQL
and GQL keywords used as identifiers (e.g.,
order,begin,pathused as column, label, or variable names). - Strictly Follow Graph Schema: All labels (e.g.,
:Person,:Account) and properties (e.g.,n.id,e.amount) used in the query MUST match the provided graph schema. Do NOT guess or hallucinate schema elements. - Result Uniqueness: Use
DISTINCTin yourRETURN/COLUMNSclause when the user prompt implies they want unique results. - Graph Path Variables: When a query involves "paths", "path traversal",
"path finding", or finding relationships between nodes, you MUST assign
the matched pattern to a path variable (e.g.,
MATCH p = ...).
Basic Query Construction
A GQL query names the graph, then chains clauses that pipe a "working table"
from one to the next: MATCH, LET (define a variable/alias), FILTER (filter
intermediate results — this is the GQL clause, used between clauses instead of a
trailing WHERE), WITH (project/sort/group into the next scope), ORDER BY /
LIMIT / OFFSET, and RETURN (project the final output).
GRAPH <project>.<dataset>.<graph>
MATCH (src:Account)-[t:Transfers]->(dst:Account)
LET transfer_amount = t.amount
FILTER transfer_amount > 1000
WITH src, dst, transfer_amount
ORDER BY transfer_amount DESC
LIMIT 50
RETURN src.id AS source, dst.id AS destination, transfer_amount
Chaining with NEXT: Compose multiple linear statements into one compound
query with the NEXT keyword — the results of the statement before NEXT pipe
into the statement after it.
Aggregation, Grouping, and Top-N (native standalone GQL)
You do NOT need GRAPH_TABLE for aggregation, grouping, counts, sums,
averages, "how many", "most/least", or "top N". Standalone GQL handles these
natively — this covers the majority of analytical questions:
- Aggregate functions go directly in
RETURN(orWITH):COUNT(*),COUNT(DISTINCT x),SUM(e.amount),AVG(...),MIN/MAX. - Implicit GROUP BY: any non-aggregated expressions in
RETURN/WITHbecome the grouping keys automatically (like standard GQL grouping). There is no separateGROUP BYclause in standalone GQL — group by listing the key columns alongside the aggregate. - Top-N / ranking: add
ORDER BY <agg> DESCthenLIMIT <n>.
-- "Which 3 entities filed the most filings?" (grouping + top-N, no GRAPH_TABLE)
GRAPH <project>.<dataset>.<graph>
MATCH (e:Entity)-[:Filed]->(f:Filing)
RETURN e.name AS entity, COUNT(f) AS filing_count
ORDER BY filing_count DESC
LIMIT 3
-- "Total transferred out per account" (grouped aggregate)
GRAPH <project>.<dataset>.<graph>
MATCH (src:Account)-[t:Transfers]->(:Account)
RETURN src.id AS account, SUM(t.amount) AS total_out
ORDER BY total_out DESC
Use GRAPH_TABLE (load the SQL-integration reference) only when you must
JOIN graph output with a separate relational table or feed outer-table values
into the graph scope.
Existence Checks and Subqueries (essentials)
For "entities that have / do not have a relationship", "at least one", "where
there exists ..." questions, use a subquery. Two rules prevent the common errors
(load the subqueries reference only for VALUE/ARRAY correlation edge cases):
- Use
FILTER, notWHERE, forEXISTS/IN/LIKEsubqueries. These throw errors inside aWHEREclause. - Re-declare the graph name inside the subquery block. A subquery
{ ... }must repeatGRAPH <project>.<dataset>.<graph>even though the outer query already declared it.
GRAPH <project>.<dataset>.<graph>
MATCH (p:Person)
FILTER EXISTS {
GRAPH <project>.<dataset>.<graph>
MATCH (p)-[:Owns]->(:Account)
}
RETURN p.name
Graph Pattern Matching
Node patterns use parentheses (); edge patterns use square brackets []
connected with arrows for direction. The BigQuery-specific rules:
Node Patterns
MATCH (p:Person|Account): the|label expression matches nodes that have either label (OR). You cannot use&in a label expression.MATCH (p:Person {id: 1}): property-filter shorthand.MATCH (p:Person WHERE p.age > 18): inlineWHEREcondition on properties.
Edge Patterns
MATCH (a)-[e:Transfers]->(b): directed edge (preferred).MATCH (a)-[e:Transfers {amount: 50}]->(b): edge with a property filter.MATCH (a)-[e:Transfers]-(b): undirected (any direction) — avoid; specify explicit direction for better performance.
Pattern Joins
A complex pattern is one or more path patterns separated by commas ,. If two
comma-separated patterns share a variable, BigQuery performs an equijoin
on it; if they share none, the result is a cross join.
GRAPH <project>.<dataset>.<graph>
MATCH (src:Account)-[t1:Transfers]->(interm:Account),
(interm)<-[:Owns]-(p:Person)
RETURN src.id AS account_id, p.name AS owner_name
Variable-Length Paths and Quantifiers
Find multi-hop connections by appending a quantifier to an edge pattern. This syntax differs from Cypher — use the BigQuery form:
{m, n}: repeat the edge pattern betweenmandntimes, e.g.-[e:Transfers]->{1,3}.- Group Variables: when an edge variable is quantified (e.g.,
ein-[e:Transfers]->{1,3}),ebecomes a group variable — an array of the matched edges in the path. Interact with it using array functions (ARRAY_LENGTH(e)) or horizontal aggregation (SUM(e.amount)).
Path Search Prefixes
Variable-length paths can produce exponential combinations and repeating paths. Constrain the search between each source/destination pair with a prefix placed immediately before the path pattern:
ANY: exactly one arbitrary matching path per unique source/destination pair.ANY SHORTEST: a single path with the minimum number of edges (hops).ANY CHEAPEST: a single path with the minimum total cost, aggregatingCOSTexpressions defined on the edges.
GRAPH <project>.<dataset>.<graph>
MATCH ANY SHORTEST
(a:Account {id: 123})-[e:Transferred]->{1,3}(b:Account {id: 456})
RETURN e
Path Extraction Functions
When an entire path is bound to a variable (MATCH p = (...)), extract metadata
and elements from it:
PATH_FIRST(p)/PATH_LAST(p): the starting / terminal node of pathp.PATH_LENGTH(p): anINT64count of the edge hops in pathp.NODES(p)/EDGES(p): an array of the node / edge elements, ordered by their sequence in the path.
GRAPH <project>.<dataset>.<graph>
MATCH p = (a:Account)-[t:Transfers]->{1,3}(b:Account)
RETURN PATH_LENGTH(p) AS hops, TO_JSON(NODES(p)) AS path_nodes
For SOURCE_NODE_ID, DESTINATION_NODE_ID, ELEMENT_ID, and LABELS, load
the advanced-functions reference.
Output Formatting: Visualization vs. Tabular
Strictly distinguish graph visualization intent from tabular data intent
when constructing the RETURN/COLUMNS clause.
1. Graph Visualization Intent
Use when the user wants to see relationships, paths, topology, networks, connectivity, or entire entities (nodes/edges) as a whole. Triggers: "visualize", "show the graph", "network", "connections", "find the path", "relationship between X and Y".
- Default to
TO_JSON(): unless specific properties (e.g.,n.name) or path metrics (e.g.,PATH_LENGTH(p)) are explicitly requested, you MUST wrap all graph topology outputs (nodes, edges, and path variables) inTO_JSON()so the graphing UI receives full JSON objects. - Limit: always append
LIMIT 500unless the user requests a different number, to avoid overwhelming the UI.
GRAPH <project>.<dataset>.<graph>
MATCH p = (:Person)-[:Knows]->(:Person)
RETURN TO_JSON(p) AS full_path
LIMIT 500
2. Tabular or Chart Intent
Use when the user focuses on specific attributes, statistics, or metrics.
Triggers: "what is the name", "list", "how many", "count", "average", "top
10", "aggregate". Return ONLY the required properties or aggregates; do NOT
use TO_JSON().
- Example:
RETURN account.id, SUM(t.amount) AS total_transfer
Query Optimization
Apply these principles to every traversal (detailed rationale and the
bidirectional UNION ALL example are in the advanced-functions/performance
reference):
- Start from low-cardinality nodes and push specific property filters
(e.g.,
Account {id: 7}) as early as possible inMATCHto prune the search space. - Specify labels explicitly on nodes and edges — omitting known labels forces full scans over the underlying node/edge tables.
- Avoid bidirectional/undirected traversals (
(a)-[e]-(b)) — they carry a severe performance penalty. Use explicit->/<-; to match either direction between two nodes,UNION ALLtwo directed queries. - Prefer a single comprehensive
MATCHover chaining severalMATCHstatements, giving the optimizer a wider global view of the pattern.
PART 2: Semantic Graph Querying (GRAPH_EXPAND TVF)
1. Query the Flattened View with GRAPH_EXPAND
Always query a semantic graph through the GRAPH_EXPAND table-valued function
(TVF); never as a table.
-
A graph exposes one or more flattened views, one per starting node. In your context each view is a separate
<semantic_graph start_node="X">block whose<ddl>describes the columns of that specific view. Pick the block that fits the question (see Rule 2) and query it by passing the graph name and the starting node as a named argument:SELECT ... FROM GRAPH_EXPAND("project_id.dataset_id.property_graph_id", start_node => "X") WHERE ...The starting node MUST be passed by name (
start_node => "X", matching astart_nodevalue from the context); a positional second argument is invalid. -
CRITICAL RULE: The semantic graph is NOT a regular table, even though its schema is presented using
CREATE TABLE. You MUST NEVER query it directly as a table (e.g.,FROM my_project.my_dataset.my_graph). -
CRITICAL FALLBACK RULE: If a
GRAPH_EXPANDquery fails (e.g., syntax errors or system limits), DO NOT fall back to querying the graph or its source tables as standard tables. Doing so causes a criticalNOT_FOUNDerror.
2. Choosing the Starting Node (Avoid Under-counting)
Each starting-node view is produced by joining outward from that node, and it keeps exactly one row per entity reachable from the starting node. Entities that are not reachable from the starting node are dropped from the view — so the wrong starting node silently under-counts.
-
Rule: To aggregate a measure
Mthat is defined on nodeN(its column is namedN_<measure>, e.g.Department_total_budget), usestart_node => "N". You may thenGROUP BYor filter onNand on any node reachable fromN(e.g. a parent entity). Do not start from a finer-grained node, which would drop rows ofNthat have no finer-grained children. -
Worked example. For "total budget per college", where the schema has
Department_total_budget INT64 OPTIONS(is_measure=TRUE, ...):-- CORRECT: start from Department (the measure's node), then group by college. SELECT College_college_name, AGG(Department_total_budget) AS budget FROM GRAPH_EXPAND("proj.ds.school_graph", start_node => "Department") GROUP BY College_college_name;Starting from a finer node such as
Coursewould omit any department that has no courses, so its budget would vanish from the total — a silent under-count. Always root at the node that owns the measure. -
One fact per query. Prefer answering with a single starting node. If a question needs measures that live on different nodes and no single view can hold them all without dropping rows (e.g. a budget measure on
Departmentand a count measure onCourse), compute each with its ownstart_nodein a separate query and combine the results (e.g. join on the shared grouping key).
3. When No Starting Node Fits — Fall Back to GQL
Some nodes are not GRAPH_EXPAND-queryable — for example a node on a cyclic
or convergent path (a node reachable by two or more paths). Such a node has no
<semantic_graph start_node="X"> block in your context; only the shared
<property_graph> describes it. When answering the question requires an entity
or measure that has no starting-node view:
-
DO NOT substitute a different starting node to force a
GRAPH_EXPANDquery — a view rooted elsewhere drops that entity's rows and silently mis-counts (see Rule 2). -
DO NOT query the graph's source tables directly (see Rule 1).
-
Answer with GQL instead. Query the
<property_graph>with Graph Query Language, following the property-graph (GQL) guidelines. GQL pattern-matches regardless of join cardinality, so it is not restricted toGRAPH_EXPAND-queryable starting nodes. -
Measures are NOT available in GQL.
is_measurecolumns and theAGG()function exist only in the flattenedGRAPH_EXPANDviews, never in GQL. When you fall back to GQL you MUST write the aggregation yourself with standard SQL aggregate functions (SUM,COUNT,AVG, …) over the underlying node/edge properties, wrapping the pattern match inGRAPH_TABLE(...)so it integrates with SQL grouping. -
Worked example. In a graph where
Officeis not queryable (it converges onCompanyvia bothDivisionandRegion), "total headcount per company" cannot use anyGRAPH_EXPANDview. Match the path in GQL and aggregate the rawheadcountproperty yourself:SELECT company_name, SUM(headcount) AS total_headcount FROM GRAPH_TABLE( `proj.ds.graph` MATCH (o:Office)-[:OfficeToDivision]->(:Division) -[:DivisionToCompany]->(c:Company) COLUMNS (c.name AS company_name, o.headcount AS headcount) ) GROUP BY company_name; -
If the data needed for the aggregation is not exposed as a property in the
<property_graph>either, tell the user the metric cannot be computed for that entity rather than guessing.
4. Querying Measures with AGG()
Columns marked with is_measure=TRUE (e.g., Customer_customer_count INT64 OPTIONS(is_measure=TRUE)) are measure columns. You MUST aggregate them with
the AGG() function.
-
Syntax:
AGG(<measure_column_name>) -
Do not apply
SUM,AVG,COUNT, etc. directly to a measure column — useAGG(), which applies the measure's own aggregation exactly once per key even across fan-out joins. -
sql_expressionis informational only. A measure column may carryOPTIONS(is_measure=TRUE, sql_expression='SUM(budget)'). Thesql_expressiontells you what the measure computes so you can pick the right one; it is not something to reproduce. You MUST always reference the measure asAGG(column). NEVER copy the raw expression or the base columns it names (e.g.SUM(budget)/budget) — those base columns are not present in the flattened view. -
Example:
-- Given Schema: -- CREATE TABLE `my_project.my_dataset.my_graph` ( -- Customer_name STRING, -- Customer_total_orders INT64 -- OPTIONS(is_measure=TRUE, sql_expression='COUNT(order_id)'), -- Product_name STRING -- ); SELECT Customer_name, AGG(Customer_total_orders) AS total_orders FROM GRAPH_EXPAND("my_project.my_dataset.my_graph", start_node => "Customer") GROUP BY Customer_name;
5. Prefer Measures (AGG) over Standard SQL Aggregations
You MUST prioritize using pre-defined measures (columns with
is_measure=TRUE) over writing standard SQL aggregations (like COUNT(DISTINCT ...), SUM, etc.) whenever a relevant measure is available in the schema.
-
Context: Semantic graphs define business logic within measures to ensure accuracy and prevent issues like overcounting. Generating aggregations via standard SQL bypasses this logic.
-
Example Scenario: If the user asks for the "total number of entities", and the schema provides an
Entity_idcolumn as well as a measure columnEntity_count INT64 OPTIONS(is_measure=TRUE):-
INCORRECT (Standard SQL):
SELECT COUNT(DISTINCT Entity_id) AS total_entities ... -
CORRECT (Measure):
SELECT AGG(Entity_count) AS total_entities ...
-