# 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. Source: https://docs.querylab.io/bigquery/graph-queries/ Updated: 2026-10-27 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: ```text 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: ```sql 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: ```text 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. A few rules we checked with dry runs and real `CREATE PROPERTY GRAPH` statements (DDL works without a reservation): - **Keys are column names.** `KEY (meta.k)` for a field inside a struct is a syntax error: `Expected ")" but got "."`. Use a top-level column. - **One table can be both a node table and an edge table, with an alias.** Listing `graph_db.Person` under `NODE TABLES` and again under `EDGE TABLES` fails with "The edge table '...' is defined more than once; use a unique name". Give the second use an alias (`graph_db.Person AS person_edges KEY (id) ...`) and it is accepted. - **Views work as node tables, with a `KEY`.** BigQuery also accepts a graph whose node table is an `INFORMATION_SCHEMA` view, such as `dataset.INFORMATION_SCHEMA.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](https://docs.querylab.io/writing-sql/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: ```sql 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. A label expression can match more than one label. `|` is "or", so `(n:Person|Account)` matches both kinds of node, and an edge pattern such as `-[:Reads|Wrote]->` follows either kind of edge. The same name used twice in a pattern is the same element: in `(a:Account)-[:Transfers]->(mid:Account)-[:Transfers]->(a:Account)`, both `a` are one node, so the query finds accounts that send money to another account and get money back. Google's reference shows this query returning accounts 16 and 20 on its sample data. A different name, like `a2`, may or may not be the same node. ### 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: ```sql 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: ```sql 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: ```sql 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: ```sql 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](https://docs.querylab.io/bigquery/primary-and-foreign-keys/) explains the `NOT ENFORCED` constraints that graphs use, and [capacity pricing](https://docs.querylab.io/bigquery/on-demand-vs-capacity-pricing/) the editions. Open the playground at https://app.querylab.io?playground=true&utm_source=docs&utm_medium=concepts to run it. It is the real app on public BigQuery datasets and needs no sign-in. ## Sources Based on the BigQuery documentation by Google, CC BY 4.0, rewritten with examples checked against BigQuery: - [Introduction to BigQuery Graph](https://cloud.google.com/bigquery/docs/graph-overview) - [Create and query a graph](https://cloud.google.com/bigquery/docs/graph-create) - [Graph query overview](https://cloud.google.com/bigquery/docs/graph-query-overview) - [Graph query best practices](https://cloud.google.com/bigquery/docs/graph-query-best-practices) - [Graphs within SQL](https://cloud.google.com/bigquery/docs/reference/standard-sql/graph-sql-queries)