Docs /Querying /Graph queries (GQL)
Querying

Graph queries in BigQuery (GQL and CREATE PROPERTY GRAPH)

BigQuery Graph lets you define a property graph over existing tables and query it with GQL or GRAPH_TABLE. Running GQL needs an Enterprise or Enterprise Plus reservation, and so does a dry run. Here is the schema, the pattern syntax and the errors we saw.

BigQuery Graph treats a set of existing tables as a graph. You declare which tables are nodes and which are edges with CREATE PROPERTY GRAPH, and then query paths with Graph Query Language (GQL), either as a standalone GRAPH ... MATCH statement or inside SQL with GRAPH_TABLE. The graph is a logical view: no data is copied, and queries read the current table contents. Creating a graph is ordinary DDL and works on on-demand. Running a GQL query does not. Google requires an Enterprise or Enterprise Plus reservation for core graph processing, so a query on on-demand pricing or a Standard edition reservation gets an error. Graph measures and GRAPH_EXPAND, which runs SQL over a graph, still work on on-demand.

Why a reservation matters

On an on-demand project, every GQL query fails, including a dry run:

BigQuery Graph queries require a reservation with Enterprise or Enterprise Plus edition.
See https://cloud.google.com/bigquery/docs/editions-intro for more information.

We tested this against a graph with a plain dry run, and got the error for every well-formed GQL query. That has two consequences. You can’t get a byte estimate for a graph query without the reservation. And the dry run is still a syntax check: a query with a typo (RETRUN) fails earlier with a syntax error (“Expected keyword INSERT or keyword REMOVE or keyword RETURN or keyword SET but got identifier RETRUN”), while a valid query reaches the reservation error. The reservation check runs before names are resolved, so a dry run can’t tell you whether a label or property exists. Graph queries are billed on slots, as capacity compute.

Google’s release notes for 20 and 27 August 2026 say existing allowlisted users can keep using Standard edition or on-demand billing for core graph processing until 26 April 2027, and not after that. Graph measures stay available in Enterprise and Enterprise Plus and on on-demand, but not in Standard edition. The tables underneath are billed once, as ordinary storage, however many graphs you define on them.

None of the queries below is a recorded example, because we couldn’t run them. Where a result is shown, it is from Google’s documentation for its sample data, not from our run. We checked that each query parses by dry-running it against a graph of that shape.

Defining a graph

A graph needs tables for nodes and tables for edges. The sample in Google’s guide has Person and Account nodes, with PersonOwnAccount and AccountTransferAccount edge tables:

CREATE OR REPLACE PROPERTY GRAPH graph_db.FinGraph
  NODE TABLES (
    graph_db.Account,
    graph_db.Person
  )
  EDGE TABLES (
    graph_db.PersonOwnAccount
      SOURCE KEY (id) REFERENCES Person (id)
      DESTINATION KEY (account_id) REFERENCES Account (id)
      LABEL Owns,
    graph_db.AccountTransferAccount
      SOURCE KEY (id) REFERENCES Account (id)
      DESTINATION KEY (to_id) REFERENCES Account (id)
      LABEL Transfers
  );

An edge table has a source key and a destination key that reference node tables. A label names the kind of node or edge, and by default a table’s label is its name. The tables in Google’s example have primary keys declared with NOT ENFORCED, and the graph uses them as element keys.

We ran the DDL on a small graph and found that it works without a reservation. Element keys are mandatory, and the error is clear:

The edge table 'dataset.knows' does not have primary key defined;
graph element table keys must be explicitly defined

The fix is PRIMARY KEY (...) NOT ENFORCED on the table, or a KEY (col, ...) clause in the graph definition. A view can also be a node table when you give it a KEY. The graph then shows in INFORMATION_SCHEMA.PROPERTY_GRAPHS, and DROP PROPERTY GRAPH removes it without touching the tables.

Querylab.io’s editor checks CREATE PROPERTY GRAPH as you type and underlines an edge table without both SOURCE KEY and DESTINATION KEY, or a graph without NODE TABLES. Graphs you have created show in the schema tree under their dataset. See Graph queries.

Reading a graph with MATCH

A MATCH pattern draws the shape you want. Nodes are in parentheses, edges in square brackets, and -> gives the direction:

GRAPH graph_db.FinGraph
MATCH (person:Person {name: "Dana"})-[:Owns]->(account:Account)
      -[transfer:Transfers]->(account2:Account)<-[:Owns]-(person2:Person)
RETURN person.name AS owner, transfer.amount AS amount, person2.name AS transferred_to
ORDER BY person2.name;

In Google’s sample data this returns two rows: Dana to Alex for 500 and Dana to Lee for 200. The same question in SQL is a four-table join on the key columns. The advantage of the graph form is for paths of variable length. Querylab.io can convert a SELECT with joins over a graph’s node and edge tables to GRAPH_TABLE syntax and back, and with Enable Graph Schema Completions on (it is off by default) it completes labels and properties from the graph’s schema.

Paths of variable length

A quantifier repeats an edge or a group. {1, 3} means one to three Transfers edges, and the edge variable becomes an array outside the pattern:

GRAPH graph_db.FinGraph
MATCH (src:Account {id: 7})-[e:Transfers]->{1, 3}(dst:Account)
WHERE src != dst
RETURN src.id AS src_account_id, ARRAY_LENGTH(e) AS path_length, dst.id AS dst_account_id;

Google’s output has rows repeated, such as account 7 to account 16 at length 1 twice, because it returns every matching path and there are two transfers between those accounts. Each row is a path, not a pair of nodes.

Path search prefixes cut that down. ANY returns one path for each pair of nodes, ANY SHORTEST the shortest, and ANY CHEAPEST the one with the lowest total of a cost you name:

GRAPH graph_db.FinGraph
MATCH ANY SHORTEST (src:Account {id: 7})-[e:Transfers]->{1, 3}(dst:Account {id: 20})
RETURN src.id, dst.id, ARRAY_LENGTH(e) AS path_length;   -- 7, 20, 2

GRAPH graph_db.FinGraph
MATCH ANY CHEAPEST (src:Account)-[e:Transfers COST e.amount]->{1,3}(dst:Account)
LET total_cost = SUM(e.amount)
RETURN src.id, dst.id, total_cost;

SUM(e.amount) over the array e is a horizontal aggregate: it sums along one path, where SUM in a RETURN sums down rows. The prefixes go before the whole path pattern, and not inside parentheses. Use ANY or ANY SHORTEST when you only need to know whether two nodes are connected, as Google recommends: without them, the query computes every path.

Statements

A GQL query is a chain of statements: MATCH, LET to name a value, FILTER, ORDER BY, LIMIT, RETURN. NEXT starts a new stage from the previous stage’s output:

GRAPH graph_db.FinGraph
MATCH (a:Account)-[t:Transfers]->(b:Account)
WHERE t.amount > 100
RETURN a.id AS account, SUM(t.amount) AS sent
GROUP BY account
ORDER BY sent DESC
LIMIT 5;

RETURN takes its own GROUP BY (or GROUP BY ALL), as in the sample. A query can’t return a whole node or edge (RETURN p is an error). Return its properties, or wrap it in TO_JSON(p).

Graph queries inside SQL

GRAPH_TABLE runs a GQL query in a FROM clause and returns a table, so you can join it with ordinary tables or aggregate it:

SELECT owner, COUNT(*) AS accounts
FROM GRAPH_TABLE(
  graph_db.FinGraph
  MATCH (p:Person)-[:Owns]->(a:Account)
  RETURN p.name AS owner
)
GROUP BY owner;

BigQuery accepted both a trailing RETURN and the SQL/PGQ form COLUMNS (p.name AS owner) in our syntax checks. GRAPH_EXPAND('graph') returns a flattened version of simpler graphs, and failed on Google’s sample graph with “Graph must have at least one valid edge if there are multiple node tables”, because its input rules need a tree-like schema.

Writing fast graph queries

Google’s best-practice page is about keeping intermediate sets small:

  • Start from selective nodes with a property filter ((a:Account {id: 7})), because the filter makes the traversal begin from one node. This matters most with quantified patterns.
  • Write directional edges (-> or <-). The undirected form -[e]- can be slower, and the advice is to write two directional queries joined with UNION ALL instead.
  • Name every label. A pattern like (a)-[t]->(b) makes BigQuery enumerate every node and edge label that could fit.
  • Prefer one MATCH with several path patterns to several MATCH statements.
  • Cap the edges you walk from nodes with very many (high-cardinality), and sample intermediate nodes in multi-hop queries.

What else is worth knowing

  • BigQuery Graph and Spanner Graph share the same graph schema and query language. Google’s guide puts operational graph queries in Spanner and analytical ones in BigQuery, on the same model.
  • Search combines with graph: the reference says vector and full-text search can be used in graph analysis.
  • Graph results can be visualized. In Querylab.io, a result column that holds TO_JSON(n) of a node or edge adds a Mode: Graph view to the result panel that draws them. The BigQuery console has a graph view too.
  • Graph measures define aggregate properties on a graph. Google’s guide reads them with the AGG function.

Related: primary and foreign keys explains the NOT ENFORCED constraints that graphs use, and capacity pricing the editions.