DuckDB en dev, Snowflake en prod: un flujo de trabajo práctico de dbt para un proyecto medallion

El adapter de DuckDB para Fusion puede hacer que un proyecto grande de dbt se sienta local y rápido, pero no convierte a DuckDB en un equivalente de Snowflake. Aquí encontrarás un flujo de trabajo práctico con dos motores para una aplicación existente, que incluye fixtures de datos, portabilidad de modelos, controles de CI, modos de falla y solución de problemas.

40 min de lectura

“DuckDB en dev. Snowflake en prod. Lo mejor de ambos mundos.” Es una premisa atractiva. dbt anunció soporte beta para DuckDB en la CLI de Fusion en April 2026. Al July 15, 2026, seguía siendo una opción beta para ejecutar un proyecto de dbt contra una base de datos embebida sin obtener primero credenciales del warehouse.

La versión útil de la idea es más precisa:

Usa DuckDB como un ciclo rápido de desarrollo local. Conserva un ciclo de validación más acotado en Snowflake como control de compatibilidad con producción.

DuckDB puede validar una gran parte de la lógica de transformación de forma rápida y económica. No puede demostrar que el SQL específico de Snowflake, el comportamiento incremental, las políticas de seguridad, los identificadores, las zonas horarias o el rendimiento físico funcionarán correctamente. Fusion mejora el análisis del código; junto con la extensión de dbt para VS Code, también agrega feedback en el editor y linaje a nivel de columna. No es una capa de traducción entre dialectos SQL.

Este artículo desarrolla un ejemplo representativo de una aplicación Idea existente en la que los usuarios envían ideas, votan, comentan y generan eventos de producto. Su proyecto de analítica tiene muchos modelos de dbt organizados en capas bronze, silver y gold. El ejemplo es intencionalmente concreto, pero el flujo de trabajo se aplica a otros proyectos maduros de dbt.

Audiencia: ingenieros y arquitectos responsables del desarrollo de analítica, CI, seguridad de datos y costos del warehouse.

En resumen

  • Al July 15, 2026, el soporte de DuckDB en la CLI de dbt Fusion está en beta. Una misma base de código de dbt puede apuntar a DuckDB local y a Snowflake en producción, pero eso no implica que el SQL compilado ni el comportamiento en tiempo de ejecución sean idénticos.
  • No apuntes los modelos bronze directamente a read_parquet() o read_csv() si quieres un análisis estático confiable de Fusion. Primero carga fixtures sanitizados en tablas de DuckDB con la estructura de las fuentes y luego deja que dbt lea esas tablas mediante llamadas normales a source().
  • Para un grafo medallion grande, usa un archivo .duckdb persistente, construye una vez el grafo portable completo y después usa selección por estado para los modelos modificados. Un manifest de Snowflake de producción puede ayudar a identificar cambios de código, pero --defer entre motores no hace que DuckDB pueda consultar las relaciones de Snowflake.
  • Aísla las expresiones específicas de cada base de datos detrás de macros con dispatch y etiqueta los modelos que sean realmente exclusivos de Snowflake para excluirlos de forma deliberada en las ejecuciones locales.
  • Trata el soporte de modelos incrementales como dependiente de la versión: prueba la lógica portable en DuckDB y luego prueba el comportamiento de materialización de producción en Snowflake.
  • Mantén una pirámide de pruebas: pruebas unitarias de dbt y un build portable en DuckDB para obtener feedback rápido; después, Snowflake Slim CI o un clon gobernado para verificar la paridad con producción.
  • El cómputo local no es automáticamente gratuito. Exportar fixtures consume cómputo de Snowflake y puede generar cargos por transferencia entre regiones o nubes, según la ruta de descarga.

Qué aprenderás aquí

  • Qué cambia con el soporte de DuckDB en dbt Fusion y qué no cambia
  • Cómo adaptar un proyecto existente de dbt con capas bronze/silver/gold a DuckDB local
  • Cómo crear datos locales sanitizados con la estructura de las fuentes sin reescribir las referencias de los modelos
  • Cómo organizar perfiles, macros con dispatch por adapter, etiquetas de modelos y selección por estado
  • Qué verificaciones corresponden a DuckDB y cuáles deben seguir ejecutándose en Snowflake
  • Las fallas más probables de portabilidad, datos, seguridad, concurrencia y rendimiento
  • Una guía de solución de problemas que relaciona síntomas con diagnósticos

Qué cambió en April 2026 y cuál era la situación en julio

El patrón de DuckDB local no es nuevo. El adapter comunitario dbt-duckdb ya permitía ejecutar proyectos de dbt Core contra DuckDB. La novedad es que DuckDB ahora está integrado en la CLI de Fusion. La CLI proporciona el flujo de análisis y ejecución del motor de Rust; el linaje a nivel de columna y la experiencia más completa en el editor requieren la extensión de dbt para VS Code.

La página oficial de configuración de DuckDB destaca cuatro límites importantes:

  1. El adapter de DuckDB para Fusion está en beta.
  2. Todavía no tiene paridad completa de funcionalidades con el adapter existente dbt-duckdb.
  3. El driver de DuckDB incluido con Fusion no puede cargar extensiones como httpfs, parquet o spatial; para usarlas se necesita un driver de DuckDB instalado en el sistema mediante la CLI dbc.
  4. Es posible que el análisis estático no infiera los esquemas de archivos locales CSV, Parquet o JSON a los que se accede mediante read_csv(), read_parquet() o read_json(). Por lo tanto, una consulta puede funcionar en tiempo de ejecución mientras Fusion informa una advertencia de resolución de tipos o un error de compilación.

dbc es una CLI para instalar drivers ADBC; no es otro adapter de dbt:

dbc install duckdb

Instala la versión preliminar actual con el comando de la guía de inicio rápido oficial y luego fija la versión exacta que funciona en la imagen de desarrollo y en CI del equipo:

python -m pip install --pre dbt
dbt --version

Las versiones preliminares cambian rápido. No copies un marcador de posición variable como 2.0.0-preview.x en un build reproducible.

Elige deliberadamente la herramienta de ejecución local:

SituaciónHerramienta local recomendadaMotivo
Proyecto nuevo que acepta el riesgo de una versión preliminarCLI de Fusion con versión fijada + DuckDBUn solo motor para análisis y ejecución; agrega la extensión de dbt para VS Code para integrar el editor
Flujo estable existente con dbt-duckdb que usa funciones de las que Fusion careceConserva dbt Core + dbt-duckdb; prueba Fusion por separadoEvita convertir una evaluación del adapter en una migración
Se necesitan extensiones de DuckDBFusion con el driver del sistema instalado por dbcEl driver incluido no puede cargar extensiones
Se necesitan linaje de columnas y feedback detallado en el editorFusion más la extensión de dbt para VS CodeLa CLI por sí sola no expone esas funciones del editor
Validación de paridad con producciónTarget de Snowflake en CIDuckDB no puede demostrar el comportamiento de Snowflake en tiempo de ejecución

Si se necesitan ambas herramientas locales durante la migración, fija sus versiones en entornos virtuales o contenedores separados. No permitas que dos instalaciones compitan por el mismo comando dbt.

El adapter reduce la fricción de conexión; no elimina el segundo motor de ejecución:

dbt project + Jinja + DAG
              │
      ┌───────┴────────┐
      │                │
      ▼                ▼
 Fusion + DuckDB    Fusion + Snowflake
 local relations    warehouse relations
 DuckDB SQL          Snowflake SQL
 local resources     Snowflake policies and compute
      │                │
 fast logic signal   production-parity signal

El dispatch de adapters de dbt existe precisamente porque la sintaxis SQL, los tipos de datos y el comportamiento admitido difieren entre plataformas de datos. Fusion puede entender ambos targets sin hacerlos equivalentes.

El caso de la aplicación Idea existente

Supongamos que la aplicación ya tiene:

  • tablas transaccionales PostgreSQL para users, ideas, votes y comments
  • eventos de producto entregados como JSON
  • una fuente de facturación con planes de workspace
  • jobs de ingesta que depositan datos de origen en Snowflake
  • aproximadamente 140 modelos de dbt acumulados con el tiempo

La cantidad exacta es ilustrativa, no un benchmark. Lo importante es la estructura del grafo:

CapaModelos de ejemploResponsabilidadExpectativa local
Bronzestg_app__ideas, stg_app__votes, stg_events__idea_viewedRenombrar, convertir tipos y conservar metadatos de origenPortable, salvo los metadatos específicos de la ingesta
Silverint_idea_vote_rollup, int_idea_lifecycle, dim_users, fct_idea_eventsDeduplicar, unir, validar la granularidad y derivar entidades reutilizablesMayormente portable
Goldmart_idea_engagement, mart_workspace_adoption, mart_trending_ideasMétricas de negocio y agregados para el productoLógica portable; la optimización física puede ser exclusiva de Snowflake
Específica de la plataformavistas seguras, hooks de políticas de enmascaramiento, Snowpark o funciones externasGobernanza y capacidades nativas del warehouseSolo Snowflake

Los nombres medallion describen una calidad creciente: bronze es datos sin procesar, silver está validada e integrada, y gold está lista para el negocio. No requieren una base de datos particular. Esto hace portables las capas lógicas, aunque su ingesta e implementación física pueden seguir siendo específicas de cada plataforma.

Comienza la prueba piloto con un flujo vertical completo en lugar de los 140 modelos:

dbt build --target duckdb --select +mart_idea_engagement

El + inicial selecciona los ancestros del mart y obliga a la prueba piloto a ejercitar conjuntamente sus fuentes raw, la preparación bronze, la agregación de votos silver y el resultado gold.

Arquitectura objetivo

                         EXISTING PRODUCTION PATH

 Postgres ─┐
 Events  ──┼─> ingestion ─> Snowflake RAW ─> bronze ─> silver ─> gold
 Billing ──┘                                  dbt on Snowflake
                                                    │
                                              dashboards / ML

                         LOCAL DEVELOPMENT PATH

 sanitized, versioned source snapshot
                │
                ▼
      bootstrap raw DuckDB tables
                │
                ▼
       bronze ─> silver ─> gold
          dbt Fusion on DuckDB
                │
       unit + data-test feedback

 Pull request ─> DuckDB check ─> Snowflake parity check ─> merge

La ruta local no reemplaza la ingesta ni copia el warehouse. Proporciona un contrato de datos pequeño, con la estructura de producción, al mismo grafo de dbt.

Paso 1: Mantén un único contrato lógico de fuentes

Los modelos de dbt deben seguir usando fuentes lógicas estables:

# models/sources/app.yml
version: 2

sources:
  - name: app
    database: "{{ env_var('DBT_RAW_DATABASE', 'idea_local') }}"
    schema: raw
    tables:
      - name: users
      - name: ideas
      - name: votes
      - name: comments
      - name: product_events

En desarrollo local, idea_local es el nombre base de idea_local.duckdb. En Snowflake CI y producción, DBT_RAW_DATABASE apunta a la base de datos raw gobernada. El SQL del modelo permanece sin cambios:

select
    idea_id,
    workspace_id,
    title,
    status,
    created_at,
    updated_at,
    _ingested_at
from {{ source('app', 'ideas') }}

No dividas el grafo en copias models/duckdb/ y models/snowflake/. Eso crea dos productos que divergen con el tiempo. Mantén un solo grafo y aísla únicamente las expresiones o materializaciones que realmente difieren.

Paso 2: Inicializa tablas locales con la estructura de las fuentes

Para un proyecto maduro, el límite local más claro es un conjunto de tablas físicas de DuckDB que coincidan con el contrato de las fuentes raw de Snowflake.

-- scripts/bootstrap_local.sql
create schema if not exists raw;

create or replace table raw.ideas as
select
    idea_id::varchar as idea_id,
    workspace_id::varchar as workspace_id,
    title::varchar as title,
    status::varchar as status,
    created_at::timestamp as created_at,
    updated_at::timestamp as updated_at,
    _ingested_at::timestamp as _ingested_at
from read_parquet('fixtures/raw/ideas.parquet');

create or replace table raw.votes as
select *
from read_parquet('fixtures/raw/votes.parquet');

Ejecuta esta inicialización con un cliente de DuckDB que pueda leer el formato de archivo elegido:

export DUCKDB_PATH="$(pwd)/local/idea_local.duckdb"
export DUCKDB_TEMP_DIRECTORY="$(pwd)/local/tmp"
mkdir -p local/tmp
duckdb "${DUCKDB_PATH}" < scripts/bootstrap_local.sql

Esta capa intermedia importa. Evita la limitación documentada del análisis de lectores de archivos planos de Fusion al presentar a dbt fuentes de tablas comunes. También impide que las rutas de archivos y la mecánica de los fixtures se filtren a los modelos de producción.

Usa tres tamaños de datos para distintas tareas:

DatasetContenidoUbicaciónPropósito
Fixtures deterministas pequeñosValores nulos, duplicados, actualizaciones tardías, estados inválidos y casos límite de zonas horarias seleccionados a manoBajo control de versiones cuando la política lo permitaPruebas unitarias y de regresión de lógica
Snapshot sanitizado de las fuentesMuestra pequeña pero consistente en sus relaciones entre todas las tablas rawAlmacén de artefactos con control de acceso o generación localDAG portable completo
Datos gobernados del warehouseEsquema de desarrollo enmascarado, relaciones padre de producción mediante defer o clon autorizadoSolo SnowflakeValidación de dialecto, materialización, políticas y escala

Un error común es tomar muestras aleatorias de cada tabla de forma independiente. Esto destruye las relaciones de claves foráneas: los votos muestreados ya no tienen ideas correspondientes en la muestra, y las ideas muestreadas ya no tienen usuarios correspondientes. Toma la muestra a partir de entidades raíz del negocio como workspace_id y luego incluye las filas relacionadas de cada fuente.

Para datos sensibles, el pipeline de inicialización debe eliminar las columnas que no se usan, tokenizar las claves de unión estables, generalizar las marcas de tiempo cuando sea necesario y publicar un manifest con verificaciones de esquema y conteo de filas. Una laptop no debe convertirse en una réplica de producción sin gobernanza.

Genera fixtures a partir de entidades raíz del negocio

El generador de fixtures debe conservar las relaciones entre tablas. El siguiente pseudocódigo con sintaxis de Snowflake selecciona un conjunto determinista de workspaces y luego exporta cada entidad relacionada. secure_token_v1 representa una función de tokenización con clave, propiedad de la organización, no un hash público simple:

create or replace transient table fixture_roots as
select workspace_id
from idea_raw.raw.workspaces
where mod(abs(hash(workspace_id)), 1000) < 5
qualify row_number() over (order by workspace_id) <= 100;

create or replace transient table fixture_ideas as
select
    secure_token_v1(i.idea_id) as idea_id,
    secure_token_v1(i.workspace_id) as workspace_id,
    concat('Idea ', row_number() over (order by i.idea_id)) as title,
    i.status,
    date_trunc('day', i.created_at) as created_at,
    date_trunc('day', i.updated_at) as updated_at,
    i._ingested_at
from idea_raw.raw.ideas i
join fixture_roots r using (workspace_id);

create or replace transient table fixture_votes as
select
    secure_token_v1(v.vote_id) as vote_id,
    secure_token_v1(v.idea_id) as idea_id,
    secure_token_v1(v.user_id) as user_id,
    v.voted_at,
    v._ingested_at
from idea_raw.raw.votes v
join idea_raw.raw.ideas i using (idea_id)
join fixture_roots r using (workspace_id);

copy into @dev_fixture_stage/idea-app/2026-07-15.1/ideas/
from fixture_ideas
file_format = (type = parquet compression = snappy)
header = true
overwrite = true;

Debe usarse la misma versión de tokenización para una clave en todos los lugares donde aparezca. Repite la descarga para cada tabla de fixtures aprobada. Mantén el stage con control de acceso y versiona la ruta para que los desarrolladores y CI puedan reproducir la misma entrada.

Publica un manifest pequeño con cada conjunto de fixtures:

fixture_version: "2026-07-15.1"
source_snapshot_at: "2026-07-15T00:00:00Z"
sampling_root: workspace_id
sanitizer_version: secure_token_v1
tables:
  raw.ideas: {rows: 842, schema_md5: "..."}
  raw.votes: {rows: 3217, schema_md5: "..."}

Después de la inicialización local, compara los conteos de filas observados y los hashes de esquema con este manifest y detén el proceso antes de ejecutar dbt si no coinciden. El publicador de fixtures puede generar un script de validación con los valores esperados:

select case
    when count(*) = 842 then true
    else error('raw.ideas row count does not match fixture manifest')
end as ideas_row_count_ok
from raw.ideas;

with observed as (
    select md5(
        string_agg(
            column_name || ':' || data_type,
            ',' order by column_index
        )
    ) as schema_md5
    from duckdb_columns()
    where database_name = current_database()
      and schema_name = 'raw'
      and table_name = 'ideas'
)
select case
    when schema_md5 = '<manifest schema_md5>' then true
    else error('raw.ideas schema does not match fixture manifest')
end as ideas_schema_ok
from observed;

Ejecuta estas aserciones dentro de bootstrap_local.sql para que una descarga parcial o un esquema de fixtures desactualizado detenga el flujo antes de que dbt comience.

El responsable de plataforma o de gobernanza de datos debe encargarse de la extracción y la sanitización. Los ingenieros de analítica deben encargarse de las filas diseñadas deliberadamente para casos límite. Actualiza el snapshot cuando cambien los contratos de las fuentes o cuando ya no represente distribuciones importantes, no en cada ejecución local.

La fidelidad necesaria de los fixtures también varía según la capa: bronze necesita casos límite malformados y específicos de las fuentes; silver necesita integridad de las relaciones y de la granularidad; gold necesita suficiente cardinalidad y sesgo en los grupos para ejercitar las métricas de negocio.

Paso 3: Define los targets de DuckDB y Snowflake

Mantén las credenciales fuera del repositorio, normalmente en ~/.dbt/profiles.yml o en un secreto administrado:

