Los equipos de marketing en 2026 no luchan contra los datos; toman decisiones basadas en datos. GA4, Meta Ads, Google Ads, CRM, CDP, server-side GTM — cada uno deja caer información en tablas separadas. El equipo está armando spreadsheets manualmente, los números cambian cada semana, nadie confía en nada. Este caos desaparece con un stack de datos moderno: BigQuery como fuente, capa de transformación con dbt, semantic layer como red de indicadores. Versionas el código en repositorio, cada cambio se prueba, las métricas vienen de una única fuente de verdad. Este artículo muestra cómo dbt + BigQuery convierte el pipeline de datos de marketing en algo production-grade.

Mapeo de fuentes: Estandarizar rutas de datos crudos

La primera tarea de dbt es mapear fuentes — ajustar datos crudos de sistemas diferentes al mismo esquema. En BigQuery, la tabla analytics_123456.events_* viene de GA4, facebook_ads.ads_insights de la API de Meta, crm.transactions de Shopify. Cada una tiene formato de timestamp diferente, identificador de usuario distinto, columna de moneda propia. En el archivo sources.yml de dbt defines estas tablas crudas:

version: 2
sources:
  - name: ga4
    database: analytics_123456
    tables:
      - name: events_
        identifier: "events_*"
        loaded_at_field: event_timestamp
  - name: meta_ads
    database: facebook_ads
    schema: public
    tables:
      - name: ads_insights
        loaded_at_field: date_start

Esta definición le dice a dbt "estas tablas vienen de upstream, yo no las toco pero pruebo su frescura". El comando dbt source freshness verifica cuándo llegó el último dato — si la API de Meta se retrasa, genera alertas. Sin mapeo de fuentes, cada modelo escribe directo SELECT * FROM analytics_123456.events_20260614, y cuando el nombre de tabla cambia, 40 modelos se rompen. Con mapping, la referencia es {{ source('ga4', 'events_') }}, el cambio se propaga desde un único punto.

GA4 usa event_timestamp en microsegundos Unix, Meta Ads usa date_start en string ISO, CRM usa created_at en datetime UTC — cada formato diferente. En el mapeo de fuentes, extraes una columna timestamp estándar: TIMESTAMP_MICROS(event_timestamp) AS event_time en GA4, PARSE_TIMESTAMP('%Y-%m-%d', date_start) AS event_time en Meta. Esta normalización proporciona entrada limpia a los modelos downstream.

Capa de modelado: Staging, intermediate, mart

La potencia de dbt está en el modelado en capas — staging, intermediate, mart. Los modelos de staging extraen 1:1 de la fuente, solo hacen renombrado y casting de tipos. stg_ga4_events.sql:

SELECT
  TIMESTAMP_MICROS(event_timestamp) AS event_time,
  user_pseudo_id AS anonymous_id,
  event_name,
  (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'session_id') AS session_id,
  geo.country,
  device.category AS device_category
FROM {{ source('ga4', 'events_') }}
WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY))
  AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())

El staging proporciona datos limpios pero sin lógica de negocio. Los modelos intermediate agregan lógica de negocio: sesionización, atribución, pasos de funnel. En int_sessions.sql agrupas eventos de GA4 por sesión:

WITH session_events AS (
  SELECT
    session_id,
    MIN(event_time) AS session_start,
    MAX(event_time) AS session_end,
    COUNT(DISTINCT CASE WHEN event_name = 'page_view' THEN event_time END) AS pageviews,
    MAX(CASE WHEN event_name = 'purchase' THEN 1 ELSE 0 END) AS converted
  FROM {{ ref('stg_ga4_events') }}
  GROUP BY session_id
)
SELECT
  *,
  TIMESTAMP_DIFF(session_end, session_start, SECOND) AS duration_seconds
FROM session_events

Los modelos mart son la capa final de consumo — BI tool, Looker, dashboards internos miran aquí. fct_marketing_performance.sql une todos los canales, calcula spend + revenue + ROAS. Cada modelo mart se enfoca en una entidad de negocio única: dim_customers, fct_orders, fct_sessions. La convención de nombres es crítica — dim_ para dimensión (cliente, producto), fct_ para hecho (transacción, evento), rpt_ para reporte agregado.

Semantic layer: Definiciones de KPI como código

La semantic layer lleva definiciones de métricas dentro de dbt — qué es "revenue", cómo se calcula "CAC" ya no está en spreadsheet sino en YAML. Con dbt v1.6+ defines el árbol de indicadores en metrics.yml:

version: 2
metrics:
  - name: revenue
    label: Revenue
    model: ref('fct_orders')
    calculation_method: sum
    expression: order_amount
    timestamp: order_date
    time_grains: [day, week, month, quarter]
    dimensions:
      - channel
      - country
      - device_category

  - name: cac
    label: Customer Acquisition Cost
    calculation_method: derived
    expression: "{{ metric('ad_spend') }} / {{ metric('new_customers') }}"
    timestamp: acquisition_date
    time_grains: [month, quarter]

Con la semantic layer, no es la herramienta BI quien calcula CAC, lo hace dbt. Cuando Looker pide "dame CAC", dbt devuelve SQL compilado, une la tabla de spend y la tabla de nuevos clientes, divide. La definición es código, por lo que el historial de git registra "quién cambió el cálculo de CAC, y por qué". La fórmula en spreadsheet no se pierde, hay control de versiones.

En proyectos de Roibase, la semantic layer se construye como parte de análisis de datos e ingeniería de insights — no solo definición de métrica, sino mapeo de árbol de KPI, jerarquía de dimensiones, estandarización de granularidad. Por ejemplo: la métrica "revenue" es la suma de fct_orders.order_amount, pero "recognized_revenue" filtra la misma tabla por timestamp recognized_at (modelo de suscripción SaaS). Una tabla, dos métricas, lógica de negocio diferente.

Exposures: Hacer visibles las dependencias downstream

Exposure es la respuesta de dbt a la pregunta "quién usa este modelo". Si un dashboard de Looker mira la tabla fct_marketing_performance, lo defines en exposures.yml:

version: 2
exposures:
  - name: marketing_dashboard
    type: dashboard
    maturity: high
    owner:
      name: Growth Team
      email: [email protected]
    depends_on:
      - ref('fct_marketing_performance')
      - ref('dim_customers')
    description: "Dashboard de marketing ejecutivo — actualización diaria, ventana móvil 90 días"
    url: https://looker.company.com/dashboards/123

Sin definición de exposure, cuando cambias fct_marketing_performance, no sabes qué dashboard se rompe. Después de dbt run, Looker muestra métricas cero, pasas 2 horas debuggeando. Con exposure, el comando dbt compile --select +exposure:marketing_dashboard muestra todos los modelos upstream, haces análisis de impacto antes del cambio.

Exposure no es solo para herramientas BI — también para reverse ETL (Hightouch, Census). Si sincronizas la tabla customers a Meta CAPI:

exposures:
  - name: meta_capi_sync
    type: application
    maturity: high
    depends_on:
      - ref('dim_customers')
    description: "Meta Conversion API — eventos de cliente incrementales, retraso 5 minutos"

Esta definición advierte "si cambias el esquema de dim_customers, rompes el schema de evento que va a Meta". En producción, evita la cadena: actualizar modelo → error de sincronización CAPI → pérdida de datos de atribución.

Pipeline en producción: Builds incrementales y cobertura de pruebas

En producción, dbt no hace refresh completo cada día — usa modelos incrementales. fct_orders.sql solo reprocesa los últimos 3 días:

{{ config(
    materialized='incremental',
    unique_key='order_id',
    partition_by={'field': 'order_date', 'data_type': 'date'},
    cluster_by=['customer_id', 'channel']
) }}

SELECT
  order_id,
  customer_id,
  order_date,
  order_amount,
  channel
FROM {{ ref('stg_shopify_orders') }}

{% if is_incremental() %}
WHERE order_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 3 DAY)
{% endif %}

El build incremental reduce el costo de BigQuery 90% — en lugar de escanear 2TB, escanea 50GB. Partition + cluster mejoran performance: una query WHERE customer_id = 'X' va solo al cluster relevante, sin full scan.

La cobertura de pruebas es crítica. En schema.yml defines pruebas para cada modelo:

models:
  - name: fct_orders
    columns:
      - name: order_id
        tests:
          - unique
          - not_null
      - name: order_amount
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: ">= 0"
      - name: order_date
        tests:
          - dbt_utils.recency:
              datepart: day
              interval: 7

El comando dbt test ejecuta estas condiciones en BigQuery como assertions — si order_amount es negativo, el build falla. En producción, cada commit corre en CI/CD: dbt run --select state:modified+ → dbt test --select state:modified+. Ejecuta el modelo modificado + dependencias downstream, prueba todo, solo permite merge si no hay problemas.

Orchestración: Airflow, Prefect, dbt Cloud

dbt no es orquestador por sí mismo — se programa con Airflow o Prefect. Un DAG de Airflow ejemplo:

from airflow.providers.google.cloud.operators.bigquery import BigQueryInsertJobOperator
from airflow.operators.bash import BashOperator

dbt_run = BashOperator(
    task_id='dbt_run',
    bash_command='cd /opt/dbt && dbt run --profiles-dir .',
    dag=dag
)

dbt_test = BashOperator(
    task_id='dbt_test',
    bash_command='cd /opt/dbt && dbt test',
    dag=dag
)

dbt_run >> dbt_test

dbt Cloud es la alternativa — orquestación manejada, Web IDE, alertas Slack. Pero la mayoría de empresas elige Airflow porque hay más tareas además de dbt: pull de API upstream, reverse ETL downstream, tablas snapshot.

La estrategia de horario está vinculada a la frescura de datos. Los eventos de GA4 tienen retraso de 24 horas (processing_date ≠ event_date), la API Insights de Meta no es real-time. Los modelos de staging se gatillan según la frescura de la fuente — cuando GA4 entrega una nueva partición, stg_ga4_events se actualiza, la cadena intermediate → mart se propaga. Un operador sensor de Airflow verifica que BigQuery tenga la partición:

wait_for_ga4 = BigQueryTableExistenceSensor(
    task_id='wait_for_ga4_partition',
    project_id='analytics_123456',
    dataset_id='events_',
    table_id=f"events_{yesterday.strftime('%Y%m%d')}",
    poke_interval=300
)

Cuando la partición está lista, la cadena dbt comienza. Este patrón resuelve el problema de datos con retraso — en lugar de detener el pipeline por demora de API, espera.

Trade-offs: Qué dbt no resuelve

dbt es motor de transformación, no data loader. ¿Quién extrae datos a BigQuery? Fivetran, Airbyte, script Python personalizado. dbt asume en sources.yml que los datos crudos ya están ahí. Patrón ELT: Extract-Load-Transform. La diferencia con ETL es que Transform ocurre dentro del warehouse. dbt es esa capa T, EL es otra cadena de herramientas.

dbt no soporta streaming en tiempo real. Kafka → inserción streaming de BigQuery → cadena de modelo incremental de dbt agrega latencia de minutos. Si necesitas latencia sub-segundo (detección de fraude, pricing dinámico), dbt no es suficiente — necesitas procesador de streams: Flink, Spark Structured Streaming, Materialize.

El soporte de modelo Python en dbt (v1.3+) es limitado. Puedes hacer manipulación de dataframe Pandas pero no entrenas modelos ML pesados en dbt. El patrón común es: feature engineering en dbt, entrenamiento de modelo en Vertex AI, inferencia en BigQuery ML. Un modelo Python de dbt es así:

def model(dbt, session):
    df = dbt.ref('stg_orders').to_pandas()
    df['log_amount'] = np.log1p(df['order_amount'])
    return df

Solo genera features — no ajustas scikit-learn. BigQuery compute es caro, el overhead de runtime Python es alto. Transformaciones complejas son más rápidas en SQL.

Ahora qué hacer

Si tus datos de marketing aún están en spreadsheets con fusión manual, el primer paso es establecer flujo de datos crudos a BigQuery. Exporta GA4, conecta APIs de Meta/Google Ads (Fivetran/Supermetrics), webhook de CRM → inserción streaming BigQuery. Cuando los datos crudos están listos, abres repositorio dbt: modelos de staging hacen mapeo de fuentes, intermediate hace sesionización/atribución, mart genera KPI final. Las primeras 2 semanas necesitas solo tabla fct_sessions y fct_orders — los dashboards apuntan aquí, las métricas se estabilizan. Semantic layer llega en semana 3, mapping de exposures en semana 4. En 6 semanas, el pipeline en producción corre git-controlled, test-covered, optimizado