La interfaz de GA4 contesta las preguntas que Google anticipó. BigQuery contesta el resto, y la entrada cuesta entender una sola cosa rara del esquema. Cuando eso hace clic, casi todo el análisis que querés en serio son cuarenta líneas de SQL.
Esto asume que la exportación ya está prendida. Si la prendiste hace poco y necesitás los meses anteriores, el histórico no se puede recuperar desde GA4 — conviene saberlo antes de planificar alrededor.
Lo único raro del esquema
En BigQuery, una fila es un evento. No una sesión, no un usuario. Y los parámetros que ese
evento llevaba no viven en columnas: viven en event_params, un array de structs clave/valor:
event_date = '20260929'
event_timestamp = 1759104000000000 -- microsegundos, no milisegundos
event_name = 'purchase'
user_pseudo_id = '1234567.8901234'
event_params = [
{ key: 'ga_session_id', value: { int_value: 1759103000 } },
{ key: 'page_location', value: { string_value: 'https://...' } },
{ key: 'session_engaged', value: { string_value: '1' } }
]
Así que no podés escribir WHERE page_location = .... Entrás al array con una subconsulta
escalar, y este patrón es cerca del 80% de todo el SQL de GA4:
SELECT
event_name,
(SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id
FROM `mi-proyecto.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260930'
AND event_name = 'purchase'
Dos detalles ahí adentro que importan más de lo que parecen:
events_* con _TABLE_SUFFIX es cómo se consultan tablas particionadas por día. El filtro no
es opcional: si lo dejás afuera, cada consulta escanea todo tu histórico. Esa es la única forma de
que esto salga caro.
event_timestamp está en microsegundos. Dividí por 1.000.000 para segundos, o usá
TIMESTAMP_MICROS(). Tratarlo como milisegundos te manda los datos a 1970 y el error no siempre es
evidente.
Sesiones y usuarios, bien contados
ga_session_id no es único por sí solo — es un entero derivado de un timestamp con alcance por
usuario, así que dos usuarios pueden compartir uno. La clave de sesión es el par:
SELECT
PARSE_DATE('%Y%m%d', event_date) AS fecha,
COUNT(DISTINCT user_pseudo_id) AS usuarios,
COUNT(DISTINCT CONCAT(
user_pseudo_id,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
)) AS sesiones,
COUNTIF(event_name = 'purchase') AS compras,
ROUND(SUM(ecommerce.purchase_revenue), 2) AS facturacion
FROM `mi-proyecto.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260930'
GROUP BY fecha
ORDER BY fecha
Esto no va a coincidir exactamente con la interfaz de GA4, y eso es esperable, no un bug. La interfaz modela conversiones que no observó y reconcilia identidades; la exportación te da lo que se recolectó. Diferencias por debajo del 10% son normales. Por encima, revisá el rango de fechas y la clave de sesión antes de sospechar de la exportación.
Un funnel que te dice dónde se va la gente
La exploración de funnel de la interfaz alcanza hasta que necesitás un paso definido por algo que
no te deja expresar. En SQL el paso es un COUNTIF y nada más:
WITH sesiones AS (
SELECT
CONCAT(user_pseudo_id,
(SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id')
) AS clave_sesion,
COUNTIF(event_name = 'view_item') > 0 AS vio_producto,
COUNTIF(event_name = 'add_to_cart') > 0 AS agrego,
COUNTIF(event_name = 'begin_checkout') > 0 AS fue_a_checkout,
COUNTIF(event_name = 'purchase') > 0 AS compro
FROM `mi-proyecto.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260901' AND '20260930'
GROUP BY clave_sesion
)
SELECT
COUNTIF(vio_producto) AS paso_1_producto,
COUNTIF(agrego) AS paso_2_carrito,
COUNTIF(fue_a_checkout) AS paso_3_checkout,
COUNTIF(compro) AS paso_4_compra,
ROUND(100 * COUNTIF(agrego) / NULLIF(COUNTIF(vio_producto), 0), 1) AS pct_producto_a_carrito,
ROUND(100 * COUNTIF(fue_a_checkout) / NULLIF(COUNTIF(agrego), 0), 1) AS pct_carrito_a_checkout,
ROUND(100 * COUNTIF(compro) / NULLIF(COUNTIF(fue_a_checkout), 0), 1) AS pct_checkout_a_compra
FROM sesiones
Cambiá cualquier paso por una condición propia — una página puntual, el valor de un parámetro, un tipo de dispositivo — y la forma de la consulta no cambia. Esa sustitución es toda la razón para estar acá adentro.
First-touch vs last-touch, sobre tus propios datos
Esta es la consulta que justifica la exportación. Los reportes de GA4 ya no te dejan comparar modelos — first-click, lineal y time-decay se eliminaron en 2023 —, pero los eventos crudos no traen ninguna atribución, lo que significa que podés asignar el crédito como quieras:
WITH contactos AS (
SELECT
user_pseudo_id,
event_timestamp,
COALESCE(collected_traffic_source.manual_source, 'direct') AS fuente,
COALESCE(collected_traffic_source.manual_medium, 'none') AS medio,
event_name,
ecommerce.purchase_revenue AS facturacion
FROM `mi-proyecto.analytics_123456789.events_*`
WHERE _TABLE_SUFFIX BETWEEN '20260701' AND '20260930'
),
convertidores AS (
SELECT user_pseudo_id, MIN(event_timestamp) AS convirtio_en, SUM(facturacion) AS facturacion
FROM contactos
WHERE event_name = 'purchase'
GROUP BY user_pseudo_id
),
ordenados AS (
SELECT
c.user_pseudo_id,
v.facturacion,
CONCAT(c.fuente, ' / ', c.medio) AS canal,
ROW_NUMBER() OVER (PARTITION BY c.user_pseudo_id ORDER BY c.event_timestamp ASC) AS rank_primero,
ROW_NUMBER() OVER (PARTITION BY c.user_pseudo_id ORDER BY c.event_timestamp DESC) AS rank_ultimo
FROM contactos c
JOIN convertidores v USING (user_pseudo_id)
WHERE c.event_timestamp <= v.convirtio_en
)
SELECT
canal,
ROUND(SUM(IF(rank_primero = 1, facturacion, 0)), 2) AS facturacion_first_touch,
ROUND(SUM(IF(rank_ultimo = 1, facturacion, 0)), 2) AS facturacion_last_touch
FROM ordenados
GROUP BY canal
ORDER BY facturacion_last_touch DESC
Correla y las dos columnas van a estar en desacuerdo, a veces en serio. La búsqueda de marca y el directo se inflan del lado del last-touch; el paid social y el display se inflan del lado del first-touch. El tamaño de esa brecha es la medida práctica de cuánto depende tu reporte de canales de una decisión contable.
Un límite honesto: esto está armado sobre user_pseudo_id, que es una cookie en un navegador. Los
recorridos cross-device aparecen como personas distintas, así que el camino real es más largo que
cualquier cosa que esta consulta pueda ver. Si eso importa para tu negocio, la solución es user_id
en el tráfico logueado más tracking server-side, no una función de
ventana más ingeniosa.
Cómo mantener la factura en cero
El primer terabyte de consultas de cada mes es gratis y el almacenamiento son un par de centavos por gigabyte, así que la mayoría de las propiedades no paga nada. Tres hábitos lo mantienen así:
- Filtrá siempre
_TABLE_SUFFIX. Este es el importante. Sin él escaneás todos los días que recolectaste en tu vida, cada vez. - Nunca
SELECT *. BigQuery cobra por columnas escaneadas. Nombrar cinco columnas en lugar de traer todas suele ser una diferencia de 10× sobre las mismas filas. - Materializá lo que reusás. Si una transformación alimenta un dashboard, escribila a una tabla de forma programada en vez de recalcularla en cada refresh. Eso también es lo que evita que un dashboard que tu equipo abra sea tan lento que dejen de abrirlo.
Usá la estimación de bytes en seco de la consola antes de correr algo nuevo. Es gratis y te dice al instante si te olvidaste del filtro de fechas.
Cuatro trampas que conviene conocer antes
events_intraday_* es otra tabla. Si la exportación incluye streaming, los datos de hoy están
en una tabla intradiaria aparte con una forma un poco distinta. events_* matchea las dos sin
avisar, así que una consulta con wildcard puede mezclar días cerrados con uno parcial. Sé explícito
cuando la distinción importe.
user_pseudo_id viene nulo cuando se deniega el consentimiento. Según tu configuración de
consentimiento, algunos eventos llegan sin identificador. Son eventos reales que no se pueden
agrupar en un usuario, e ignorarlo en silencio subestima tus totales.
(not set) es un valor real. Significa que esa dimensión no se recolectó para ese evento, no
que no pasó nada. Filtralo a propósito en vez de dejarlo viajar dentro de un GROUP BY.
La facturación de ecommerce vive en dos lugares. ecommerce.purchase_revenue a nivel evento y
items como registro repetido para el detalle por línea. Sumar los dos sin pensarlo duplica.
La versión corta
Una fila es un evento, los parámetros se esconden en event_params detrás de UNNEST, los
timestamps son microsegundos, y _TABLE_SUFFIX es lo único que te separa de una factura
sorprendente. Con esos cuatro hechos podés contar sesiones bien, armar un funnel con cualquier
definición de paso que quieras y comparar modelos de atribución sobre tus propios caminos — que es
el análisis que la interfaz no te va a dar a ningún precio.
Si tus reportes y tu facturación no coinciden y querés saber cuál de los dos miente, ahí es donde suelo empezar.