Each activity on the left is self-contained: it illustrates one BigQuery concept, brings its own setup, and can be done on its own. Work them in any order, or do just the ones you need.
Cost
Every activity shows its cost under the title — 🟢 No cost, 💰 Low cost, or 💸 Higher cost.
Sample data
Many of the activities use data in the roi-bq-demos.bq_demo dataset
Other activities rely on additional sample tables that need to be derived from the tables in the bq_demo dataset. You can use the directions found here to generate the required tables.
There is a much smaller dataset which will work with the demos: roi-bq-demos.bq_demo_small. Queries will be much faster with this dataset, so aren't as effective at illustrating query speed benefits.
💸Higher costscans up to 100B rows
Setting up
Log into your lab-provided project with your lab user account.
Run the -- view up-to-date table query. This should show you the total number of orders per month, with values for Jan. and Feb.
Running a time travel query, finding results from specific time
Copy the -- view table with only initial load query from the SQL file in Github and paste it into your BigQuery editor.
Replace target with the time travel target value that was output in your Cloud Shell window. It should look like this: 1654541783
Run the query. This should show you the total number of orders per month, but from the table as it looked after its initial load when only January data was stored.
Restore a previous version of the table to a new table
Copy the -- create restoration table query from the SQL file in Github and paste it into your BigQuery editor.
Replace target with the time travel target value that was output in your Cloud Shell window. It should look like this: 1654541783
Run the query. Once the table has been created, check out the Details and Preview data. Verify that only January orders exist.
💰Low cost
Setting up
Make sure you have the BigQuery console open.
Working with cache
Run this query:
select * from `roi-bq-demos.bq_demo.customer` where cust_state = "AK"
Look at JOB INFORMATION
Note the query duration and bytes processed
Click on the link for the Destination table. This temp table has the results of your query and lasts for 24 hours
Read and run the populate arrays explicitly query, and review the results
Read and run the populate arrays using array_agg query, and review the results
Array lengths
In the Explorer pane, drill down to bigquery-public-data.github_repos.commits.
Read and run the report array length query, and review the results.
Write a query to find all the rows where the length of the difference array is equal to five (hint, you can use the find by array length query from the git file).
UNNEST
Review and run the select basic array from array query. Review the results.
Click on the JSON tab in the Query results area, and note the structure of the results. There's an array with a single row object. That object has a single column, which is a four-element array.
Review and run the select table from array query. Review the results.
Click on the JSON tab in the Query results area, and note the structure of the results. There are four rows, each with one column which is a scalar value. Unnest flattens the array into a table.
Review and run the calculate average of array query.
Review and run the basic correlated cross join query. The CTE at the top is just creating an initial arrays table with two rows and two columns. Review the standard results output and the JSON output.
Review and run the comma correlated cross join query.
It turns out, that if you're doing a correlated cross join, you don't even need to explicitly do the UNNEST, it's done for you implicitly. Review and run the comma implicit unnest query.
Querying on array contents
Review and run the -- find row where num_array contains 2 - take 1 query. Review the results.
Edit the query, modifying the CTE so that the first row in the arrays table is [2, 2, 3, 4]. Re-run the query and note the results.
Edit the query to search for rows that have an 8 in the array. Run the query and review the results? Are they correct?
Run the -- find row where num_array contains 2 - take 2 query.
Run the -- find row where num_array contains 2 - take 3 query.
All three of these queries return the same results, but there are differences in performance on large datasets. Let's explore that.
Run each of the find commits that... queries, noting the time taken for each to run.
💰Low cost
Setting up
Make sure you have the BigQuery console open.
Review the table_nested_partitioned table in your instructor's project
Find the total sales for the 8754 zip code by running the find sales for 6 months in 8754 from nested query (replace with the instructor's project name). Note the duration and data processed.
Run the find for 6 months in 8754 from nested/partitioned query (replace with the instructor's project name) and note the duration and data processed.
Run the find for 6 months in 8754 from nested/partitioned/clustered query (replace with the instructor's project name) and note the duration and data processed.
💰Low cost
Setting up
Make sure you have the BigQuery console open.
Review the roi-bq-demos.bq_demo.order_mv materialized view
Copy the -- heavyweight query query into the BigQuery editor and run it. It will take a couple of minutes; while it runs, don't touch anything, just watch the elapsed time tick.
When the query completes, click on Job Information and note the Duration (elapsed time) and Bytes processed.
Click on Execution Details and note the Slot time consumed, Bytes shuffled, and Bytes spilled to disk.
Write down your five numbers: elapsed time, slot time consumed, bytes processed, bytes shuffled, bytes spilled to disk.
Work vs. wait
Divide your slot time consumed by your elapsed time (make sure both are in seconds). The result is roughly the number of slots that were working on your query at once.
Run a lightweight query
Copy the -- lightweight query query into the BigQuery editor and run it.
Collect the same five numbers from Job Information and Execution Details.
Copy the -- scan and filter query query into the BigQuery editor and run it.
Open Execution Details and note the Bytes shuffled.
Move a little
Copy the -- aggregation query query into the BigQuery editor and run it.
Check Bytes shuffled again. It's bigger than zero this time, but still small.
Move a lot
If you completed the five numbers activity, don't pay for that query twice: click View all jobs at the bottom of the page.
Select the most recent job that shows a Slot contention insight, click View in editor, and skip to step 10.
If you don't have that job, copy the -- join query query into the BigQuery editor and run it. It will take a couple of minutes.
Open Execution Details and note the Bytes shuffled. Compare it to the first two queries.
Find the guilty stage
With the join query's results open, click on Execution Graph.
Click through the stages and find the stage that wrote the most data to shuffle. Which table was it reading?
The check-engine light
For each of your queries, check Bytes spilled to disk in Execution Details. The first two queries should show 0 B. The join query should show terabytes spilled.
Review the -- sales joined to price history query query. It builds two tables with GENERATE_ARRAY (so it bills zero bytes): 100,000 sales of 100 products, and a price history holding every price each product has ever had. It joins them on prod_code to compute total revenue.
Copy the query into the BigQuery editor and run it.
Look at the result: 100,000,000 rows came out of a 100,000 row sales table, and total revenue is in the billions for products that cost under $100.
Click on Execution Graph and find the flagged stage. Click it and review the notice: This stage has join(s) that produce significantly more rows than it consumes. The rows from one join side match multiple rows from the other. The console has diagnosed the fan-out for you; BigQuery calls this insight a high-cardinality join.
Fix what the insight named
Review and run the -- sales joined to current price query query. Before joining, it reduces the price history to one row per product: the price currently in effect.
Check the result: 100,000 rows (one per sale), a sane revenue total, and no flag in the Execution Graph.
Read the diagnosis on a real query
If you completed the shuffle and spill activity: click View all jobs at the bottom of the page, select the join query job with the Slot contention insight, and click View in editor.
Open its Execution Graph and review the performance insights shown for that job.
Copy the -- find the queries worth optimizing query into the BigQuery editor and run it. Every job's stats land in INFORMATION_SCHEMA.JOBS in real time, with 180 days of history.
Review the results. The jobs you ran in the earlier activities should be near the top.
Edit the query to sort by total_bytes_processed instead, and re-run it. Does the same job top both lists?
Hunt flagged queries at project scale
Copy the -- find jobs the console flagged query into the BigQuery editor and run it. If you completed the performance insights activity, your high-cardinality join job appears, with the insight recorded in the job's metadata.
Explore further
The github file has more examples: top spenders by user, table compression ratios, full table scans, and tables nobody has queried in 90 days. Pick one, read it, and run it.
🟢No costestimates only — do not run the SELECT * query
Paste the -- unpartitioned table query query into the editor and read the estimate: about 112 GB to count one month's customers.
Replace it with the -- partitioned table query query. Same filter, but the table is partitioned by order_date: about 9.3 GB. Eleven months of partitions were never opened.
Replace it with the -- broken partition filter query query. It asks for exactly the same rows, but the estimate is back to 112 GB.
Cluster pruning
Paste the -- unclustered table query query and read the estimate: about 70 GB.
Replace it with the -- clustered table query query. Same data, but the table is clustered on cust_state: about 1.4 GB. Within the storage, blocks that can't contain AK are skipped.
Replace it with the -- broken cluster filter query query: UPPER(cust_state) turns block skipping off, and the estimate returns to about 70 GB.
Review the -- broadcast join query query: 3.75B line items joined to a 10,000 row product table.
Copy the query into the BigQuery editor and run it. It will take a minute or so.
Open Execution Graph and look at the shape of the plan. One data source feeds its own Input stage (the 10K records edge tells you it's the product table). The other data source (the line item table) has no input stage at all: its arrow runs straight into the Join+ stage, because the 3.75B row scan happens inside the join. That is also why Join+ holds nearly all the query's slot time.
Click the join stage and review the step list. The + in Join+ means extra steps were fused into the stage: this one READs, JOINs, and pre-AGGREGATEs in a single pass, which is why it outputs millions of partial results rather than billions of joined rows.
Find the JOIN step in that list: it reads EACH WITH ALL.
Note the Bytes shuffled in Execution Details.
When both sides are big
Review the -- hash join query query: 375M orders joined to 7.5M customers.
Copy the query into the BigQuery editor and run it.
Open Execution Graph: this time both tables get input stages feeding the join.
Click the join stage and find the JOIN step: it reads EACH WITH EACH.
Compare Bytes shuffled to the broadcast query. The hash join shuffled dozens of times more, on far less data.
Review the -- join at raw grain query query. The question is "orders per state", and the join exists only to look up each customer's state. (This is the same query the broadcast and hash activity used for its hash join; if you just ran it there, you can reuse that job's stats via View all jobs.)
Run it, then note the Slot time consumed and Bytes shuffled in Execution Details.
In the Execution Graph, click the join stage and note how many rows arrived at it: all 375M order rows, plus the customers.
Aggregate to the question's grain, then join
Review the -- aggregate first query query. Before the join, it collapses the orders to one row per customer: COUNT(*) grouped by cust_id. The count then survives the join as SUM(n).
Run it and compare the results to the first query: identical.
Compare Slot time consumed and Bytes shuffled: roughly 4x less slot time and 9x less shuffle. Check the join stage in the Execution Graph: about 15M rows arrived instead of 382M.
Review the -- join before declaring anything query query. It asks for order numbers on a single day. Look at the SELECT list: every column comes from the order table. The customer table is joined, but nothing from it is selected.
Run the query and note the Slot time consumed in Execution Details.
Open the Execution Graph. There is a Join stage, and about 12M customer rows arrive at it, purely to confirm that each order has a customer.
Tell the optimizer what you know
Review the -- declare the primary key query and -- declare the foreign key query queries. These declare that cust_id uniquely identifies a customer, and that every order's cust_id points at one.
Note the NOT ENFORCED on both. It is required, and it means exactly what it says: BigQuery will never check the claim.
Run both statements against your own copies of the tables in the class dataset, replacing your_project. They finish almost instantly and bill nothing: no data is read or rewritten, only metadata is updated.
Run the -- check what you declared query query to see the two constraints listed.
Run the same query again
Run the -- join before declaring anything query query a second time, unchanged, against your declared tables.
Compare the results to the first run: identical rows.
Open the Execution Graph and compare it to the first run. The Join stage is gone entirely, and the customer table is never read.
Compare Slot time consumed: roughly a quarter less, on a query whose text you never touched.
What a false promise costs you
Review the -- build a table pair that breaks the promise query query. Employee 3 sits in department 99, which does not exist. Run it to create the two small tables.
Run the -- the honest answer query query. It returns employees 1 and 2. Employee 3 is dropped, correctly, because an inner join has nothing to match it to.
Run the -- declare the false foreign key query query. It succeeds. Nothing was validated, and no warning was given.
Run the -- the honest answer query query again, completely unchanged.
Read the results: employee 3 is now included, in a join to a department that does not exist.
Run the -- First query - exact query in one editor tab. This query will find the number of unique article titles in the 106B row wikipedia table
Open a 2nd editor tab, and run the -- Second query - approx query. This will approximate the number of unique article titles in the 106B row wikipedia table.
Compare results
Calculate the % difference in query execution time between the two queries
Calculate the % difference in reported number of unique titles
🟢No costbatch loads are free
Setting up
Make sure you have the BigQuery console open.
Make sure your project has a dataset named class (you created one in the exploring datasets activity).
Loading with the console
In the Explorer panel, click the three-dot menu next to your class dataset and select Create table.
Set Create table from to Google Cloud Storage.
In the file field, enter jwd-gcp-demos/ingest_demo/daily/usage_2026-07-20.csv (no gs:// prefix).
Confirm the File format is CSV.
Name the table usage.
In the Schema section, check Auto detect.
Click Create table and wait for the load to finish.
Click the new usage table and open its Details tab: Number of rows is 1,000,000.
Check the Schema tab: minutes came out INTEGER and event_date came out DATE. Autodetect chose well here.
Appending with the CLI
Tuesday's file has arrived. Open the Cloud Shell and load it into the same table:
Back in the console, refresh the Details tab: 2,000,000 rows. Loads append by default; --replace would have truncated the table first.
Click View all jobs and look at the two most recent jobs: both are Load jobs, identical in kind. The console form and the bq load command are two front ends for the same thing.
🟢No costbatch loads are free
Setting up
Make sure you have the BigQuery console open.
Open the Cloud Shell.
Make sure your project has a dataset named class (you created one in the exploring datasets activity).
The load fails. Read the error: Unable to parse. Autodetect sampled the start of the file, saw only numbers in minutes, chose INTEGER, and then row 90,000 said N/A.
This load succeeds. Check what autodetect decided:
bq show --schema class.daily_usage
Note the type of the minutes column: STRING. Tuesday's file is small enough that the N/A fell inside autodetect's sample, so it picked the only type that fits everything.
In the BigQuery editor, try to use the data:
SELECT SUM(minutes) AS total_minutes FROM class.daily_usage
The query fails: no version of SUM accepts a STRING.
Pin the schema, name the marker
Look at the schema file:
cat daily_usage_schema.json
Reload Monday's file, pinning the schema and telling BigQuery what the vendor means by N/A:
Review the -- load monday query query. It re-does the console load from the basic loading activity, as a SQL statement: the column list pins the schema the way --schema did, and the FROM FILES options mirror the bq load flags.
Run it in the query editor. It finishes in a few seconds and reports that it created the usage_sql table.
Review the -- load tuesday query query: no column list this time, because the table now exists. LOAD DATA INTO appends; LOAD DATA OVERWRITE would replace.
Run it.
Run the -- verify query query: each of the two dates has 1,000,000 meetings.
🟢No coststreaming bills per GiB; these rows are under the free tier
Review the script in the browser tab you opened. It connects to the table's default stream, wraps the connection in a JSONWriter, then appends one JSON row per second for 30 seconds, as if meetings were ending live.
Watch rows arrive
Run the script:
node storage_write_example.js
While it prints appended once a second, switch to the BigQuery editor and run this a few times:
SELECT COUNT(*) AS so_far FROM class.live_usage
The count climbs while the script runs. A row is queryable about a second after the append; there is no waiting for a batch.
When the script finishes, look at the last few events:
SELECT * FROM class.live_usage ORDER BY event_ts DESC LIMIT 5
💸Higher costscans large tables
Setting up
Make sure you have the BigQuery console open.
Querying the partitioned table
Write and run a query to find all the March 2018 orders from the instructor's nested/repeated table, projecting all the columns except the customer email and phone number, and storing the results in a derived table in your class dataset.
Write and run a query to sum sales for AK customers orders in March 2018, using the full nested/repeated table. Note the query duration and bytes processed.
Write and run a query to sum sales for AK customers orders in March 2018, using the derived table. Note the query duration and bytes processed.
Schedule the query to run daily at 12.01am, overwriting the previous contents of the derived table.