• Home
  • /
  • Blog
  • /
  • What is Pipe Query Syntax in BigQuery
кавер статті 17 блог

What is Pipe Query Syntax in BigQuery


According to the documentation, Pipe Query Syntax (hereinafter referred to as Pipe) is an extension of the SQL query language that is simpler and more concise than standard SQL syntax. Pipe supports the same operations as standard SQL syntax while enhancing certain aspects of functionality and usability. Before introducing Pipe, Google conducted research and provided a detailed explanation of it here. The following screenshot is taken from this research. It clearly illustrates the complex execution logic of a standard SQL query and how the same query appears in a more linear and logical form using Pipe.

17.1

If you're anything like me, the previous information probably didn’t impress you much...

I consider myself quite a skeptical person who dislikes change. So the first thing that came to my mind when I saw the initial mentions of Pipe was: "Why reinvent the wheel? SQL is already the simplest programming language."

Additionally, I’m someone who likes to have a well-founded opinion. So, almost immediately after Pipe became publicly available, I decided to dive into it—just to prove to myself that Google had come up with something useless. My skepticism lasted about an hour. Unexpectedly, I realized that this syntax struck a chord with me.

But as always, let’s go step by step:

Structure and Purpose of the Article

I won’t go over the documentation. Instead, I’ll share examples of queries of varying complexity based on raw GA4 data, written in two formats—standard SQL and Pipe. Using these examples, we’ll analyze the differences so that you can decide for yourself which is better: the new syntax or the eternal classic. Though perhaps eternal should be in quotation marks...

Query 1 – Top 10 User Sources

Let’s start with something simple—identifying the top 10 sources that bring the most users to your website.

SQLPipe

-- select
SELECT


-- first source and first acquisition channel
traffic_source.source,
traffic_source.medium,


-- count the number of users
COUNT(DISTINCT user_pseudo_id) AS users


-- from GA4 export data
FROM bigqueryfortests.analytics_11111111.events_20*


-- group users by source and channel

GROUP BY 1, 2


-- then sort by the number of users in descending order
ORDER BY users DESC


-- display the top 10 results
LIMIT 10

-- from GA4 export data

FROM bigqueryfortests.analytics_11111111.events_20*


-- count the number of users
|>AGGREGATE
COUNT(DISTINCT user_pseudo_id) AS users

-- group by first source and acquisition channel
GROUP BY
traffic_source.source,
traffic_source.medium

-- then sort by the number of users in descending order
|>ORDER BY users DESC

-- display the top 10 results
|>LIMIT 10

I intentionally added a "human-readable translation" in the comments for each line of the query. Of course, the syntax is different, but visually, the "profit" isn't immediately obvious.

You might think: "I already know SQL well, so why should I spend time learning Pipe?" I had the same thought.

But try reading only the comments and ask yourself: "Which one is easier to follow?"

Overview of Key Operators

You may have noticed — or maybe not — that the previous Pipe query does not include the SELECT operator. In SQL, there are two mandatory operators: SELECT and FROM. In Pipe, only one is required — FROM. Essentially, SELECT * FROM table in Pipe is simply written as FROM table.

In this context, SELECT has lost its mandatory status and is now on the same level as other operators.

Here are some of the most commonly used operators in Pipe:

  • |>AGGREGATE – Handles aggregation functions, which were previously performed in SELECT. It is used together with GROUP BY to define the parameters for aggregation.
  • |>SELECT – Used to list the columns to be retrieved from a table. It can also rename existing columns and create new ones using simple non-aggregated expressions, e.g.: x * y AS z
    However, I don't recommend adding new columns in this operator since there is a dedicated one for that. The only additional use case for SELECT, apart from its primary function, is renaming a few columns—though RENAME exists for that purpose as well. If extensive renaming is needed, it's better to use the appropriate operator.
  • |>EXTEND – Used to add new columns based on existing ones. Yes, you could add them in SELECT, but the major advantage of Pipe lies in its readability. Using operators according to their logical purpose allows you to quickly understand the data processing flow—where columns are merely selected and where new ones are created. If everything were done "the SQL way" in SELECT, it would quickly make the query harder to read.
  • |>SET – Unlike the previous function, it does not add new columns but modifies existing ones. It works similarly to: SELECT * REPLACE (column * 2 AS column)
    However, the syntax is different and more logical: column_name = expression: |>SET column = column * 2
  • |>WHERE – Combines the functionality of WHERE, HAVING, and QUALIFY. Now, you don’t need to decide which of these three operators to use for filtering—if you need to filter something, |>WHERE does the job.
  • |>JOIN – Works exactly the same as in standard SQL—it joins tables.
  • |>UNION – Functions just like in traditional SQL, appending data from the second table to the first. By adding BY NAME, for example: UNION ALL BY NAME. The union will be performed based on column names, regardless of their order in the tables. This significantly reduces the likelihood of errors during the merging process, effectively bringing it down to zero.

By the way, the UNION ALL BY NAME option was also added to standard SQL around the same time Pipe became publicly available. You can find more details here.

But these are not all the operators. You can find the full list here. As you can see, there aren’t too many of them, and they all have logical names. This means that when reading a Pipe query, you can already anticipate what kind of processing will occur in the next block—of course, as long as you use the commands as intended. :)

You may have noticed that operators begin with the |> symbol. This marks the start of the next processing step. These can be added indefinitely, eliminating the need to create numerous subqueries and come up with names like "raw", "prep", "final", "super_final", "last_final", "the_very_last_final_I_promise", or "final2".

The |> symbol is not added before FROM, as it marks the beginning of a query rather than a step. It is also not used before GROUP BY, as that is part of the AGGREGATE operator.

Query 2 – Most Popular Pages on the Website

That’s it for the theory. If you were already familiar with SQL, it should take you roughly 1–2 hours to grasp the principles of Pipe and practice using it.

Now, let's look at the following query, which will show us the 10 most visited pages.

SQLPipe

-- select

SELECT


-- page titles

(SELECT value.string_value FROM UNNEST(event_params) WHERE KEY = 'page_title') AS page_title,


-- count the number of rows,

COUNT(*) AS pageviews,


-- count the number of users,

COUNT(DISTINCT user_pseudo_id) AS users,


-- calculate the average number of page views per user and round to 2 decimal places

ROUND(COUNT(*) / COUNT(DISTINCT user_pseudo_id), 2) AS pageviews_per_user


-- from GA4 export data

FROM
bigqueryfortests.analytics_11111111.events_20*


-- filter where the event name is 'page_view'

WHERE event_name = 'page_view'


-- group calculations by page titles

GROUP BY 1


-- sort by the number of page views in descending order

ORDER BY 2 DESC


-- limit the result to the top 10 entries

LIMIT 10

-- from GA4 export data

FROM bigqueryfortests.analytics_11111111.events_20*


-- select only 'page_view' events

|>WHERE event_name = 'page_view'


-- count the number of rows

|>AGGREGATE
COUNT(*) AS pageviews,


-- count the number of users

COUNT(DISTINCT user_pseudo_id) AS users


-- group by page titles

GROUP BY
(SELECT value.string_value FROM UNNEST(event_params) WHERE KEY = 'page_title') AS page_title


-- add the average number of page views per user, rounded to 2 decimal places

|>EXTEND
ROUND(pageviews / users, 2) AS pageviews_per_user


-- sort by the number of page views in descending order

|>ORDER BY pageviews DESC


-- limit the result to the top 10 entries

|>LIMIT 10

In this example, it’s clear that standard SQL requires extra "work." We need to jump to the WHERE clause to understand why COUNT(*) is labeled as pageviews. Additionally, to calculate the rate, we have to repeat the formulas for counting page views and users.

With Pipe, you follow the logical processing sequence step by step, and in later stages, you can directly use column names that were already defined earlier.

Query 3 – Open E-commerce Funnel with Breakdown by User Browsers

The following query will help you determine whether there are potential technical issues in the funnel for a specific browser.

SQLPipe

-- сreate a subquery

WITH events AS (


-- select

SELECT


-- user browser data
device.web_info.browser,


-- count the total number of users

COUNT(DISTINCT user_pseudo_id) AS users,


-- count the number of users who viewed a product

COUNT(DISTINCT IF(event_name = 'view_item', user_pseudo_id, NULL)) AS view_item,


-- count the number of users who added a product to the cart

COUNT(DISTINCT IF(event_name = 'add_to_cart', user_pseudo_id, NULL)) AS add_to_cart,


-- count the number of users who proceeded to checkout

COUNT(DISTINCT IF(event_name = 'begin_checkout', user_pseudo_id, NULL)) AS begin_checkout,


-- count the number of users who completed a purchase

COUNT(DISTINCT IF(event_name = 'purchase', user_pseudo_id, NULL)) AS purchase


-- from GA4 export data

FROM bigqueryfortests.analytics_11111111.events_20*


-- group results by browser

GROUP BY 1
)


-- now, retrieve all the aggregated data

SELECT
*,


-- Calculate the percentage of users who viewed a product out of all users, -- rounding to two decimal places and converting to a percentage

ROUND(SAFE_DIVIDE(view_item, users) * 100, 2) || '%' AS view_item_rate,


-- calculate the percentage of users who added a product to the cart out of those who viewed a product

ROUND(SAFE_DIVIDE(add_to_cart, view_item) * 100, 2) || '%' AS add_to_cart_rate,


-- calculate the percentage of users who proceeded to checkout out of those who added a product to the cart

ROUND(SAFE_DIVIDE(begin_checkout, add_to_cart) * 100, 2) || '%' AS begin_checkout_rate,


-- calculate the percentage of users who completed a purchase out of those who proceeded to checkout

ROUND(SAFE_DIVIDE(purchase, begin_checkout) * 100, 2) || '%' AS purchase_rate


-- use the data from the subquery

FROM events


-- sort results by the number of users in descending order

ORDER BY users DESC

-- from GA4 export data

FROM bigqueryfortests.analytics_11111111.events_20*
|>AGGREGATE


-- count the total number of users

COUNT(DISTINCT user_pseudo_id) AS users,


-- count the number of users who viewed a product

COUNT(DISTINCT IF(event_name = 'view_item', user_pseudo_id, NULL)) AS view_item,


-- count the number of users who added a product to the cart

COUNT(DISTINCT IF(event_name = 'add_to_cart', user_pseudo_id, NULL)) AS add_to_cart,


-- count the number of users who proceeded to checkout

COUNT(DISTINCT IF(event_name = 'begin_checkout', user_pseudo_id, NULL)) AS begin_checkout,


-- count the number of users who completed a purchase

COUNT(DISTINCT IF(event_name = 'purchase', user_pseudo_id, NULL)) AS purchase


-- group by user browsers

GROUP BY
device.web_info.browser


-- add additional calculations

|>EXTEND


-- percentage of users who viewed a product out of all users, rounded to two decimal places

ROUND(SAFE_DIVIDE(view_item, users) * 100, 2) || '%' AS view_item_rate,


-- percentage of users who added a product to the cart out of those who viewed it

ROUND(SAFE_DIVIDE(add_to_cart, view_item) * 100, 2) || '%' AS add_to_cart_rate,


-- percentage of users who proceeded to checkout out of those who added a product to the cart

ROUND(SAFE_DIVIDE(begin_checkout, add_to_cart) * 100, 2) || '%' AS begin_checkout_rate,


-- percentage of users who completed a purchase out of those who proceeded to checkout

ROUND(SAFE_DIVIDE(purchase, begin_checkout) * 100, 2) || '%' AS purchase_rate


-- sort results by the number of users in descending order
|>ORDER BY users desc

And when queries become a bit longer, another small profit becomes evident—queries in Pipe are generally shorter. While not always the case, in the vast majority of situations, they tend to be more concise. Based on my tests, Pipe queries are about 15–20% shorter, and in some cases—especially when processing data from a single table without joins—they can be up to 40% shorter.

For large queries, this is a significant advantage, as you maintain a clear processing logic and spend less time jumping between different subqueries.

Query 4 – GA4 Traffic Acquisition Report

Finally, let's look at an example query that retrieves data from what is likely the most popular report in GA4—Traffic Acquisition.

SQLPipe

-- сreate a subquery
WITH prep AS (


-- select
SELECT


-- primary Channel Group
session_traffic_source_last_click.cross_channel_campaign.primary_channel_group,


-- session ID
CONCAT(user_pseudo_id, (SELECT value.int_value FROM UNNEST(event_params) WHERE KEY = 'ga_session_id')) AS session_id,


-- determine if the session was engaged
MAX(
COALESCE((
SELECT value.int_value FROM UNNEST(event_params) WHERE KEY = 'session_engaged'),
CAST((SELECT value.string_value FROM UNNEST(event_params) WHERE KEY = 'session_engaged') AS int64))) AS session_engaged,


-- sum total engagement time in seconds
SUM((SELECT value.int_value FROM UNNEST(event_params) WHERE KEY = 'engagement_time_msec')) / 1000 AS engagement_time_sec,


-- count total events
COUNT(*) AS events,


-- count conversions
COUNTIF(event_name = 'purchase') AS conversions,


-- sum total revenue
SUM(ecommerce.purchase_revenue) AS total_revenue


-- from GA4 export data
FROM `bigqueryfortests.analytics_11111111.events_20*`


-- group results by Primary Channel Group and Session ID
GROUP BY 1, 2
)

SELECT


-- now, select Primary Channel Group
primary_channel_group,


-- count total sessions
COUNT(*) AS sessions,


-- count engaged sessions
SUM(session_engaged) AS engaged_sessions,


-- calculate engagement rate
ROUND(SUM(session_engaged) / COUNT(*) * 100, 2) || '%' AS engagement_rate,


-- calculate average engagement time per session in seconds
ROUND(SUM(engagement_time_sec) / COUNT(*), 2) AS avg_engagement_time_per_session_in_sec,


-- calculate average number of events per session
ROUND(SUM(events) / COUNT(*), 2) AS events_per_session,


-- count total events
SUM(events) AS event_count,


-- count total conversions
SUM(conversions) AS conversions,


-- calculate session conversion rate
ROUND(COUNTIF(conversions >= 1) / COUNT(*) * 100, 2) || '%' AS session_conversion_rate,


-- sum total revenue
ROUND(SUM(total_revenue), 2) AS total_revenue


-- from the subquery
FROM prep

-- group results by Primary Channel Group
GROUP BY 1


-- sort results by session count in descending order
ORDER BY 2 desc

-- from GA4 export data

FROM `bigqueryfortests.analytics_11111111.events_20*`
|>SELECT


-- take Primary Channel Group
session_traffic_source_last_click.cross_channel_campaign.primary_channel_group,


-- session ID

CONCAT(user_pseudo_id, (SELECT value.int_value FROM UNNEST(event_params) WHERE KEY = 'ga_session_id')) AS session_id,


-- engagement data as an integer

(SELECT value.int_value FROM UNNEST(event_params) WHERE KEY = 'session_engaged') AS session_engaged_int,


-- engagement data as a string

(SELECT value.string_value FROM UNNEST(event_params) WHERE KEY = 'session_engaged') AS session_engaged_string,


-- engagement time

(SELECT value.int_value FROM UNNEST(event_params) WHERE KEY = 'engagement_time_msec') AS engagement_time_msec,


-- event name
event_name,


-- revenue
ecommerce.purchase_revenue