idea_analytics:
  target: "{{ env_var('DBT_TARGET', 'duckdb') }}"
  outputs:
    duckdb:
      type: duckdb
      path: "{{ env_var('DUCKDB_PATH', './local/idea_local.duckdb') }}"
      schema: analytics
      threads: 4
      settings:
        memory_limit: "4GB"
        temp_directory: "{{ env_var('DUCKDB_TEMP_DIRECTORY', './local/tmp') }}"

    snowflake:
      type: snowflake
      account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
      user: "{{ env_var('SNOWFLAKE_USER') }}"
      private_key_path: "{{ env_var('SNOWFLAKE_PRIVATE_KEY_PATH') }}"
      role: TRANSFORMER
      database: IDEA_ANALYTICS
      warehouse: DBT_CI
      schema: "DBT_{{ env_var('CI_RUN_ID', 'DEV') }}"
      threads: 8

Luego verifica los targets por separado:

export DUCKDB_PATH="$(pwd)/local/idea_local.duckdb"
export DUCKDB_TEMP_DIRECTORY="$(pwd)/local/tmp"
DBT_TARGET=duckdb dbt debug --target duckdb
DBT_TARGET=snowflake dbt debug --target snowflake

DuckDB resuelve las rutas relativas de bases de datos a partir de la ubicación de profiles.yml, que puede ser ~/.dbt en lugar del proyecto. Un DUCKDB_PATH absoluto garantiza que el comando de inicialización y dbt apunten al mismo archivo.

Usa en Snowflake CI una opción sin contraseña compatible con Fusion, como la autenticación con par de claves. Si quieres usar federación de identidad de cargas de trabajo, confirma primero que el driver de Fusion con versión fijada y el método de autenticación la admitan. El perfil anterior solo muestra dónde colocar los campos respaldados por secretos.

Para un grafo grande, es preferible un archivo .duckdb persistente a :memory:. Una base de datos en memoria empieza vacía en cada invocación, por lo que comandos separados de dbt run y dbt test no pueden depender de relaciones padre materializadas por un proceso anterior.

Paso 4: Define un límite de portabilidad

La mayor parte de la lógica de selección, unión, filtrado y ventanas se puede compartir. La sintaxis específica de cada base de datos debe ser explícita, no aparecer accidentalmente.

Por ejemplo, la extracción de JSON difiere entre Snowflake y DuckDB. Aísla esa diferencia detrás del dispatch por adapter:

El ejemplo supone que el name del proyecto en dbt_project.yml es idea_analytics; reemplaza ese namespace de dispatch por el nombre real del proyecto o paquete.

-- macros/json_string.sql
{% macro json_string(column_name, json_path) %}
  {{ return(adapter.dispatch('json_string', 'idea_analytics')(column_name, json_path)) }}
{% endmacro %}

{% macro snowflake__json_string(column_name, json_path) %}
  get_path(
    parse_json({{ column_name }}),
    {{ dbt.string_literal(json_path) }}
  )::varchar
{% endmacro %}

{% macro duckdb__json_string(column_name, json_path) %}
  json_extract_string(
    {{ column_name }},
    {{ dbt.string_literal('$.' ~ json_path) }}
  )
{% endmacro %}

Este ejemplo define payload como texto JSON en el límite compartido. El modelo conserva la intención de negocio:

-- models/silver/int_event_idea_ids.sql
select
    event_id,
    {{ json_string('payload', 'idea_id') }} as idea_id,
    occurred_at
from {{ source('app', 'product_events') }}

Prefiere las macros multibase de datos existentes de dbt para operaciones comunes como aritmética de fechas, conversiones seguras de tipos, funciones de cadenas y nombres de tipos. Agrega dispatch en el proyecto solo cuando la abstracción integrada no cubra el requisito.

Este patrón mejora la mantenibilidad, pero genera dos expresiones compiladas. Las pruebas compartidas deben verificar que ambas implementaciones produzcan el mismo resultado.

unit_tests:
  - name: extracts_idea_id_from_json_text
    model: int_event_idea_ids
    given:
      - input: source('app', 'product_events')
        rows:
          - event_id: e1
            payload: '{"idea_id":"i1"}'
            occurred_at: '2026-07-15 10:00:00'
    expect:
      rows:
        - event_id: e1
          idea_id: i1
          occurred_at: '2026-07-15 10:00:00'

Ejecuta la misma prueba en ambos targets. Que pase en DuckDB no dice nada sobre la implementación seleccionada para Snowflake mediante dispatch:

dbt test --target duckdb --select extracts_idea_id_from_json_text
dbt test --target snowflake --select extracts_idea_id_from_json_text

Esta prueba cubre una ruta que devuelve un valor escalar. Agrega casos explícitos para una clave ausente, JSON null, SQL NULL, arreglos, números y objetos anidados antes de tratar la macro como una capa general de compatibilidad JSON.

Etiqueta los modelos cuyo comportamiento no se pueda reproducir localmente:

{{ config(tags=['snowflake_only']) }}

select
    idea_id,
    ai_complete('model-name', moderation_prompt) as moderation_result
from {{ ref('ideas_requiring_moderation') }}

Ejecuta el grafo portable de forma explícita:

dbt build --target duckdb --exclude tag:snowflake_only+

No reemplaces un modelo exclusivo de Snowflake con SQL local ficticio que siempre pase. Proporciona un stub de contrato significativo en un límite claramente definido o excluye el modelo y valídalo en Snowflake.

El límite definido por la etiqueta también debe ser válido en el DAG. Excluir una relación padre exclusiva de Snowflake mientras se seleccionan sus descendientes portables deja una referencia a una relación que la ejecución local no construyó. Excluye esa rama completa o proporciona una relación de límite probada con el mismo contrato lógico.

Inspecciona y excluye la rama completa exclusiva de Snowflake, incluidos sus descendientes:

dbt ls --select tag:snowflake_only+
dbt build --target duckdb --exclude tag:snowflake_only+

Si un mart portable realmente necesita uno de esos descendientes, el límite requiere una implementación local real o una relación de contrato respaldada por fixtures. Una etiqueta por sí sola no puede hacer portable la dependencia.

Paso 5: Haz que un grafo grande sea rápido

En una copia recién obtenida del repositorio:

export DUCKDB_PATH="$(pwd)/local/idea_local.duckdb"
export DUCKDB_TEMP_DIRECTORY="$(pwd)/local/tmp"
mkdir -p local/tmp
duckdb "${DUCKDB_PATH}" < scripts/bootstrap_local.sql
dbt build --target duckdb --exclude tag:snowflake_only+
mkdir -p .dbt-state/local
cp target/manifest.json .dbt-state/local/manifest.json

Durante la iteración normal:

dbt build \
  --target duckdb \
  --select state:modified+ \
  --state .dbt-state/local \
  --exclude tag:snowflake_only+

Esto funciona cuando las relaciones ascendentes que no cambiaron todavía están presentes en el archivo persistente de DuckDB. Si el archivo es nuevo o falta una relación ascendente, incluye los ancestros:

dbt build \
  --target duckdb \
  --select +state:modified+ \
  --state .dbt-state/local \
  --exclude tag:snowflake_only+

No agregues --defer de producción al comando de DuckDB sin evaluar sus consecuencias. El defer de dbt resuelve las llamadas a ref() no construidas hacia relaciones de otro manifest. Una relación de Snowflake nombrada en un manifest de producción no se vuelve legible para un proceso local de DuckDB. Para la ejecución local, usa un manifest de DuckDB construido desde la rama de comparación y relaciones locales.

Un manifest de Snowflake todavía puede ser útil para inspeccionar cambios lógicos, pero la configuración de base de datos, esquema, alias, env_var y var que depende del entorno puede generar resultados inesperados de state:modified entre targets. dbt documenta estas salvedades de la comparación de estado. Inspecciona el conjunto seleccionado antes de tratar un manifest de otro motor como fuente definitiva.

Paso 6: Prueba la lógica y el estado incremental por separado

Las pruebas unitarias de dbt son adecuadas para reglas de transformación acotadas porque usan entradas estáticas. Un modelo de ciclo de vida puede probar transiciones inválidas sin construir el grafo completo:

unit_tests:
  - name: closed_ideas_cannot_return_to_review
    model: int_idea_lifecycle
    given:
      - input: ref('stg_app__ideas')
        rows:
          - {idea_id: i1, status: closed, updated_at: '2026-07-10 09:00:00'}
      - input: ref('stg_app__idea_status_events')
        rows:
          - {idea_id: i1, status: in_review, occurred_at: '2026-07-10 10:00:00'}
    expect:
      rows:
        - {idea_id: i1, effective_status: closed}

Ejecuta la capa rápida de pruebas unitarias:

dbt test --target duckdb --select test_type:unit

El conocimiento de columnas integrado en dbt v2 elimina el requisito de dbt Core v1 de materializar cada relación padre directa antes de las pruebas unitarias. Si la versión preliminar exacta todavía solicita una relación, especialmente para un modelo incremental, crea primero relaciones padre que contengan solo el esquema con dbt run --empty.

Luego construye y ejecuta pruebas de datos contra las relaciones locales:

dbt build --target duckdb --exclude tag:snowflake_only+

Los modelos incrementales necesitan una prueba adicional con estado. Los adapters maduros dbt-duckdb y dbt-snowflake anuncian soporte para merge y delete+insert, y dbt documenta que la mecánica incremental varía según la base de datos. El adapter de DuckDB para Fusion está en beta y todavía no promete paridad completa con dbt-duckdb, así que verifica cada estrategia requerida contra la versión preliminar exacta fijada antes de adoptarla.

{{ config(
    materialized='incremental',
    unique_key='event_id',
    incremental_strategy='merge'
) }}

select
    event_id,
    idea_id,
    event_type,
    occurred_at,
    _ingested_at,
    _source_sequence
from {{ ref('stg_events__idea_events') }}

{% if is_incremental() %}
where _ingested_at >= (
    select coalesce(
        {{ dbt.dateadd(
            datepart="day",
            interval=-3,
            from_date_or_timestamp="max(_ingested_at)"
        ) }},
        timestamp '1970-01-01'
    )
    from {{ this }}
)
{% endif %}

qualify row_number() over (
    partition by event_id
    order by _ingested_at desc, _source_sequence desc
) = 1

La superposición de tres días es ilustrativa. Define el período de revisión retrospectiva según el nivel de servicio de llegada tardía de la fuente, o usa un offset persistente de la fuente. Una marca de agua estricta de > max(_ingested_at) puede omitir filas que comparten la marca de tiempo máxima o llegan tarde. El ejemplo supone que la ingesta proporciona un _source_sequence estable; si no existe un criterio de desempate determinista, rechaza los duplicados ambiguos en lugar de dejar que row_number() elija uno arbitrariamente.

Ejercita al menos estos casos en cada motor:

  1. Target vacío y carga inicial
  2. Nueva ejecución del mismo lote
  3. Un evento que llega tarde
  4. Una fila actualizada con el mismo unique_key
  5. Filas de origen duplicadas para un mismo unique_key
  6. Un cambio de esquema

Si la versión preliminar actual de DuckDB para Fusion no admite la estrategia requerida, usa DuckDB para probar el SQL de refresco completo del modelo y conserva las pruebas de materialización incremental en Snowflake. No presentes una reconstrucción de tabla local como evidencia de que un merge de producción funciona.

Con ERROR_ON_NONDETERMINISTIC_MERGE=TRUE —el valor predeterminado de Snowflake—, Snowflake genera un error cuando varias filas de origen coinciden con una fila de destino y hacen que el resultado de una actualización o de una combinación de actualización y eliminación sea no determinista. Las filas de origen duplicadas sin coincidencia todavía pueden insertarse por separado. Cambiar a delete+insert puede modificar el comportamiento, pero no debe ocultar una granularidad incorrecta. Agrega una prueba de unicidad y corrige primero la lógica de origen.

El diseño de CI: primero rapidez, luego fidelidad

El flujo de trabajo práctico es una pirámide de pruebas, no un reemplazo de motor:

VerificaciónControl de DuckDBControl de Snowflake
Lógica portable de selección, unión y ventanasSeñal rápida principalConfirmar en modelos modificados
Paridad del SQL con dispatchEjecutar la implementación de DuckDBEjecutar la implementación de Snowflake
Mecánica de merge incrementalDependiente de la versión, señal parcialVerificación definitiva
RBAC, enmascaramiento y políticas de acceso a filasNo cubiertoVerificación definitiva
Poda de microparticiones, clustering y dimensionamiento del warehouseNo cubiertoVerificación definitiva
Every save / local command
  └─ Fusion parsing + selected DuckDB model
     (+ editor and column lineage with the dbt VS Code extension)

Every commit
  └─ dbt unit tests + portable DuckDB graph on deterministic fixtures

Every pull request
  └─ Snowflake Slim CI for state:modified+ and downstream tests

Before production deployment
  └─ Snowflake-native models, policies, hooks, incremental paths,
     query plans, and required scale checks

Un pipeline de PR ilustrativo:

# Gate 1: fast and warehouse-independent
export DUCKDB_BASENAME="ci_${CI_RUN_ID}"
export DUCKDB_PATH="$(pwd)/local/${DUCKDB_BASENAME}.duckdb"
export DUCKDB_TEMP_DIRECTORY="$(pwd)/local/tmp"
export DBT_RAW_DATABASE="${DUCKDB_BASENAME}"
mkdir -p local/tmp
duckdb "${DUCKDB_PATH}" < scripts/bootstrap_local.sql
DBT_TARGET=duckdb dbt build \
  --target duckdb \
  --exclude tag:snowflake_only+

# Gate 2: real target semantics
DBT_TARGET=snowflake dbt build \
  --target snowflake \
  --select state:modified+ \
  --defer \
  --state path/to/production-artifacts

El segundo control sigue el patrón Slim CI de dbt: construye los modelos modificados y sus dependencias descendentes en un esquema aislado de Snowflake, mientras resuelve las relaciones padre no seleccionadas a partir de un manifest anterior de producción.

Para pruebas de alta fidelidad, Snowflake también documenta la clonación sin copia de bases de datos o esquemas. Un clon puede exponer datos con la estructura de producción sin duplicar todo el almacenamiento, pero CI sigue consumiendo cómputo del warehouse y almacenando las microparticiones modificadas. El acceso a los datos clonados también sigue siendo una decisión de gobernanza.

El objetivo de costos debe ser menos trabajo en el warehouse, no “nada de Snowflake en ningún lugar”. Un control de Snowflake pequeño y dirigido conserva la semántica de producción mientras reduce el trabajo del warehouse frente a ejecutar el grafo completo de Snowflake en cada edición.

Dónde suele fallar la portabilidad

RiesgoPor qué puede pasar en DuckDBQué lo detecta
Funciones y sintaxis SQLEl dialecto de DuckDB se basa en PostgreSQL; Snowflake tiene funciones y sintaxis semiestructurada diferentesMacros multibase de datos o con dispatch, más CI en Snowflake
Identificadores y uso de comillasSnowflake convierte a mayúsculas los identificadores sin comillas y conserva las mayúsculas y minúsculas de los que llevan comillas; las relaciones locales pueden usar otra capitalizaciónPolítica explícita de nombres y compilación o build en Snowflake
Marcas de tiempoAmbos motores tienen comportamiento de zona horaria dependiente de la sesión, pero sus tipos y precisión de marcas de tiempo difierenConfigurar UTC explícitamente, convertir tipos de forma deliberada y probar fixtures de horario de verano y límites de fecha
Precisión numéricaLos fixtures pequeños pueden no revelar desbordamientos decimales, redondeos o conversiones implícitasConversiones explícitas a decimal(p,s) y valores límite en ambos motores
JSON y datos anidadosLas funciones JSON/VARIANT de DuckDB y VARIANT/FLATTEN de Snowflake no son intercambiablesMacros con dispatch y fixtures anidados representativos
Materializaciones incrementalesEl merge generado por el adapter, las relaciones temporales y el comportamiento ante cambios de esquema difierenPruebas de dos ejecuciones y de datos tardíos en Snowflake
Hooks y paquetesUn paquete puede asumir un adapter, comportamiento de Python, catálogo o formato de logs específicoVerificación de preparación para Fusion y CI específica por target
Políticas de seguridadDuckDB local no aplica RBAC, enmascaramiento, políticas de acceso a filas ni comportamiento de vistas seguras de SnowflakePruebas de políticas y rol de mínimo privilegio en Snowflake
Rendimiento físicoLa velocidad de una laptop no predice la poda de microparticiones, el clustering, el dimensionamiento del warehouse ni el uso de disco por desbordamiento en SnowflakeHistorial o perfil de consultas y prueba de escala en Snowflake
Actualidad de las fuentesUn snapshot local estático no ejercita el retraso de ingesta ni los metadatos actuales de las fuentesVerificaciones de actualidad en producción
Distribución de datosLas muestras pequeñas ocultan sesgos, patrones poco frecuentes de valores nulos y joins de alta cardinalidadFixtures estratificados más pruebas a escala de warehouse
ConcurrenciaUn proceso local puede parecer saludable mientras los jobs de CI compiten por un mismo archivo de DuckDBUn archivo de base de datos por job y workspaces aislados

Guía de solución de problemas

Comienza con el diagnóstico más acotado:

SíntomaPrimera verificación
Error de resolución de tipos de archivo planoConfirma que el modelo lea una tabla inicializada, no read_parquet()
Relación no encontradaCompara el nombre base de DuckDB, la base de datos de la fuente, el esquema y el resultado de la inicialización
Pasa localmente, pero falla la sintaxis en SnowflakeCompara el SQL compilado para cada target y las macros con dispatch
Error de bloqueo de archivoCierra los otros procesos de escritura y usa un archivo por job de CI
El conteo no coincideRevisa la versión de los fixtures, los valores nulos, la zona horaria, la precisión y el estado incremental
Error de merge en SnowflakePrueba la unicidad en la granularidad declarada de unique_key

“Fusion no puede resolver el tipo de read_parquet()”

Causa probable: el análisis estático actual de Fusion no puede inferir el esquema del archivo plano, aunque DuckDB pueda ejecutar la consulta.

Solución:

  1. Carga el archivo en una tabla física raw durante la inicialización.
  2. Referencia esa tabla mediante source().
  3. Si se necesita una extensión, instala el driver de DuckDB del sistema con dbc install duckdb; el driver incluido con Fusion no puede cargar extensiones.
  4. Si el análisis estricto bloquea SQL de lectura de archivos que por lo demás es válido, cambia solo la rama afectada a static_analysis: baseline. El modo baseline todavía puede emitir advertencias y no proporciona comprobación estricta de tipos ni linaje detallado de columnas.

“El modelo pasa localmente, pero Snowflake indica una función desconocida o un error de sintaxis”

Causa probable: SQL específico del warehouse se filtró a un modelo supuestamente portable.

Solución:

  • Inspecciona el SQL compilado para ambos targets.
  • Reemplaza la expresión por una macro multibase de datos de dbt cuando esté disponible.
  • De lo contrario, agrega implementaciones snowflake__... y duckdb__... con dispatch.
  • Agrega un fixture que verifique resultados iguales y conserva el control de Snowflake para los PR.

“No se encuentra la tabla de origen en DuckDB”

Causa probable: el database de la fuente no coincide con el nombre base del archivo de DuckDB, la inicialización usó otro esquema o una ruta relativa se resolvió desde un directorio de perfiles diferente.

Solución:

dbt debug --target duckdb
duckdb "${DUCKDB_PATH}" \
  -c "select database_name, schema_name, table_name from duckdb_tables();"

Alinea DBT_RAW_DATABASE, el nombre base de .duckdb, el esquema de la fuente y el resultado de la inicialización. Prefiere una ruta absoluta de DuckDB en CI.

“No se pudo bloquear el archivo de DuckDB”

Causa probable: otro proceso tiene el archivo abierto para escritura. El archivo nativo de DuckDB admite un proceso de lectura y escritura a la vez, aunque ese proceso puede usar varios hilos.

Solución:

  • Cierra los clientes interactivos de DuckDB antes de ejecutar dbt.
  • Asigna a cada job de CI su propia ruta de base de datos.
  • No compartas un archivo .duckdb con permiso de escritura entre contenedores ni en un sistema de archivos de red.
  • Mantén la concurrencia interna de modelos de dbt dentro de un solo proceso.

“DuckDB es terminado por el sistema o llena el disco”

Causa probable: un join amplio, una ordenación o una cantidad alta de hilos agotó la memoria o el almacenamiento temporal.

Solución:

  • Reduce la cantidad de hilos de dbt y DuckDB.
  • Configura memory_limit, temp_directory y max_temp_directory_size.
  • Asegúrate de que la ruta temporal tenga espacio.
  • Reduce el tamaño de los fixtures mientras conservas los casos límite y la integridad de las relaciones.
  • Usa preserve_insertion_order=false para las cargas masivas correspondientes después de confirmar que el orden no forma parte del contrato.

La guía de DuckDB sobre falta de memoria señala que reducir el límite de memoria a aproximadamente 50–60% de la RAM de la máquina puede evitar que el sistema operativo termine el proceso, ya que algunas operaciones asignan memoria fuera del administrador de buffers.

“Snowflake informa un merge no determinista”

Causa probable: las filas de entrada del modelo incremental no son únicas según unique_key.

Solución:

  1. Ejecuta una consulta agrupada de duplicados sobre la entrada incremental.
  2. Agrega una prueba de unicidad de dbt en la granularidad declarada del modelo.
  3. Deduplica de forma determinista con una regla de orden explícita.
  4. Usa delete+insert solo cuando esa sea realmente la semántica deseada.

Un fixture local pequeño suele pasar por alto esto porque contiene solo una fila por clave.

“Los conteos difieren entre DuckDB y Snowflake”

Revisa lo siguiente en orden:

  1. Versión de los fixtures y conteos de filas de origen
  2. Manejo de valores nulos en joins y predicados
  3. Mayúsculas y minúsculas en identificadores y cadenas
  4. Tipo de marca de tiempo, zona horaria de la sesión y conversiones en los límites de fecha
  5. Precisión decimal y división entera
  6. JSON null frente a SQL NULL
  7. row_number() no determinista o limit sin un order by completo
  8. Estado del target incremental

Convierte el conjunto más pequeño de filas que difieran en un fixture determinista de prueba unitaria antes de cambiar el SQL de producción.

“El grafo local está desactualizado o falta una relación”

Causa probable: la selección por estado supuso que existía una relación de un build local anterior, pero el archivo de base de datos o el manifest provenía de otra rama.

Solución:

export DUCKDB_PATH="$(pwd)/local/idea_local.duckdb"
rm -f "${DUCKDB_PATH}"
duckdb "${DUCKDB_PATH}" < scripts/bootstrap_local.sql
dbt build --target duckdb --exclude tag:snowflake_only+
mkdir -p .dbt-state/local
cp target/manifest.json .dbt-state/local/manifest.json

Trata el archivo de base de datos y su manifest guardado como una sola generación de caché. Reconstruye ambos después de cambiar de rama, actualizar dbt o modificar de manera sustancial los fixtures de origen.

“Un paquete o hook funcionaba en dbt Core, pero falla en Fusion”

Causa probable: compatibilidad incompleta con Fusion, eliminación de comportamiento obsoleto, introspección compleja o comportamiento no compatible de Jinja o del adapter.

Solución:

  • Actualiza los paquetes y verifica que su rango compatible de dbt incluya 2.0.0.
  • Ejecuta las verificaciones oficiales de preparación para Fusion.
  • Inicia los recursos afectados en modo baseline de análisis estático.
  • Fija la versión preliminar de Fusion que se sabe que funciona.
  • Conserva la ruta de producción existente de dbt Core/Snowflake hasta que la funcionalidad bloqueante tenga una migración verificada.

La presencia de un target de DuckDB no hace que un proyecto existente esté listo para Fusion.

“La factura de Snowflake cambió de lugar en vez de reducirse”

Causa probable: los desarrolladores exportan repetidamente snapshots grandes, leen almacenamiento de objetos en otra región o siguen ejecutando un grafo completo de Snowflake en CI.

