AlpinaShop has spent three modules generating data without ever looking at it. Orders pile up in alpinashop-pedidos, visits sit in the load balancer logs, images are served from the CDN and shopping carts are abandoned in Firestore. Lucía, the analyst, has a notebook full of questions nobody has answered: which backpack sells best in Catalonia, whether the autumn campaign moved the needle, why 30 % of shopping carts stall at the shipping step, what an order is worth on average and how that number has evolved over two years.
Most people's instinctive reaction to that problem is to open a connection to the production database and start writing SELECT. In 02-03 we already avoided the worst of it by creating the read replica alpinashop-pedidos-replica-informes, so that reports would not compete with purchases. But that replica is a sticking plaster. A query that walks two years of order lines to group by category and month will still take minutes and will still be awkward to write, because PostgreSQL is not built for that. PostgreSQL is built to answer "give me order 48213" in a millisecond, not to answer "give me the average of every order over the last two years grouped by six dimensions".
In this lesson you will understand why those two questions need different machines, you will create the alpinashop_analitica dataset in alpinashop-datos, you will load the order history into it, you will write the SQL that genuinely answers Lucía's questions and — this is what separates someone who knows how to use BigQuery from someone who gets a surprise invoice — you will learn exactly how you pay and how you avoid overpaying.
Contents
- Why an analytical warehouse is not "a bigger database"
- How BigQuery works inside: columns, separation and Dremel
- Cloud SQL versus BigQuery: the table you need to internalise
- Structure: project, dataset, table and view
- Creating the
alpinashop_analiticadataset - Data types,
STRUCTandARRAY: denormalisation as a virtue - Creating AlpinaShop's tables
- Loading data: from Cloud Storage, in streaming and without loading at all
- Bringing the history over from Cloud SQL with federated queries
- Business SQL: Lucía's questions, answered
- The cost model and how not to bankrupt yourself
- Partitioning and clustering: the before and after, measured
- Materialized views, result cache and quotas
- Logical versus physical storage
- Access control: dataset, table, column and row
INFORMATION_SCHEMA: auditing who spends and on what- BigQuery ML, mentioned and deferred
- Why an analytical warehouse is not "a bigger database"
There are two families of workload over data, and confusing them is the most expensive conceptual mistake in this profession.
OLTP (online transaction processing) is what alpinashop-pedidos does. Many very small, concurrent operations that read and write a handful of rows identified by primary key, with strict transactional guarantees. "Insert this order", "update the stock of SKU MOCH-40L-AZ", "give me user 8821's shopping cart".
OLAP (online analytical processing) is what Lucía wants. Few, very large queries that read millions of rows, touch few columns of each one, aggregate, group and sort, and write nothing.
The difference is not one of size, it is one of shape. And that shape determines how the bytes are best stored on disk.
Picture the lineas_pedido table with 8 million rows and these columns: pedido_id, sku, nombre_producto, categoria, cantidad, precio_unitario, descuento, iva, fecha, pais_envio, metodo_pago, email_cliente, notas.
- PostgreSQL stores the data by rows: every field of line 1, then every field of line 2, and so on. It is perfect for "give me the whole of line 1", because it is contiguous on disk.
- Lucía's question, by contrast, is
SELECT categoria, SUM(cantidad * precio_unitario) FROM lineas_pedido GROUP BY categoria. It needs only three of the thirteen columns. But to read those three, PostgreSQL has to drag all thirteen off the disk, because they are interleaved. It reads ten times more bytes than it uses.
BigQuery stores the data by columns: all the categoria values together, all the cantidad values together. The query above reads exactly three blocks of disk and never touches the other ten. And since each block holds values of the same type that are very repetitive — categoria will have a dozen distinct values across eight million rows — it compresses brutally well.
ROW storage (OLTP) [p1|MOCH-40L|Backpack 40L|mochilas|2|89.90|...] [p1|CRAM-12P|Crampons|...] [p2|...] └──────────── to read "categoria" you must traverse everything ────────────┘ COLUMN storage (OLAP) pedido_id: [p1, p1, p2, p2, p3, ...] sku: [MOCH-40L, CRAM-12P, ...] categoria: [mochilas, mochilas, mochilas, crampones, ...] <-- compresses to almost nothing cantidad: [2, 1, 1, 3, ...]
From that follow the two rules that govern everything else in BigQuery:
- Reading few columns is cheap; reading all of them is expensive. That is why
SELECT *is, literally, the fastest way to spend money. - Filtering on a column does not avoid reading it. If you filter with
WHERE pais_envio = 'ES', BigQuery has to read all of thepais_enviocolumn to know which rows match. The only way not to read data is for it to be partitioned or clustered, which we will see in section 12.
- How BigQuery works inside: columns, separation and Dremel
Three architectural decisions explain why BigQuery behaves the way it does.
Its own columnar storage (Capacitor). The data is kept in a compressed columnar format on Google's distributed file system (Colossus), replicated automatically. You never see files, you do not manage indexes, you do not run VACUUM and you do not define tablespaces. There is nothing to administer.
Complete separation of storage and compute. This is the most important one and the one that changes your mindset. In Cloud SQL, disk and CPU live on the same machine: if you need more CPU you also pay for more machine, and if the database is idle the machine is still switched on and billing. In BigQuery there is no machine.
- Storage is billed per GB per month held. It always exists.
- Compute is billed per query executed (or per reserved capacity). If nobody queries, compute costs zero.
The immediate consequence for AlpinaShop: you can have 2 TB of history stored and, if Lucía goes on holiday, pay only for the storage — a few euros — without shutting anything down. And the other way round: a single query can mobilise hundreds of machines for 8 seconds and then hand them back. That elasticity is impossible in a server-based model.
Distributed execution (Dremel engine). When you launch a query, the planner breaks it down into an execution tree. The leaves of the tree — the workers — read fragments of columns in parallel, each does its share of the aggregation, and the intermediate results travel up the tree, mixing in an in-memory shuffle layer until the final result comes out.
flowchart TD
Q["Lucia SQL query<br/>SUM sales GROUP BY category"]
P["Planner<br/>execution tree"]
S["Shuffle layer<br/>in memory"]
W1["Worker 1<br/>reads columnar fragment"]
W2["Worker 2"]
W3["Worker 3"]
Wn["... Worker N"]
C["Colossus<br/>columnar storage"]
R["Result"]
Q --> P
P --> W1 & W2 & W3 & Wn
C --> W1 & W2 & W3 & Wn
W1 & W2 & W3 & Wn --> S
S --> R
The unit of that compute is called a slot: a slot is, roughly, a slice of CPU with its associated memory. A query uses as many slots as the planner decides and as are available. We will come back to slots in section 11, because they are the key to the capacity pricing model.
- Cloud SQL versus BigQuery: the table you need to internalise
| Dimension | Cloud SQL (alpinashop-pedidos) |
BigQuery (alpinashop_analitica) |
|---|---|---|
| Purpose | OLTP: transactions, the shop running | OLAP: analysis, reports, dashboards |
| Storage | By rows | By columns, compressed |
| Typical unit of work | Read/write 1-100 rows | Read 10⁶-10⁹ rows, write 0 |
| Query latency | 1-50 ms | 1-30 s (seconds, not milliseconds) |
| Target concurrency | Thousands of simultaneous connections | Dozens of simultaneous queries |
| Writing | INSERT/UPDATE row by row, constantly |
Batch or streaming load; occasional and expensive UPDATE |
| ACID transactions | Yes, it is its reason for existing | Limited multi-statement transactions; not its job |
| Indexes | You define them (B-tree, GIN…) | They do not exist; there is partitioning and clustering |
| Foreign keys | Yes, with referential integrity | Declarable as not enforced; it does not validate them |
| Scaling | Vertical (more CPU/RAM) + replicas | Automatic and invisible |
| Cost model | Per hour of instance switched on | Per byte queried (or reserved slots) + GB stored |
| Cost if nobody uses it | The instance's cost, in full | Storage only |
| Recommended modelling | Normalised (3NF) | Denormalised, with STRUCT and ARRAY |
The right way to read this table is not "BigQuery is better". It is: they are complementary tools and AlpinaShop needs both. The shop will carry on buying against Cloud SQL. Lucía will analyse against BigQuery. The job of this lesson and the ones that follow is to build the pipe that carries the data from the first to the second.
- Structure: project, dataset, table and view
BigQuery's hierarchy rests on the resource hierarchy you already know from 01-04:
Organization alpinashop.example
└── Project alpinashop-datos <-- analytics lives here
└── Dataset alpinashop_analitica <-- container with location and permissions
├── Table pedidos
├── Table lineas_pedido
├── Table productos
├── Table visitas
├── View v_ventas_mensuales
└── Routine (UDF, stored procedure)Four points of precision that avoid problems later on:
- The project is the billing unit. The queries Lucía launches are charged to the project where they run, not to the project where the table lives. That allows marketing, for instance, to query data in
alpinashop-datoswhile paying from its own project. For AlpinaShop, everything runs and is billed inalpinashop-datos, which already carries itscentro-costelabel. - The dataset is the unit of organisation and of permissions. It is where roles are granted, and it is what gets shared.
- The dataset's location is immutable. It is decided at creation time and cannot be changed: to move it you have to create another one and copy the data. We will choose
europe-west1, consistent with the rest of AlpinaShop's infrastructure and with the GDPR requirement to keep European customers' personal data in the EU. - You cannot
JOINacross datasets in different locations. If someone createsalpinashop_marketinginus-central1tomorrow, they will not be able to cross it withalpinashop_analitica. It is the most common irreversible design mistake in BigQuery. Fix the location as a team standard from day one.
A table's full name is project.dataset.table, and in standard SQL it is written between backticks when the project contains hyphens:
A note on naming: dataset and table identifiers accept letters, numbers and underscores, but not hyphens. That is why the project is alpinashop-datos (with a hyphen) and the dataset is alpinashop_analitica (with an underscore).
- Creating the
alpinashop_analitica dataset
alpinashop_analitica datasetWe will work with the bq tool, which comes bundled with the gcloud CLI and with Cloud Shell since 01-06.
gcloud config set project alpinashop-datos
# Create the analytics dataset
bq --location=europe-west1 mk \
--dataset \
--description="AlpinaShop analytical warehouse: orders, catalogue and browsing" \
--default_table_expiration=0 \
--label=entorno:produccion \
--label=equipo:datos \
--label=centro-coste:analitica \
alpinashop-datos:alpinashop_analiticaOption by option:
--location=europe-west1: the immutable location. It goes before themksubcommand, not after; that is a common mistake.--dataset: says that the resource to create is a dataset and not a table.--default_table_expiration=0: no automatic expiry. If you put2592000(30 days in seconds), every table created here would self-destruct after 30 days. It is extremely useful in a test dataset and catastrophic in production, so it is worth being explicit.- The three labels replicate the
entorno/equipo/centro-costescheme AlpinaShop settled on in 01-04, so that the analytics cost can be isolated in the billing report.
It is also worth creating a working dataset with an expiry, so that the temporary tables from analyses do not pile up forever:
bq --location=europe-west1 mk --dataset \
--description="Temporary working area; tables expire after 7 days" \
--default_table_expiration=604800 \
--label=entorno:desarrollo --label=equipo:datos \
alpinashop-datos:alpinashop_scratchVerification:
bq ls --format=prettyjson --datasets alpinashop-datos
bq show --format=prettyjson alpinashop-datos:alpinashop_analiticaIn the output of bq show, look at "location": "europe-west1". If it says US, you have created the dataset in the wrong place: delete it now, before putting data in it, because afterwards there is no cheap way back.
# Only if you got the location wrong and the dataset is empty
bq rm -r -f -d alpinashop-datos:alpinashop_analitica
- Data types,
STRUCT and ARRAY: denormalisation as a virtue
STRUCT and ARRAY: denormalisation as a virtueBigQuery's scalar types are few and deliberately strict:
| Type | Use at AlpinaShop | Note |
|---|---|---|
STRING |
sku, categoria, pais_envio |
UTF-8, no declared maximum length |
INT64 |
cantidad, pedido_id |
64-bit integer; there is no INT of other sizes |
NUMERIC |
precio_unitario, total_pedido |
Exact decimal, 38 digits, 9 decimal places: the type for money |
FLOAT64 |
Approximate metrics, ratios | Floating point: never for amounts |
BOOL |
es_regalo |
|
DATE |
fecha_pedido |
No time |
TIMESTAMP |
momento_evento |
Absolute instant in UTC |
DATETIME |
Civil dates with no time zone | Less common; prefer TIMESTAMP |
GEOGRAPHY |
Geospatial analysis | Points, lines, polygons |
JSON |
Semi-structured payloads | Queryable with native operators |
BYTES |
Binary | Rare in analytics |
A rule that saves grief: amounts go in NUMERIC, never in FLOAT64. With FLOAT64, adding €1.10 and €2.20 can give 3.3000000000000003, and that phantom cent will show up in a board report at the worst possible moment.
And now the part that sets modelling in BigQuery apart from classic relational modelling: nested types.
ARRAY<T>: an ordered list of values of typeTinside a single cell.STRUCT<...>: a record with named fields inside a single cell.- They combine:
ARRAY<STRUCT<sku STRING, cantidad INT64, precio NUMERIC>>is "a list of order lines inside the order's row".
In PostgreSQL, an order with three lines is four rows spread across two tables joined by a foreign key. In BigQuery it can be a single row that contains its three lines inside it.
Why is that better here? Because in a distributed engine, a JOIN between two large tables forces data to move between machines through the shuffle layer, and that is what is expensive. If the lines already travel glued to their order, the JOIN disappears: it becomes an UNNEST, which is a local operation inside each worker, with no network in between.
-- Example of a single row with a nested structure
SELECT
'PED-2026-0042' AS pedido_id,
DATE '2026-03-14' AS fecha_pedido,
STRUCT('ES' AS pais, 'Barcelona' AS ciudad) AS envio,
[
STRUCT('MOCH-40L-AZ' AS sku, 1 AS cantidad, NUMERIC '89.90' AS precio_unitario),
STRUCT('FRON-300L' AS sku, 2 AS cantidad, NUMERIC '34.50' AS precio_unitario)
] AS lineas;That row contains a complete order. envio is a STRUCT (a record), and lineas is an ARRAY<STRUCT<...>> (a list of records). Everything needed to analyse the order is in one single place on disk.
The practical modelling rule: normalise in OLTP to avoid duplication when writing; denormalise in OLAP to avoid JOIN when reading. Storage is cheap (a few cents per GB per month); shuffle is not.
That said, you should not take it to extremes. For AlpinaShop we will keep pedidos and lineas_pedido as separate tables — because that is how they arrive from PostgreSQL and it makes incremental loading easier — and we will nest where it genuinely pays off: the shipping block inside the order, and the events inside the browsing session. It is an honest middle ground and a very common one in practice.
- Creating AlpinaShop's tables
We are going to create the four tables with DDL in SQL, which is more readable and more versionable than a schema JSON. These statements are run in BigQuery Studio, the console's query interface (BigQuery menu), or from bq query --use_legacy_sql=false.
-- Order header. Partitioned by date and clustered by country and status.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.pedidos`
(
pedido_id STRING NOT NULL OPTIONS(description="Business identifier, e.g. PED-2026-0042"),
fecha_pedido DATE NOT NULL OPTIONS(description="Date the order was confirmed"),
momento_pedido TIMESTAMP OPTIONS(description="Exact instant in UTC"),
cliente_id STRING OPTIONS(description="Pseudonymised customer identifier"),
email_cliente STRING OPTIONS(description="PERSONAL DATA: access restricted by column policy"),
canal STRING OPTIONS(description="web | movil | telefono"),
estado STRING OPTIONS(description="confirmado | enviado | entregado | devuelto | cancelado"),
envio STRUCT<
pais STRING,
provincia STRING,
ciudad STRING,
codigo_postal STRING,
metodo STRING,
coste NUMERIC
> OPTIONS(description="Nested shipping block"),
metodo_pago STRING,
cupon STRING,
subtotal NUMERIC NOT NULL,
descuento NUMERIC,
iva NUMERIC,
total_pedido NUMERIC NOT NULL OPTIONS(description="Final amount charged, VAT included")
)
PARTITION BY fecha_pedido
CLUSTER BY estado, canal
OPTIONS(
description="AlpinaShop order headers, replicated from Cloud SQL alpinashop-pedidos",
partition_expiration_days=NULL,
require_partition_filter=TRUE
);The three details that matter:
PARTITION BY fecha_pedidophysically splits the table into one partition per day. A query that filters by date will only read the partitions it needs.CLUSTER BY estado, canalsorts the data inside each partition by those columns, in that order. Filters onestadowill be able to skip whole blocks.require_partition_filter=TRUEis the best defensive decision in the whole lesson: it rejects any query that does not filter onfecha_pedido. ASELECT * FROM pedidoswill fail with an explicit error instead of scanning two years of data. It is a cheap seat belt that prevents absurd invoices.
-- Order lines. Same partition key so that JOINs filter identically.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.lineas_pedido`
(
pedido_id STRING NOT NULL,
linea_num INT64 NOT NULL,
fecha_pedido DATE NOT NULL OPTIONS(description="Denormalised from pedidos so that it can be partitioned"),
sku STRING NOT NULL,
cantidad INT64 NOT NULL,
precio_unitario NUMERIC NOT NULL,
descuento_linea NUMERIC,
importe_linea NUMERIC NOT NULL OPTIONS(description="cantidad * precio_unitario - descuento_linea")
)
PARTITION BY fecha_pedido
CLUSTER BY sku
OPTIONS(description="Order line detail", require_partition_filter=TRUE);
-- Product catalogue. Small table: neither partitioning nor clustering.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.productos`
(
sku STRING NOT NULL,
nombre STRING NOT NULL,
categoria STRING NOT NULL OPTIONS(description="mochilas | crampones | tiendas | frontales | ropa | cuerdas"),
subcategoria STRING,
marca STRING,
precio_catalogo NUMERIC,
coste_compra NUMERIC OPTIONS(description="CONFIDENTIAL: commercial margin"),
peso_gramos INT64,
activo BOOL,
fecha_alta DATE
)
OPTIONS(description="Master product catalogue, synchronised from the ERP");A design note about lineas_pedido: we have duplicated fecha_pedido from the header. In a relational model that would be reprehensible redundancy. Here it is essential, because a table can only be partitioned by a column of its own. Without that duplication, any JOIN between orders and lines would force a scan of the entire lines table. It is the perfect example of why OLTP modelling rules do not transfer literally.
And the browsing table, which does make full use of nesting:
-- Browsing sessions with their nested events.
CREATE TABLE `alpinashop-datos.alpinashop_analitica.visitas`
(
sesion_id STRING NOT NULL,
fecha DATE NOT NULL,
inicio TIMESTAMP NOT NULL,
cliente_id STRING OPTIONS(description="NULL if the visitor has not signed in"),
dispositivo STRUCT<tipo STRING, navegador STRING, sistema STRING>,
origen STRUCT<fuente STRING, medio STRING, campana STRING>,
pais STRING,
eventos ARRAY<STRUCT<
momento TIMESTAMP,
tipo STRING, -- vista_pagina | ver_producto | anadir_carrito | iniciar_pago | compra
ruta STRING,
sku STRING,
valor NUMERIC
>> OPTIONS(description="Every event in the session, in chronological order")
)
PARTITION BY fecha
CLUSTER BY pais, sesion_id
OPTIONS(description="Browsing sessions on the web catalogue", require_partition_filter=TRUE);A 40-click session is one row, not 40. Counting how many sessions reached iniciar_pago but not compra — the abandoned-cart question Lucía brought along — will be a local operation, with no JOIN and no shuffle.
- Loading data: from Cloud Storage, in streaming and without loading at all
There are four ways of getting data into BigQuery, and picking the wrong one is a classic source of cost and pain.
| Method | Latency | Cost | When to use it |
|---|---|---|---|
| Batch load from Cloud Storage | Minutes | Free (does not consume on-demand slots) | Daily dumps, history, reprocessing |
| Streaming (Storage Write API) | Seconds | Per GB inserted | Events you need "now" |
| External table / BigLake | None: nothing is loaded | You pay when you query, and it is slower | Data that lives in the bucket and is rarely queried |
| Federated query to Cloud SQL | Direct | You pay the compute | Bringing in fresh, small operational data |
Batch load from Cloud Storage
It is the workhorse and it is free, which surprises a lot of people. Google does not charge for batch ingestion: it charges for the resulting storage and for the subsequent queries.
Suppose the nightly dump of the order history leaves files in the bucket. Supported formats: CSV, line-delimited JSON (NDJSON), Avro, Parquet and ORC.
# Load a CSV with a header, autodetecting nothing: explicit schema
bq load \
--source_format=CSV \
--skip_leading_rows=1 \
--null_marker='\N' \
--field_delimiter=',' \
--time_partitioning_field=fecha_pedido \
--clustering_fields=estado,canal \
alpinashop-datos:alpinashop_analitica.pedidos \
gs://alpinashop-catalogo/exportaciones/2026/03/14/pedidos-*.csv \
./esquema_pedidos.json- The
pedidos-*.csvwildcard lets you load dozens of files in a single operation, and BigQuery processes them in parallel. It is far better than one giant file. --null_marker='\N'translates the null markerpg_dumpuses in CSV format; without it you would end up with the literal string\Nin the cells.- Passing the schema in a JSON file is preferable to
--autodetect. Autodetection guesses, and guessing with money is a bad idea: it is quite capable of inferringFLOAT64for a column of amounts orSTRINGfor a date in an odd format.
An honest comparison of formats:
| Format | Schema included | Compressed | Parallelisable | Verdict |
|---|---|---|---|---|
| CSV | No | Only externally (gzip: not parallelisable) | Yes if not compressed | Universal, fragile with commas and line breaks |
| JSON (NDJSON) | No | Same as CSV | Yes | Good for nested data; verbose and heavy |
| Avro | Yes | Yes, in blocks | Yes, even compressed | The best option for repeated loads |
| Parquet | Yes | Yes, columnar | Yes | Excellent; ideal if you already use Spark (04-03) |
| ORC | Yes | Yes | Yes | Valid; common in migrations from Hive |
Recommendation for AlpinaShop: Avro or Parquet for the automated processes, because they carry the schema inside and there is no type ambiguity and no surprises with commas in product names. CSV only for what arrives from third parties, such as the carrier file we will see in 04-05.
Streaming inserts
When the data has to be available within seconds — the real-time autumn campaign dashboard — you use the Storage Write API, which replaces the old tabledata.insertAll and is cheaper and offers better guarantees.
# Publishing a purchase event by streaming into BigQuery.
# Requires: pip install google-cloud-bigquery-storage
from google.cloud import bigquery
client = bigquery.Client(project="alpinashop-datos")
table = "alpinashop-datos.alpinashop_analitica.pedidos"
rows = [
{
"pedido_id": "PED-2026-0042",
"fecha_pedido": "2026-03-14",
"momento_pedido": "2026-03-14T10:22:31Z",
"cliente_id": "CLI-8821",
"canal": "web",
"estado": "confirmado",
"envio": {"pais": "ES", "provincia": "Barcelona",
"ciudad": "Barcelona", "codigo_postal": "08013",
"metodo": "estandar", "coste": "4.90"},
"subtotal": "158.90",
"total_pedido": "192.27",
}
]
errors = client.insert_rows_json(table, rows)
if errors:
# NEVER ignore this return value: streaming failures are silent
raise RuntimeError(f"Rows rejected by BigQuery: {errors}")Two important warnings about streaming:
- It costs money per GB inserted, whereas batch loading is free. If the data can wait an hour, do not stream it: accumulate it in the bucket and load it in batch. It is one of the most profitable and most ignored cost optimisations there is.
- For AlpinaShop, the real route will not be this code inside the Flask application, but Pub/Sub → BigQuery (04-04) or Pub/Sub → Dataflow → BigQuery (04-02). Writing straight to BigQuery from the website couples the shop to the analytical warehouse: if BigQuery has an incident, we do not want the shop to stop selling.
External tables and BigLake
Sometimes the data is already in alpinashop-catalogo and duplicating it is not worth it. An external table leaves the files where they are and BigQuery reads them at query time.
-- External table over the carrier's files, without loading them
CREATE OR REPLACE EXTERNAL TABLE `alpinashop-datos.alpinashop_analitica.ext_envios_transportista`
OPTIONS (
format = 'CSV',
uris = ['gs://alpinashop-catalogo/exportaciones/*/*/*/envios-*.csv'],
skip_leading_rows = 1,
field_delimiter = ';'
);Advantages: zero duplication, zero ingestion cost, the data always reflects the bucket. Drawbacks: slower queries, no partitioning or clustering, and no result cache. Use them for data queried now and again, or as a landing zone before a real load.
BigLake is the evolution of external tables: it adds fine-grained access control — column-level and row-level permissions over data that lives in the bucket — without the analyst needing permission on the bucket. It relies on a BigQuery connection with its own service account, which is what accesses Cloud Storage. For AlpinaShop, that means Lucía will be able to query the export CSVs without having storage.objectViewer on alpinashop-catalogo, which fits the least privilege of 03-04.
- Bringing the history over from Cloud SQL with federated queries
The order history lives in alpinashop-pedidos. There is an elegant way of bringing it over: EXTERNAL_QUERY, the federated query, which runs SQL directly on PostgreSQL and returns the result to BigQuery as if it were a table.
First, the connection. Marta creates it once:
# 1) Create the BigQuery connection to Cloud SQL
bq mk --connection \
--connection_type=CLOUD_SQL \
--properties='{"instanceId":"alpinashop-prod:europe-west1:alpinashop-pedidos-replica-informes","database":"tienda","type":"POSTGRES"}' \
--connection_credential='{"username":"informes_lectura","password":"REPLACE_ME"}' \
--location=europe-west1 \
conn-pedidos-postgres
# 2) See the service account BigQuery has created for this connection
bq show --connection --location=europe-west1 alpinashop-datos.conn-pedidos-postgresNote two deliberate decisions:
- We point at the read replica
alpinashop-pedidos-replica-informes, not at the primary instance. That is exactly what it was created for in 02-03: so that reports never touch the database that serves purchases. - We use the
informes_lecturauser, which only hasSELECT. The real password must come from Secret Manager (db-password-catalogois the catalogue's one; this will get its own secret), never from a file in the repository, as we established in 03-06.
Now the history load, in a single statement:
-- Initial dump of the order header history from PostgreSQL
INSERT INTO `alpinashop-datos.alpinashop_analitica.pedidos`
(pedido_id, fecha_pedido, momento_pedido, cliente_id, email_cliente,
canal, estado, envio, metodo_pago, cupon, subtotal, descuento, iva, total_pedido)
SELECT
p.pedido_id,
DATE(p.creado_en) AS fecha_pedido,
p.creado_en AS momento_pedido,
p.cliente_id,
p.email AS email_cliente,
p.canal,
p.estado,
STRUCT(p.pais, p.provincia, p.ciudad,
p.codigo_postal, p.metodo_envio, p.coste_envio) AS envio,
p.metodo_pago,
p.cupon,
p.subtotal,
p.descuento,
p.iva,
p.total
FROM EXTERNAL_QUERY(
'alpinashop-datos.europe-west1.conn-pedidos-postgres',
'''SELECT pedido_id, creado_en, cliente_id, email, canal, estado,
pais, provincia, ciudad, codigo_postal, metodo_envio, coste_envio,
metodo_pago, cupon, subtotal, descuento, iva, total
FROM pedidos
WHERE creado_en >= '2024-01-01' '''
) AS p;How to read it:
- The string inside
EXTERNAL_QUERYis PostgreSQL SQL, not BigQuery SQL. It runs over there, with that syntax. The triple quotes save you from escaping the internal single quotes. - Filtering inside that string (
WHERE creado_en >= ...) is critical: the fewer rows that cross the network, the better. Filtering outside, in BigQuery, would bring everything over anyway. - The
STRUCT(...)builds the nestedenvioblock out of the six flat columns PostgreSQL returns. This is where you can see the translation between the two models.
When NOT to use federated queries: for large, repeated loads. EXTERNAL_QUERY puts load on the PostgreSQL instance and does not parallelise; it is for the initial dump and for bringing over small, fresh tables (the product master, for example). The daily synchronisation of the history will be done with the export → load pattern orchestrated in 04-06, or with CDC, which we will see in 04-05.
- Business SQL: Lucía's questions, answered
BigQuery uses GoogleSQL, an ANSI-standard dialect with extensions. If you know SQL, you know 90 % of it. Let us get to the real questions.
Sales by category and month
SELECT
FORMAT_DATE('%Y-%m', l.fecha_pedido) AS month,
pr.categoria,
COUNT(DISTINCT l.pedido_id) AS orders,
SUM(l.cantidad) AS units,
ROUND(SUM(l.importe_linea), 2) AS sales_eur
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
JOIN `alpinashop-datos.alpinashop_analitica.productos` AS pr
ON pr.sku = l.sku
WHERE l.fecha_pedido BETWEEN DATE '2025-01-01' AND DATE '2026-12-31'
GROUP BY month, pr.categoria
ORDER BY month, sales_eur DESC;The WHERE on fecha_pedido is not cosmetic: it is what activates partition pruning and what stops require_partition_filter=TRUE from rejecting the query. Without it, BigQuery would read the whole table.
Average order value and how it evolves
SELECT
DATE_TRUNC(fecha_pedido, MONTH) AS month,
COUNT(*) AS num_orders,
ROUND(AVG(total_pedido), 2) AS avg_order_value,
ROUND(APPROX_QUANTILES(total_pedido, 100)[OFFSET(50)], 2) AS median,
ROUND(APPROX_QUANTILES(total_pedido, 100)[OFFSET(90)], 2) AS p90
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido >= DATE '2025-01-01'
AND estado NOT IN ('cancelado', 'devuelto')
GROUP BY month
ORDER BY month;APPROX_QUANTILES computes percentiles approximately but far more cheaply than an exact calculation over millions of rows. Putting the median next to the mean is not a statistical whim: if a €4,000 corporate order lands in March, the mean shoots up and the median does not flinch. Showing only the mean is how you mislead a board of directors without meaning to.
Products with no sales
-- Active products that have sold nothing in the last 90 days
SELECT
pr.sku, pr.nombre, pr.categoria, pr.precio_catalogo, pr.fecha_alta
FROM `alpinashop-datos.alpinashop_analitica.productos` AS pr
WHERE pr.activo = TRUE
AND pr.sku NOT IN (
SELECT DISTINCT sku
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido`
WHERE fecha_pedido >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
)
ORDER BY pr.fecha_alta;Be careful with NOT IN and nulls: if the subquery returned any null sku, NOT IN returns zero rows without warning. In production, LEFT JOIN ... WHERE l.sku IS NULL or NOT EXISTS is safer.
Product ranking with window functions
-- Top 3 products per category, with their share within the category
WITH sales AS (
SELECT
pr.categoria,
pr.sku,
pr.nombre,
SUM(l.importe_linea) AS sales_eur
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
JOIN `alpinashop-datos.alpinashop_analitica.productos` AS pr USING (sku)
WHERE l.fecha_pedido >= DATE '2026-01-01'
GROUP BY pr.categoria, pr.sku, pr.nombre
)
SELECT
categoria,
RANK() OVER (PARTITION BY categoria ORDER BY sales_eur DESC) AS position,
nombre,
ROUND(sales_eur, 2) AS sales_eur,
ROUND(100 * sales_eur
/ SUM(sales_eur) OVER (PARTITION BY categoria), 1) AS share_pct
FROM sales
QUALIFY position <= 3
ORDER BY categoria, position;Two gems here:
OVER (PARTITION BY categoria)computes over each group without collapsing the rows, which is precisely what aGROUP BYcannot do.QUALIFYfilters on the result of a window function directly, with no need to wrap everything in another subquery. It is a GoogleSQL extension and it saves an enormous amount of noise.
UNNEST: the conversion funnel over nested data
-- Conversion funnel for the month: from session to purchase
WITH session_flags AS (
SELECT
sesion_id,
LOGICAL_OR(e.tipo = 'ver_producto') AS viewed_product,
LOGICAL_OR(e.tipo = 'anadir_carrito') AS added_to_cart,
LOGICAL_OR(e.tipo = 'iniciar_pago') AS started_checkout,
LOGICAL_OR(e.tipo = 'compra') AS purchased
FROM `alpinashop-datos.alpinashop_analitica.visitas`,
UNNEST(eventos) AS e
WHERE fecha BETWEEN DATE '2026-03-01' AND DATE '2026-03-31'
GROUP BY sesion_id
)
SELECT
COUNT(*) AS sessions,
COUNTIF(viewed_product) AS viewed_product,
COUNTIF(added_to_cart) AS added_to_cart,
COUNTIF(started_checkout) AS started_checkout,
COUNTIF(purchased) AS purchased,
ROUND(100 * COUNTIF(purchased) / NULLIF(COUNTIF(started_checkout), 0), 1)
AS pct_checkout_to_purchase
FROM session_flags;UNNEST(eventos) turns each session's array of events into rows, and the comma before it is an implicit CROSS JOIN with the parent row: every event keeps access to sesion_id, pais and the rest. It is local to each worker, with no shuffle. The last field answers directly the abandoned-cart question Lucía had been carrying since module 3, and NULLIF(..., 0) avoids the division by zero on a day with no checkouts started.
- The cost model and how not to bankrupt yourself
This section is worth the price of the lesson on its own. BigQuery is wonderful and it is also the service that gives most people a fright when the invoice arrives.
There are two compute models, and they can be mixed per project:
| Model | How you pay | Order of magnitude (verify in the official documentation) | When it suits you |
|---|---|---|---|
| On demand | Per bytes read by each query | ~$5-6 per TB scanned | Irregular use, exploration, getting started |
| Editions (capacity) | Per slots reserved over time | ~$0.04-0.10 per slot-hour depending on edition | Constant, predictable use, fixed cost |
The current editions are Standard, Enterprise and Enterprise Plus, with slot reservations that can autoscale (you pay for the slots used, with a minimum) or be committed for one or three years at a discount. Each adds features: Standard covers the basics, Enterprise adds governance and CMEK, Enterprise Plus adds advanced recovery and strict residency.
For AlpinaShop the decision is on demand, and it should be said with the numbers: a team of three people querying a few hundred gigabytes a month spends a few euros. Reserving slots would carry a much higher fixed monthly cost. The industry rule of thumb is to migrate to capacity when on-demand spend stably exceeds the cost of the equivalent reservation, something that usually happens from tens of terabytes queried per month onwards.
Now, what you really have to do every day.
Estimate before you run: --dry-run
bq query --use_legacy_sql=false --dry_run \
'SELECT categoria, SUM(importe_linea)
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` l
JOIN `alpinashop-datos.alpinashop_analitica.productos` p USING (sku)
WHERE l.fecha_pedido >= "2026-01-01"
GROUP BY categoria'It returns something like:
Query successfully validated. Assuming the tables are not modified, running this query will process 428934112 bytes of data.
428 MB. At ~$5/TB, about $0.002. No problem. But that same calculation on a badly written query can come back with 900 GB and cost $4.50 every time somebody hits run. Ten analysts refreshing a dashboard every five minutes turn that into a four-figure monthly invoice.
In BigQuery Studio you do not need the command: the interface shows, top right, in real time as you type, "This query will process X". Teach everyone who gets access to look there. It is the most profitable habit you can install in a data team.
Why SELECT * is expensive
You pay for the bytes of the columns read. SELECT * on pedidos reads all fourteen columns; SELECT pedido_id, total_pedido reads two. Typical difference: between five and twenty times.
-- BAD: reads every column of every partition
SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos`;
-- ALSO BAD: LIMIT does not reduce what is read, only what is displayed
SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos` LIMIT 10;
-- GOOD: two columns and one partition
SELECT pedido_id, total_pedido
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido = DATE '2026-03-14';
-- To take a look at the data, use the PREVIEW, which is FREE
-- bq head -n 10 alpinashop-datos:alpinashop_analitica.pedidosLIMIT does not reduce the cost. It is misunderstanding number one. To inspect data use bq head or the console's Preview tab: they read the storage directly and cost nothing.
And a handy trick when you want many columns except one:
SELECT * EXCEPT(email_cliente, cupon)
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido = CURRENT_DATE();
- Partitioning and clustering: the before and after, measured
We already declared partitioning and clustering when creating the tables. Now let us measure exactly what they buy.
Partitioning: splits the table into chunks by the value of a column (date, integer range, or ingestion time). BigQuery discards whole partitions without reading them if the WHERE allows it.
Clustering: physically sorts the data inside each partition by up to four columns. It allows blocks within the partition to be skipped.
The experiment, with an orders table covering two years (let us say 40 GB):
# A) UNPARTITIONED table, filtering by date
bq query --use_legacy_sql=false --dry_run \
'SELECT SUM(total_pedido) FROM `alpinashop-datos.alpinashop_scratch.pedidos_plana`
WHERE fecha_pedido = "2026-03-14"'
# -> This query will process 3,221,225,472 bytes (3.0 GB: it reads the WHOLE column)
# B) Table PARTITIONED by fecha_pedido, same filter
bq query --use_legacy_sql=false --dry_run \
'SELECT SUM(total_pedido) FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido = "2026-03-14"'
# -> This query will process 4,194,304 bytes (4 MB: a single partition)From 3 GB to 4 MB: 750 times less. And not a single letter of the SQL has changed, only the physical definition of the table. Over a year of daily queries, that difference is what separates a three-euro invoice from a two-thousand-euro one.
Clustering does not show up in the --dry-run because its saving is only known at execution time (the planner does not know in advance how many blocks it will be able to skip). It is measured on the real result:
-- Run it and then query the bytes actually billed
SELECT
job_id,
query,
total_bytes_processed,
total_bytes_billed,
ROUND(total_bytes_billed / POW(1024,3), 2) AS gb_billed,
TIMESTAMP_DIFF(end_time, start_time, MILLISECOND) AS ms
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 HOUR)
AND job_type = 'QUERY'
ORDER BY creation_time DESC
LIMIT 20;A quick decision guide:
| Situation | What to do |
|---|---|
| Table with a date column and queries by range | Partition by that date, always |
Frequent filters on high-cardinality columns (sku, cliente_id) |
Cluster by them |
| Table smaller than ~1 GB | Neither: the complexity is not worth it |
| More than 4,000 partitions expected | Partition by month instead of by day |
| You want to prevent full scans | require_partition_filter=TRUE |
A nuance about the order in CLUSTER BY estado, canal: the order matters. Clustering sorts first by estado and within that by canal. A filter on canal alone makes far less use of the clustering than a filter on estado. Put the column you filter on most first.
- Materialized views, result cache and quotas
Three more mechanisms for paying less, in increasing order of effort.
Result cache (free and automatic)
If you run exactly the same query over data that has not changed, BigQuery returns the cached result: zero cost and an immediate answer, for about 24 hours. It is invalidated when any table involved is modified.
It gets broken accidentally more easily than it seems: it is enough for the query to use CURRENT_TIMESTAMP() or other non-deterministic functions, or for the text to change by one character. If a dashboard uses WHERE fecha >= CURRENT_DATE() - 7, it will never cache properly. It is worth fixing explicit dates whenever the report allows it.
Views and materialized views
A view is saved SQL: it takes up no space and it is run — and paid for — in full every time.
CREATE OR REPLACE VIEW `alpinashop-datos.alpinashop_analitica.v_ventas_mensuales` AS
SELECT
DATE_TRUNC(l.fecha_pedido, MONTH) AS mes,
pr.categoria,
SUM(l.importe_linea) AS ventas_eur,
SUM(l.cantidad) AS unidades
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
JOIN `alpinashop-datos.alpinashop_analitica.productos` AS pr USING (sku)
WHERE l.fecha_pedido >= DATE '2024-01-01'
GROUP BY mes, pr.categoria;A materialized view stores the precomputed result and BigQuery refreshes it incrementally when the base table changes. On top of that, and this is the best part, the optimiser uses it automatically even when you query the original table.
CREATE MATERIALIZED VIEW `alpinashop-datos.alpinashop_analitica.mv_ventas_diarias_sku`
PARTITION BY dia
CLUSTER BY sku
OPTIONS (enable_refresh = TRUE, refresh_interval_minutes = 60)
AS
SELECT
fecha_pedido AS dia,
sku,
SUM(cantidad) AS unidades,
SUM(importe_linea) AS ventas_eur,
COUNT(*) AS num_lineas
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido`
GROUP BY dia, sku;The Looker Studio dashboard Lucía will build in 04-07 will read a few megabytes from this materialized view instead of scanning the lines table every time someone opens the report. Limitations to be aware of: it only supports a subset of SQL (aggregations yes; JOIN with restrictions and window functions no), and the incremental refresh consumes compute, so do not create twenty of them.
Cost quotas: the seat belt
Hard limits on bytes processed can be imposed, per project and per user:
# Maximum 2 TB a day across the whole analytics project
gcloud alpha services quota update \
--service=bigquery.googleapis.com \
--consumer=projects/alpinashop-datos \
--metric=bigquery.googleapis.com/quota/query/usage \
--unit=1/d/{project} \
--value=2199023255552And at session or individual-query level:
# Reject the query if it is going to read more than 10 GB
bq query --use_legacy_sql=false --maximum_bytes_billed=10737418240 \
'SELECT ... 'For AlpinaShop the policy is: a project quota of 2 TB/day, require_partition_filter on the large tables, and a budget alert in Billing (01-04) at €50 a month on the centro-coste:analitica label. Three independent safety nets, none of them expensive.
- Logical versus physical storage
Storage has its own model, and for some years now you can choose how it is billed per dataset.
| Mode | What is measured | Order of magnitude | When to choose it |
|---|---|---|---|
| Logical (default) | Bytes of the uncompressed data | ~$0.02/GB/month active | Data that compresses poorly |
| Physical | Compressed bytes actually occupied, plus time travel | ~$0.04/GB/month active, but over far fewer GB | Very repetitive data (the usual case) |
The price per GB in physical mode is roughly double, but the GB are usually between 4 and 10 times fewer because BigQuery compresses repetitive columns very well. For data like AlpinaShop's — categories, statuses, countries repeated millions of times — physical mode usually comes out clearly cheaper. You have to measure it before switching:
-- Compare what each mode would cost, with real data from your tables
SELECT
table_name,
ROUND(total_logical_bytes / POW(1024,3), 2) AS logical_gb,
ROUND(total_physical_bytes / POW(1024,3), 2) AS physical_gb,
ROUND(total_logical_bytes / NULLIF(total_physical_bytes, 0), 1) AS compression_ratio
FROM `alpinashop-datos.alpinashop_analitica`.INFORMATION_SCHEMA.TABLE_STORAGE
ORDER BY total_logical_bytes DESC;There is also long-term pricing: a partition that is not modified for 90 consecutive days automatically drops in price, by around half. It is automatic, there is nothing to do, and it affects neither performance nor availability. Since most of AlpinaShop's history is never touched, a good part of the storage will end up at that reduced rate on its own.
Watch out for one detail: any UPDATE on a partition resets its 90-day counter. A badly designed process that rewrites the entire history every night keeps the whole table at the high rate forever. It is another argument in favour of incremental loads by partition.
- Access control: dataset, table, column and row
Everything from IAM (03-04) applies here, plus two layers that only exist in BigQuery.
Dataset and table level
The relevant predefined roles:
| Role | What it allows | For whom at AlpinaShop |
|---|---|---|
roles/bigquery.dataViewer |
Read data and metadata | Lucía and gcp-datos@, on alpinashop_analitica |
roles/bigquery.dataEditor |
Also create and modify tables | The pipelines' service accounts |
roles/bigquery.dataOwner |
Also delete the dataset and grant permissions | Nobody permanently |
roles/bigquery.jobUser |
Run queries (bill them to the project) | Everyone who queries |
roles/bigquery.user |
jobUser + create datasets + read metadata |
Standard analyst profile |
The nuance that confuses everybody: dataViewer is not enough to query. Seeing the data and running a job are different permissions. You also need jobUser on the project where the query runs. If Lucía says "I can see the table but I cannot query it", this is it 90 % of the time.
# Read access on the dataset for the data group
bq add-iam-policy-binding \
--member='group:[email protected]' \
--role='roles/bigquery.dataViewer' \
alpinashop-datos:alpinashop_analitica
# And permission to launch queries in the project
gcloud projects add-iam-policy-binding alpinashop-datos \
--member='group:[email protected]' \
--role='roles/bigquery.jobUser'As always since 03-04: permissions to groups, never to people. When another analyst joins, it is enough to put them in gcp-datos@.
Let us also remember the custom role analistaCatalogo created in 03-04 in alpinashop-datos. Now it makes sense to complete it with the minimum analytical permissions:
gcloud iam roles update analistaCatalogo --project=alpinashop-datos \
--add-permissions=bigquery.jobs.create,bigquery.tables.getData,\
bigquery.tables.list,bigquery.datasets.get,bigquery.routines.getColumn level: hiding the customer's email
GDPR warning.
email_clienteis personal data. Processing it for analytical purposes requires a legal basis, minimisation and restricted access. What follows is a technical control, not a legal opinion: any processing of real personal data must be reviewed by a compliance professional or the DPO before going into production. All the data in this course is fictitious.
The control is done with Data Catalog policy tags. The idea: you tag the column, and only someone holding the Fine-Grained Reader role on that tag can read it.
# 1) Sensitivity taxonomy for AlpinaShop
gcloud data-catalog taxonomies create \
--location=europe-west1 \
--display-name="AlpinaShop sensitivity" \
--activated-policy-types=FINE_GRAINED_ACCESS_CONTROL
TAXO=$(gcloud data-catalog taxonomies list --location=europe-west1 \
--format="value(name)" --filter="displayName='AlpinaShop sensitivity'")
# 2) Tag for direct personal data
gcloud data-catalog taxonomies policy-tags create \
--taxonomy="$TAXO" --display-name="pii-directo" \
--description="Directly identifies a person: email, phone, address"
PT=$(gcloud data-catalog taxonomies policy-tags list --taxonomy="$TAXO" \
--format="value(name)" --filter="displayName='pii-directo'")
# 3) Only the security group may read columns carrying that tag
gcloud data-catalog taxonomies policy-tags add-iam-policy-binding "$PT" \
--member='group:[email protected]' \
--role='roles/datacatalog.categoryFineGrainedReader'And it is applied to the column:
ALTER TABLE `alpinashop-datos.alpinashop_analitica.pedidos`
ALTER COLUMN email_cliente
SET OPTIONS (
policy_tags = ['projects/alpinashop-datos/locations/europe-west1/taxonomies/TAXO_ID/policyTags/PT_ID']
);From that moment on, if Lucía runs SELECT * FROM pedidos, the whole query fails with an access error on email_cliente. She has to write SELECT * EXCEPT(email_cliente) or list the columns. That is a desirable side effect: it reinforces the habit of not using SELECT *, which we already know is expensive as well.
For AlpinaShop, the full recommendation is not to bring the raw email into the analytical warehouse at all. Lucía needs to know how many distinct customers bought, not who they were. A pseudonymised identifier does the job just as well:
-- Working view for analytics: no direct personal data
CREATE OR REPLACE VIEW `alpinashop-datos.alpinashop_analitica.v_pedidos_analitica` AS
SELECT
* EXCEPT(email_cliente),
TO_HEX(SHA256(CONCAT(email_cliente, 'secret-salt-from-secret-manager'))) AS cliente_hash
FROM `alpinashop-datos.alpinashop_analitica.pedidos`;The salt has to come from Secret Manager (03-06) and not be written into the SQL as it is here; without a salt, a hash of an email is reversible by brute force and is still personal data. In 04-07 we will see the specific tool for this, Sensitive Data Protection.
Row level
A row access policy filters which rows each identity sees. Useful if AlpinaShop opens a French subsidiary tomorrow and each sales team should only see its own market:
CREATE ROW ACCESS POLICY pol_solo_espana
ON `alpinashop-datos.alpinashop_analitica.pedidos`
GRANT TO ('group:[email protected]')
FILTER USING (envio.pais = 'ES');The filter is applied transparently and inescapably: the user writes their normal SELECT and only gets Spanish rows, without knowing the policy exists.
Sharing without copying: authorized views
If you want to give access to the result but not to the base table, you use an authorized view: permission is granted on the view, the view has permission on the table, and the user does not. It is the clean mechanism for exposing aggregated data to marketing without opening up the order detail to them.
bq update --view_udf_resource= --authorized_view \
--source_dataset=alpinashop-datos:alpinashop_analitica \
alpinashop-datos:alpinashop_analitica.v_ventas_mensuales
INFORMATION_SCHEMA: auditing who spends and on what
INFORMATION_SCHEMA: auditing who spends and on whatBigQuery observes itself. INFORMATION_SCHEMA is a set of read-only views with metadata and with the job history. This query ought to be run once a week in any serious team:
-- The 20 most expensive queries of the last 7 days, with their estimated cost
SELECT
user_email,
job_id,
DATE(creation_time) AS day,
ROUND(total_bytes_billed / POW(1024,4), 3) AS tb_billed,
ROUND(total_bytes_billed / POW(1024,4) * 6.25, 2) AS approx_cost_eur,
TIMESTAMP_DIFF(end_time, start_time, SECOND) AS seconds,
total_slot_ms,
cache_hit,
SUBSTR(REGEXP_REPLACE(query, r'\s+', ' '), 1, 160) AS query_text
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
AND job_type = 'QUERY'
AND state = 'DONE'
AND error_result IS NULL
ORDER BY total_bytes_billed DESC
LIMIT 20;The price of 6.25 is hard-coded as an order of magnitude: replace it with the one in force in your region and verify it in the official documentation, because it changes.
Other useful queries from the same place:
-- Spend per user and day: to know who needs training
SELECT
user_email,
DATE(creation_time) AS day,
COUNT(*) AS queries,
COUNTIF(cache_hit) AS from_cache,
ROUND(SUM(total_bytes_billed) / POW(1024,4), 3) AS tb_billed
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
AND job_type = 'QUERY'
GROUP BY user_email, day
ORDER BY tb_billed DESC;
-- Tables nobody queries but that we keep paying for
SELECT table_name,
ROUND(total_logical_bytes / POW(1024,3), 2) AS gb,
TIMESTAMP_MILLIS(last_modified_time) AS last_modified
FROM `alpinashop-datos.alpinashop_analitica`.INFORMATION_SCHEMA.TABLE_STORAGE
ORDER BY gb DESC;That last one is the analytical equivalent of the orphan-disk clean-up in 02-01: in every data warehouse a year old there are _backup_final_v2 tables nobody remembers and that are paid for every month.
- BigQuery ML, mentioned and deferred
BigQuery includes BigQuery ML, which lets you train models with SQL syntax: CREATE MODEL ... OPTIONS(model_type='linear_reg'), and then ML.PREDICT. You can do regression, classification, time series with ARIMA_PLUS, k-means, matrix factorisation for recommendations and even invoke Vertex AI models from SQL.
For AlpinaShop it is a very attractive route: demand forecasting per SKU ahead of the autumn campaign, or customer segmentation, without taking the data out of BigQuery or setting up any infrastructure.
But that is module 5. There we will look at Vertex AI, AutoML and recommendation models with the rigour they deserve: how training and validation data are separated, how a model is evaluated and why a good metric in testing can be useless in production. Here we simply note that the door exists and that it is one CREATE MODEL away.
Common Mistakes and Tips
Creating the dataset in US by accident. It is the console's default option and it is irreversible. As well as preventing JOINs with europe-west1, it takes European customers' personal data outside the EU. Always check the location before loading the first byte, and fix --location=europe-west1 in the team's configuration.
Believing that LIMIT makes the query cheaper. It does not: the same bytes are read. To look at data, bq head or the preview, which are free.
Using SELECT * out of habit. In a columnar engine it is between five and twenty times more expensive. Write out the columns, or use SELECT * EXCEPT(...).
Forgetting the partition filter. Without WHERE fecha_pedido ..., a partitioned table is read in full and the partitioning is useless. Turn on require_partition_filter=TRUE and let the engine protect you.
Using FLOAT64 for amounts. The rounding errors show up in exactly the report management sees. NUMERIC always for money.
Doing UPDATE and DELETE as in PostgreSQL. BigQuery supports them, but they rewrite entire partitions, they are expensive and they reset the long-term counter. For bulk updates, MERGE or rewriting the whole partition with WRITE_TRUNCATE. And to correct one field of one order, do it in Cloud SQL and resynchronise.
Writing to BigQuery from the web application. It couples the shop to the analytical warehouse. Publish to Pub/Sub (04-04) and let another process do the writing. The shop must be able to sell even when analytics is down.
Granting dataViewer and forgetting jobUser. It is the most repeated support ticket in the BigQuery world.
Putting LIMIT on a query that sorts. ORDER BY over millions of rows forces a global sort that can exhaust resources with the Resources exceeded error. Aggregate first, sort afterwards; or use window functions with QUALIFY.
Tip: use labels on your jobs. bq query --label=proceso:panel-direccion lets you separate out in INFORMATION_SCHEMA how much each consumer of data costs. When someone asks "how much does the management dashboard cost us?", you will have the number.
Tip: keep the DDL in Git. The CREATE TABLE statements in this lesson are code. They belong under version control alongside the rest of the project, not only inside BigQuery. In 06-07 we will turn them into Terraform.
Exercises
Exercise 1: dataset, table and first load
In alpinashop-datos, create a test dataset called alpinashop_ejercicios in europe-west1 whose tables expire after 3 days. Inside it, create a table opiniones with: opinion_id (STRING, required), sku (STRING, required), fecha (DATE, required), puntuacion (INT64), texto (STRING), pais (STRING). It must be partitioned by fecha, clustered by sku and reject queries with no partition filter. Then insert three sample customer reviews and check with --dry-run how many bytes it costs to query a single day's worth.
Exercise 2: the management query
Using the alpinashop_analitica tables, write a single query that returns, for each month of 2026 and only for orders that are neither cancelled nor returned: the month, the number of orders, total revenue, the average order value, the best-selling category of that month and the percentage that category represents of the month's total. Order by month. Before running it, estimate its cost with --dry-run.
Exercise 3: diagnosing a runaway invoice
Marta gets the budget alert: alpinashop-datos has spent €180 this month when the historical figure was €12. Nobody has loaded any new data. Write the INFORMATION_SCHEMA queries that let you (a) identify the user or service account responsible, (b) isolate the specific query and how many times it has run, and (c) determine whether it makes use of the cache. Then propose three concrete corrective measures, ordered from the most immediate to the most structural.
Solutions
Solution 1
gcloud config set project alpinashop-datos
# Test dataset with a 3-day expiry (259200 seconds)
bq --location=europe-west1 mk --dataset \
--description="Exercise dataset; tables expire after 3 days" \
--default_table_expiration=259200 \
--label=entorno:desarrollo --label=equipo:datos \
alpinashop-datos:alpinashop_ejerciciosCREATE TABLE `alpinashop-datos.alpinashop_ejercicios.opiniones`
(
opinion_id STRING NOT NULL,
sku STRING NOT NULL,
fecha DATE NOT NULL,
puntuacion INT64 OPTIONS(description="From 1 to 5"),
texto STRING OPTIONS(description="Free text from the customer"),
pais STRING
)
PARTITION BY fecha
CLUSTER BY sku
OPTIONS(
description="Customer reviews of products (fictitious data)",
require_partition_filter=TRUE
);
INSERT INTO `alpinashop-datos.alpinashop_ejercicios.opiniones`
(opinion_id, sku, fecha, puntuacion, texto, pais)
VALUES
('OPI-0001','MOCH-40L-AZ', DATE '2026-03-10', 5,
'Very comfortable on long treks, the straps hold up well.','ES'),
('OPI-0002','FRON-300L', DATE '2026-03-10', 3,
'The battery lasts less than advertised on high power.','FR'),
('OPI-0003','CRAM-12P', DATE '2026-03-11', 4,
'Good grip on hard ice, although they are a bit heavy.','ES');bq query --use_legacy_sql=false --dry_run \
'SELECT sku, AVG(puntuacion) AS average
FROM `alpinashop-datos.alpinashop_ejercicios.opiniones`
WHERE fecha = "2026-03-10"
GROUP BY sku'The result will be a few hundred bytes: with three rows, it is almost all metadata. What matters is the habit, not the figure. And if you remove the WHERE fecha, the query will not be expensive: it will fail, because require_partition_filter=TRUE rejects it. That error is exactly the behaviour we want.
Solution 2
WITH valid_orders AS (
SELECT
DATE_TRUNC(fecha_pedido, MONTH) AS month,
pedido_id,
total_pedido
FROM `alpinashop-datos.alpinashop_analitica.pedidos`
WHERE fecha_pedido BETWEEN DATE '2026-01-01' AND DATE '2026-12-31'
AND estado NOT IN ('cancelado', 'devuelto')
),
month_summary AS (
SELECT
month,
COUNT(*) AS num_orders,
ROUND(SUM(total_pedido), 2) AS revenue_eur,
ROUND(AVG(total_pedido), 2) AS avg_order_value
FROM valid_orders
GROUP BY month
),
category_sales AS (
SELECT
DATE_TRUNC(l.fecha_pedido, MONTH) AS month,
pr.categoria,
SUM(l.importe_linea) AS cat_sales
FROM `alpinashop-datos.alpinashop_analitica.lineas_pedido` AS l
JOIN `alpinashop-datos.alpinashop_analitica.productos` AS pr USING (sku)
JOIN valid_orders AS vo USING (pedido_id)
WHERE l.fecha_pedido BETWEEN DATE '2026-01-01' AND DATE '2026-12-31'
GROUP BY month, pr.categoria
),
top_category AS (
SELECT
month,
categoria,
cat_sales,
SUM(cat_sales) OVER (PARTITION BY month) AS month_total_sales
FROM category_sales
QUALIFY ROW_NUMBER() OVER (PARTITION BY month ORDER BY cat_sales DESC) = 1
)
SELECT
FORMAT_DATE('%Y-%m', s.month) AS month,
s.num_orders,
s.revenue_eur,
s.avg_order_value,
t.categoria AS top_category,
ROUND(100 * t.cat_sales / NULLIF(t.month_total_sales, 0), 1) AS top_share_pct
FROM month_summary AS s
LEFT JOIN top_category AS t USING (month)
ORDER BY month;The keys to the solution:
- The CTEs (
WITH) break the problem into readable steps. BigQuery evaluates them as part of the plan; they do not create tables and they do not cost extra. QUALIFY ROW_NUMBER() OVER (...) = 1keeps the leading category of each month in a single pass.ROW_NUMBERand notRANKbecause we want exactly one row even in a tie.- The
SUM(...) OVER (PARTITION BY month)computes the month's total without collapsing the rows, which is what allows the division that gives the share. - The date filters are on both partitioned tables. Leaving them out of
lineas_pedidowould make theJOINscan the whole table: the query would give the same result and cost twenty times more. NULLIF(..., 0)protects against division by zero in a month with no sales.
The --dry-run over two years of history should come back with a few hundred megabytes: cents. Without the partition filters, tens of gigabytes.
Solution 3
(a) Identify who is responsible:
SELECT
user_email,
COUNT(*) AS queries,
ROUND(SUM(total_bytes_billed)/POW(1024,4), 2) AS tb_billed,
ROUND(SUM(total_bytes_billed)/POW(1024,4)*6.25,2) AS approx_cost_eur
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), MONTH)
AND job_type = 'QUERY'
GROUP BY user_email
ORDER BY tb_billed DESC;(b) Isolate the query and count its executions:
SELECT
SUBSTR(REGEXP_REPLACE(query, r'\s+', ' '), 1, 200) AS query_text,
COUNT(*) AS executions,
ROUND(AVG(total_bytes_billed)/POW(1024,3), 2) AS gb_per_execution,
ROUND(SUM(total_bytes_billed)/POW(1024,4), 2) AS total_tb,
MIN(creation_time) AS first_run,
MAX(creation_time) AS last_run
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), MONTH)
AND job_type = 'QUERY'
GROUP BY query_text
ORDER BY total_tb DESC
LIMIT 10;(c) Check how well the cache is being used:
SELECT
user_email,
COUNTIF(cache_hit) AS from_cache,
COUNTIF(NOT cache_hit) AS without_cache,
ROUND(100 * COUNTIF(cache_hit) / COUNT(*), 1) AS cache_pct
FROM `region-europe-west1`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE creation_time >= TIMESTAMP_TRUNC(CURRENT_TIMESTAMP(), MONTH)
AND job_type = 'QUERY'
GROUP BY user_email;The typical and predictable diagnosis: the culprit is not a person but a dashboard that auto-refreshes every 5 minutes running SELECT * FROM lineas_pedido with no date filter. A cache_pct close to zero confirms it: if the query carries CURRENT_TIMESTAMP() or the table is receiving continuous writes, the cache is never any use. 288 executions a day × 2 GB × 30 days ≈ 17 TB ≈ €100.
Three corrective measures, from the immediate to the structural:
- Today (containment): apply
--maximum_bytes_billedto the dashboard's user or service account and set the project's daily quota at 2 TB. The bleeding stops within minutes even if nobody touches the dashboard. - This week (correction): rewrite the dashboard's query with explicit columns and a partition filter, and drop the refresh from 5 minutes to 1 hour. That is usually enough to divide the cost by a hundred.
- This month (structural): create a materialized view with the aggregation the dashboard needs and point the dashboard at it; turn on
require_partition_filter=TRUEon the large tables so the problem cannot recur; and schedule the weekly audit query with an alert if any user goes over a TB threshold.
Measure 3 is the only one that prevents a relapse. The first two buy time.
Conclusion
AlpinaShop now has a data warehouse. In this lesson you have understood the real difference — not the slogan version — between OLTP and OLAP: it is not a matter of size but of access pattern, and that pattern is what justifies columnar storage, the separation of storage and compute, and the distributed execution across slots that makes a query over eight hundred million rows finish in seconds.
You have created the alpinashop_analitica dataset in alpinashop-datos, in europe-west1, knowing that this location is an irreversible decision with compliance implications and consequences for your ability to cross data. You have defined the tables pedidos, lineas_pedido, productos and visitas with the right types — NUMERIC for money, always — with a STRUCT for the shipping block and with an ARRAY<STRUCT<...>> of events inside each session, understanding why denormalisation, which in PostgreSQL would be a mistake, is here the way to avoid the shuffle that is what is genuinely expensive in a distributed engine.
You have loaded data by all four routes and you know which to use: batch from Cloud Storage — free — for volume, streaming only when seconds matter, external tables and BigLake for what is not worth duplicating, and EXTERNAL_QUERY against the alpinashop-pedidos-replica-informes replica for the initial dump of the history, deliberately pointing at the replica and with the read-only informes_lectura user.
You have written the SQL that answers the questions Lucía had been accumulating for three modules: sales by category and month, average order value with the median beside it so as not to mislead anyone, products that do not sell, per-category rankings with window functions and QUALIFY, and the complete conversion funnel with UNNEST over the nested events, which finally puts a number on that 30 % of abandoned shopping carts.
And, above all, you have learned how not to bankrupt yourself: --dry-run before running, SELECT * banished, LIMIT unmasked as a false economy, partitioning by date that turned 3 GB into 4 MB, clustering ordered by the column you filter on most, require_partition_filter as a seat belt, materialized views for the dashboards, a result cache that is free as long as you do not break it, quotas per project and per query, physical versus logical storage measured with your own data, and INFORMATION_SCHEMA as the tool that turns "the invoice has gone up" into "this query, this user, 288 times a day". You have closed off access with roles granted to groups, you have remembered that dataViewer without jobUser queries nothing, and you have protected email_cliente with a policy tag — with the express GDPR warning and the recommendation not to bring the raw email into the warehouse at all, but a pseudonymised identifier salted from Secret Manager.
There is one obvious gap left. Everything we have loaded is history: a snapshot of what has already happened, brought over from PostgreSQL in one go. But AlpinaShop never stops selling. Right now, as you read this, there are customers browsing the catalogue, adding backpacks to their shopping carts and confirming orders, and none of those facts reaches alpinashop_analitica. Synchronising by hand every night is a sticking plaster that breaks the moment somebody asks "I want to see the campaign's sales today, not tomorrow".
In 04-02, Cloud Dataflow, we will build the missing pipe. You will see what a data pipeline is and why a script on a VM is not enough; you will learn the Apache Beam model, which describes both a batch process and a real-time one with the same code; you will write in Python a pipeline that reads the history from the alpinashop-catalogo bucket, cleans it, aggregates it and writes it into the tables you have just created; and you will step into the genuinely interesting territory of streaming — event time versus processing time, watermarks, windows and late data — to leave ready the pipeline that will consume the pedidos-nuevos topic as soon as we create it in 04-04. When you finish that lesson, data will stop arriving when somebody remembers and will start arriving on its own.
Google Cloud Platform (GCP) Course
Module 1: Introduction to Google Cloud Platform
- What is Google Cloud Platform?
- Setting Up Your GCP Account
- A Tour of the GCP Console
- Projects, Resource Hierarchy and Billing
- Regions, Zones and the Shared Responsibility Model
- Cloud Shell and the gcloud CLI
Module 2: Core GCP Services
- Compute Engine: Virtual Machines on Google Cloud
- Cloud Storage: Object Storage
- Cloud SQL: Managed Relational Databases
- App Engine: Platform as a Service
- Google Kubernetes Engine (GKE)
- NoSQL Databases: Firestore, Bigtable and Spanner
- How to Choose the Right Compute Service
Module 3: Networking and Security
- VPC Networks
- Cloud Load Balancing
- Cloud CDN
- Identity and Access Management (IAM)
- Cloud Armor
- Secrets and Encryption: Secret Manager and Cloud KMS
- Cloud DNS, TLS Certificates and Publishing Services Securely
Module 4: Data and Analytics
- BigQuery: The Analytical Data Warehouse
- Cloud Dataflow: Batch and Streaming Data Processing
- Cloud Dataproc: Managed Spark and Hadoop
- Cloud Pub/Sub: Asynchronous Messaging
- Cloud Data Fusion: Code-Free Data Integration
- Orchestrating Pipelines with Cloud Composer and Workflows
- Data Governance and Dashboards with Dataplex and Looker Studio
Module 5: Machine Learning and AI
- Vertex AI: The Machine Learning Platform on GCP
- AutoML: Custom Models Without Writing Code
- TensorFlow on GCP: Training and Serving Models
- Natural Language API
- Vision API
- Generative AI on Vertex AI: Gemini Models and Embeddings
- MLOps: From Model to Product with Vertex AI Pipelines
Module 6: DevOps and Monitoring
- Cloud Build: Continuous Integration on GCP
- Cloud Source Repositories and Source Code Management
- Cloud Functions: Serverless Functions
- Cloud Monitoring (formerly Stackdriver): Metrics, Dashboards and Alerts
- Cloud Deployment Manager and Native Infrastructure as Code
- Cloud Logging and Cloud Trace: Logs, Traces and Diagnostics
- Terraform on GCP: Infrastructure as Code in Practice
Module 7: Advanced GCP Topics
- Hybrid and Multicloud with Anthos
- Serverless Computing with Cloud Run
- Advanced Networking: Shared VPC, Peering and Hybrid Connectivity
- Security Best Practices
- Cost Management and Optimization
- Reliability: SLOs, High Availability and Disaster Recovery
- Governance at Scale: Organization, Policies and Auditing