-- add a column to determine session engagement based on both parameters

|>EXTEND
COALESCE(session_engaged_int, CAST(session_engaged_string AS int64)) AS session_engaged
|>AGGREGATE


-- determine whether the session was engaged

MAX(session_engaged) AS session_engaged,


-- calculate total engagement time in seconds

SUM(engagement_time_msec) / 1000 AS engagement_time_sec,


-- count events

COUNT(*) AS events,


-- count conversions

COUNTIF(event_name = 'purchase') AS conversions,


-- sum total revenue

SUM(purchase_revenue) AS total_revenue


-- group by Primary Channel Group and Session ID

GROUP BY
primary_channel_group,
session_id


-- now, calculate
|>AGGREGATE


-- sessions
COUNT(*) AS sessions,


-- engaged sessions

SUM(session_engaged) AS engaged_sessions,


-- engagement rate

ROUND(SUM(session_engaged) / COUNT(*) * 100, 2) || '%' AS engagement_rate,


-- average engagement time

ROUND(SUM(engagement_time_sec) / COUNT(*), 2) AS avg_engagement_time_per_session_in_sec,


-- average events number per session

ROUND(SUM(events) / COUNT(*), 2) AS events_per_session,


-- total events

SUM(events) AS event_count,


-- total conversions

SUM(conversions) AS conversions,


-- session conversion rate

ROUND(COUNTIF(conversions >= 1) / COUNT(*) * 100, 2) || '%' AS session_conversion_rate,


-- sum total revenue

ROUND(SUM(total_revenue), 2) AS total_revenue


-- for each Primary Channel Group

GROUP BY
primary_channel_group


-- sort results by the number of sessions in descending order

|>ORDER BY sessions desc

As I mentioned earlier, Pipe queries are not always shorter, but they maintain a sequential and linear structure that is significantly easier to comprehend compared to standard SQL.

You may have noticed that in this example, for simplicity, I used the primary_channel_group field from the session_traffic_source_last_click.cross_channel_campaign object. This field is relatively new and is supposed to reflect traffic channel values from the GA4 interface, where sources are determined based on the last known source. However, I have many concerns about the GA4 interface—it is, to put it mildly, not perfect.

Previously, I conducted research on other fields within this object—specifically session_traffic_source_last_click.manual_campaign, which appeared slightly earlier—and compared them with the GA4 interface. The results for some projects were quite surprising. You can find the link to that research here.

A different approach is to use raw, unprocessed Google data instead of relying on predefined values. The true foundation for determining traffic sources lies in page_location and page_referrer. While processing logic based on these fields is undoubtedly more complex, it can help fix interface bugs and customize data to meet specific business needs.

At one point, Max and I hosted a webinar, where we thoroughly analyzed how GA4 determines sources in the interface and shared a script based on page_location and page_referrer. You can purchase access to the recording and script here.

And if everything written above sounds like "very interesting, but totally unclear" to you, then your best bet is to join the BigQuery for Marketing course. There, the sensei instructor, with his expert teaching skills, explains complex topics related to working with raw data in BigQuery in an easy-to-understand manner.

Final Thoughts

I hope that through these examples, I was able to introduce you to the principles of Pipe and highlight the differences between it and standard SQL.

I'm certainly not trying to persuade you to "join the dark side." I firmly believe that learning the classic SQL syntax should come first, as it is the most widely used. With some variations, SQL is applied everywhere data processing is needed. However, these "variations" do not affect the query execution plan, which remains consistent across different dialects.

And here comes the "but." Pipe has a significant advantage—it is simpler and more logical. This makes it easier for more specialists to start working with raw data, as learning and understanding Pipe takes less time.

For those already working with data, the simplicity and sequential nature of this syntax improves comprehension of processing stages, which in turn reduces the likelihood of errors when writing complex query logic.

To summarize my impression of Pipe, I can confidently say that Google didn’t come up with nonsense! :)


Loading comments…