Solución:

  • Publica fixtures pequeños, versionados y consistentes en sus relaciones con una frecuencia controlada.
  • Mantén los artefactos en la región adecuada.
  • Usa selección por estado tanto en los controles locales como en los de Snowflake.
  • Mide en conjunto el cómputo de exportación, los cargos aplicables por transferencia de datos y los créditos del warehouse consumidos por CI.
  • No actualices los datos locales cuando baste un fixture para un cambio solo de código.

La seguridad y la gobernanza son arquitectura, no limpieza posterior

DuckDB local cambia el límite de confianza. Snowflake centraliza la autenticación, RBAC, el enmascaramiento, las políticas de acceso a filas y el historial de acceso. En Enterprise Edition o superior, Access History puede registrar referencias a políticas de enmascaramiento y de acceso a filas cuando se consultan objetos protegidos. Un archivo .duckdb o Parquet en una laptop no hereda esos controles.

Un pipeline seguro de fixtures debe responder:

  • ¿Quién tiene permiso para generar y descargar un fixture?
  • ¿Qué columnas se eliminan, enmascaran, tokenizan o generalizan?
  • ¿Es posible volver a unir tokens estables con otro dataset filtrado?
  • ¿Cuánto tiempo permanecen los archivos locales y cómo se eliminan?
  • ¿Se pueden auditar las versiones de los fixtures y el código que los genera?
  • ¿Puede CI usar datos sintéticos mientras solo un control restringido de Snowflake accede a datos gobernados?

Si la organización no puede responder esas preguntas, los datos locales con la estructura de producción no están listos para un uso amplio por parte de los desarrolladores. Comienza con fixtures sintéticos o seleccionados a mano.

Lecturas relacionadas: EDA con NATS: proyecciones CQRS sitúa el warehouse en la ruta de lectura de analítica, mientras que Preparación de bases de datos para agentes de IA seguros profundiza en el mínimo privilegio y el acceso gobernado a los datos.

Cuándo encaja bien este patrón

Usa DuckDB como ciclo interno predeterminado cuando:

  • la mayoría de los modelos usan SQL relacional en lugar de funciones nativas de Snowflake
  • los desarrolladores pasan una cantidad considerable de tiempo esperando iteraciones pequeñas de modelos
  • el proyecto puede crear fixtures de origen representativos y sanitizados
  • el equipo mantendrá el dispatch por adapter y las pruebas de paridad
  • se conserva un control real de Snowflake antes del merge o del despliegue

Mantén Snowflake como target principal de desarrollo cuando:

  • el grafo depende en gran medida de Snowpark, Cortex, funciones externas, streams/tasks, objetos seguros o políticas de plataforma
  • la corrección depende de la distribución de datos a escala de producción y de los planes de consulta
  • los datos no pueden salir de la plataforma gobernada de forma legal o segura
  • replicar las fuentes costaría más que las consultas de desarrollo que reemplazaría
  • mantener dos implementaciones de adapter superaría el tiempo ahorrado para obtener feedback

Esto no tiene que ser una decisión de todo o nada. Un proyecto puede descubrir que una gran parte de su lógica silver y gold es portable, mientras que la ingesta, la gobernanza y los modelos especializados siguen siendo exclusivos de Snowflake.

Un plan de adopción de bajo riesgo

  1. Haz un inventario de portabilidad. Clasifica los modelos y paquetes como portables, que necesitan dispatch o exclusivos de Snowflake.
  2. Elige un flujo vertical. Para la aplicación Idea, usa ideas + votes → idea engagement, que abarca bronze, silver y gold.
  3. Construye fixtures con la estructura de las fuentes. Incluye valores nulos, votos duplicados, ideas eliminadas, eventos tardíos, decimales grandes y límites de zonas horarias.
  4. Agrega el target de DuckDB. Usa un archivo persistente y fija la versión preliminar de Fusion.
  5. Elimina los lectores directos de archivos del SQL de los modelos. Carga los archivos en tablas raw durante la inicialización.
  6. Agrega pruebas entre motores. Comienza con funciones con dispatch y modelos incrementales.
  7. Agrega el control rápido de CI. Ejecuta las pruebas unitarias y el grafo portable en un archivo de DuckDB aislado.
  8. Conserva Snowflake Slim CI. Ejecuta los modelos modificados y sus descendientes con la semántica real del target.
  9. Mide todo el sistema. Compara la latencia para obtener feedback, los créditos de Snowflake, el costo de generación de fixtures, los cargos aplicables por transferencia de datos y las fallas de paridad.
  10. Amplía solo mientras las fallas de paridad sigan siendo visibles. Una falla detectada por Snowflake CI es evidencia útil sobre el límite, no una razón para ocultar el control.

La conclusión práctica

“DuckDB en dev, Snowflake en prod” aporta valor cuando se trata como una estrategia de verificación por capas:

  • DuckDB responde: ¿Funciona la lógica de transformación portable con un dataset pequeño y representativo?
  • Snowflake responde: ¿El target de producción la compilará, materializará, protegerá y ejecutará correctamente?

Fusion permite responder la primera pregunta de forma más rápida y completa mediante la ejecución local y la comprensión de SQL. Con la extensión de dbt para VS Code, el mismo flujo de trabajo también ofrece feedback en el editor y linaje a nivel de columna. No convierte las dos preguntas en una sola.

Para un proyecto medallion grande, la mejor implementación no consiste en replicar el warehouse en cada laptop. Consiste en conservar un solo grafo de dbt, establecer un límite de portabilidad deliberado, proporcionar fixtures pequeños con la estructura de las fuentes y gastar cómputo de Snowflake solo donde lo que se está probando es Snowflake.

Fuentes

Consultadas el July 15, 2026. El estado de los productos depende de la fecha.

Inspiración, no evidencia: la publicación de Joachim Hodana en LinkedIn aportó la premisa “DuckDB en dev, Snowflake en prod”.

Criterio editorial: la pirámide de pruebas con dos motores, el límite de fixtures con la estructura de las fuentes, la clasificación de modelos, la implementación de la aplicación Idea y la secuencia de adopción son patrones de arquitectura recomendados, sintetizados a partir de las capacidades y limitaciones documentadas. No afirman que DuckDB y Snowflake produzcan resultados idénticos ni constituyen un benchmark de costos medido. Valida el patrón con tu propio grafo, política de datos y factura del warehouse.