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

  1. Why an analytical warehouse is not "a bigger database"
  2. How BigQuery works inside: columns, separation and Dremel
  3. Cloud SQL versus BigQuery: the table you need to internalise
  4. Structure: project, dataset, table and view
  5. Creating the alpinashop_analitica dataset
  6. Data types, STRUCT and ARRAY: denormalisation as a virtue
  7. Creating AlpinaShop's tables
  8. Loading data: from Cloud Storage, in streaming and without loading at all
  9. Bringing the history over from Cloud SQL with federated queries
  10. Business SQL: Lucía's questions, answered
  11. The cost model and how not to bankrupt yourself
  12. Partitioning and clustering: the before and after, measured
  13. Materialized views, result cache and quotas
  14. Logical versus physical storage
  15. Access control: dataset, table, column and row
  16. INFORMATION_SCHEMA: auditing who spends and on what
  17. BigQuery ML, mentioned and deferred

  1. 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 the pais_envio column 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.

  1. 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.

  1. 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.

  1. 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-datos while paying from its own project. For AlpinaShop, everything runs and is billed in alpinashop-datos, which already carries its centro-coste label.
  • 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 JOIN across datasets in different locations. If someone creates alpinashop_marketing in us-central1 tomorrow, they will not be able to cross it with alpinashop_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:

SELECT * FROM `alpinashop-datos.alpinashop_analitica.pedidos` LIMIT 10;

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).

  1. Creating the alpinashop_analitica dataset

We 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_analitica

Option by option:

  • --location=europe-west1: the immutable location. It goes before the mk subcommand, 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 put 2592000 (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-coste scheme 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_scratch

Verification:

bq ls --format=prettyjson --datasets alpinashop-datos
bq show --format=prettyjson alpinashop-datos:alpinashop_analitica

In 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

  1. Data types, STRUCT and ARRAY: denormalisation as a virtue

BigQuery'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 type T inside 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.

  1. 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_pedido physically splits the table into one partition per day. A query that filters by date will only read the partitions it needs.
  • CLUSTER BY estado, canal sorts the data inside each partition by those columns, in that order. Filters on estado will be able to skip whole blocks.
  • require_partition_filter=TRUE is the best defensive decision in the whole lesson: it rejects any query that does not filter on fecha_pedido. A SELECT * FROM pedidos will 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.

  1. 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-*.csv wildcard 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 marker pg_dump uses in CSV format; without it you would end up with the literal string \N in 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 inferring FLOAT64 for a column of amounts or STRING for 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:

  1. 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.
  2. 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.

  1. 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-postgres

Note 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_lectura user, which only has SELECT. The real password must come from Secret Manager (db-password-catalogo is 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_QUERY is 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 nested envio block 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.

  1. 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 a GROUP BY cannot do.
  • QUALIFY filters 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.

  1. 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.pedidos

LIMIT 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();

  1. 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.

  1. 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=2199023255552

And 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.

  1. 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.

  1. 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.get

Column level: hiding the customer's email

GDPR warning. email_cliente is 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

  1. INFORMATION_SCHEMA: auditing who spends and on what

BigQuery 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.

  1. 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_ejercicios
CREATE 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 (...) = 1 keeps the leading category of each month in a single pass. ROW_NUMBER and not RANK because 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_pedido would make the JOIN scan 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:

  1. Today (containment): apply --maximum_bytes_billed to 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.
  2. 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.
  3. This month (structural): create a materialized view with the aggregation the dashboard needs and point the dashboard at it; turn on require_partition_filter=TRUE on 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

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved