← Kỹ sư dữ liệu← Data Engineer
Kỹ sư dữ liệuData Engineer19 Th7, 2026Jul 19, 202621 phút đọc16 min read

ETL/ELT & Data PipelineETL/ELT & Data Pipelines

Thuộc bộ kiến thức Data Engineer Roadmap.

Tổng quan

Một data pipeline là một chuỗi các bước tự động di chuyển dữ liệu từ một hoặc nhiều nguồn (source) đến một hoặc nhiều đích (destination), đồng thời áp dụng các transformation dọc đường. Đây chính là “hệ thống ống nước” (plumbing) của vòng đời data engineering: ingestion (./03-data-sources-and-ingestion.md) đưa dữ liệu thô về trong tầm tay, storage và data modeling (./06-data-modeling-and-warehousing.md) cho dữ liệu một “ngôi nhà” và một hình dạng cụ thể, nhưng chính pipeline mới là thứ thực sự chạy — theo lịch (schedule) hoặc liên tục — kết nối hai phần đó lại và giữ cho dữ liệu luôn tươi mới. Không có pipeline, một data warehouse chỉ là một tòa nhà trống không có con đường nào dẫn tới.

Câu hỏi thiết kế trung tâm mà mọi pipeline phải trả lời là: transformation xảy ra khi nào, so với việc load dữ liệu? Câu trả lời chia pipeline thành hai họ — ETL và ELT — và lựa chọn thứ tự đó lan tỏa ảnh hưởng đến tooling, chi phí, độ trễ (latency), và ai trong team có thể thực hiện thay đổi. Note này sẽ đi qua sự khác biệt ETL/ELT, pattern Reverse ETL mới hơn khép lại vòng lặp bằng cách đẩy dữ liệu từ warehouse ngược trở lại các công cụ vận hành (operational tools), dbt như tiêu chuẩn thực tế (de facto standard) cho “T” trong ELT, và các nguyên tắc về độ tin cậy (idempotency, checkpointing) — điều phân biệt một pipeline chỉ đơn thuần “chạy được” với một pipeline có thể tin tưởng chạy không giám sát trong nhiều năm.

Kiến thức nền tảng

Data pipeline thực chất là gì

Bóc bỏ hết lớp vỏ thương hiệu, mọi data pipeline đều đang làm cùng ba việc, theo một thứ tự và tần suất nào đó:

  1. Extract — kéo dữ liệu ra khỏi hệ thống nguồn (một OLTP database, một SaaS API, một stream sự kiện).
  2. Transform — định hình lại, làm sạch, lọc, join, và aggregate dữ liệu đó thành dạng hữu ích cho đích đến của nó.
  3. Load — ghi dữ liệu (đã hoặc chưa transform) vào đích (một warehouse, một lake, một ứng dụng khác).

Pipeline là đoạn code và orchestration nối các bước này lại với nhau, khởi chạy bởi một trigger — lịch cron, một file vừa được đặt vào object storage, một job upstream vừa hoàn tất, hoặc một stream liên tục. Điều thay đổi giữa các kiến trúc không phải là ba động từ trên có xảy ra hay không, mà là “T” nằm ở đâu so với “L” — và đó chính xác là sự khác biệt giữa ETL và ELT.

ETL: Extract, Transform, Load

Trong pattern ETL truyền thống, transformation xảy ra ở một lớp xử lý riêng biệt trước khi dữ liệu chạm tới đích:

Source ──▶ Extract ──▶ Transform (external engine) ──▶ Load ──▶ Destination

Về mặt lịch sử, đây không phải là một lựa chọn phong cách — nó bị ép buộc bởi bài toán kinh tế. Data warehouse của thập niên 1990-2000 (Teradata, Oracle on-prem) có compute và storage đắt đỏ, cố định. Bạn không thể đổ dữ liệu thô, trùng lặp, chưa sạch vào warehouse rồi transform sau; mỗi byte và mỗi CPU cycle bên trong warehouse đều khan hiếm và tốn kém. Vì vậy transformation bị đẩy ra các server ETL riêng (Informatica, IBM DataStage, Talend), được sizing độc lập với warehouse, và chỉ kết quả cuối cùng — sạch, đã được model — mới được load vào.

Cái giá của cách tiếp cận này là sự cứng nhắc về kiến trúc: logic transformation nằm trong một tool riêng với engine thực thi riêng, thường ẩn sau một GUI, tách rời khỏi các analyst và engineer thông thạo SQL — những người hiểu rõ business logic nhất. Thay đổi schema hay yêu cầu transformation mới nghĩa là phải đụng vào một hệ thống khác với hệ thống mà dữ liệu cuối cùng sẽ nằm trong, và không có cách nào dễ dàng để xem lại dữ liệu chưa transform một khi bạn đã quyết định không load nó vào.

ELT: Extract, Load, Transform

ELT đảo ngược thứ tự: dữ liệu thô được load vào đích trước, và transformation xảy ra bên trong đích, sử dụng compute của chính nó:

Source ──▶ Extract ──▶ Load (raw) ──▶ Destination ──▶ Transform (in-warehouse SQL)

ELT trở thành pattern thống trị trong thập kỷ qua vì một lý do đơn giản: cloud data warehouse đã khiến compute trở nên rẻ, co giãn (elastic), và tách rời khỏi storage. Snowflake, BigQuery, và Redshift cho phép bạn scale compute lên cho một transformation job rồi scale xuống ngay sau đó, chỉ trả tiền cho những gì đã dùng, và chi phí storage đủ thấp để việc giữ một bản sao đầy đủ dữ liệu thô của mọi bảng nguồn không còn là một khoản chi đáng kể. Khi việc lưu trữ dữ liệu thô với chi phí rẻ và tính toán co giãn trên nó trở nên khả thi, không còn lý do gì để định hình dữ liệu trước khi nó được load — bạn có thể load mọi thứ nguyên bản rồi quyết định cách model nó sau, với đầy đủ SQL và query engine của chính warehouse.

Điều này kéo theo một số hệ quả thực tế:

Những tradeoff còn lại là thật, không bị loại bỏ hoàn toàn: dữ liệu thô và dữ liệu đã transform giờ đều được lưu trữ, gây trùng lặp storage (thường là một sự đánh đổi tốt do storage của warehouse rẻ, nhưng không miễn phí); warehouse giờ cần được sizing cho cả ingestion load lẫn transformation compute, và một transformation job chạy tràn (runaway) sẽ tiêu tốn cùng ngân sách compute với mọi thứ khác; và bạn giờ phụ thuộc vào việc warehouse có tooling transformation SQL native mạnh mẽ (window function, incremental materialization, v.v.) thay vì khả năng chuyên biệt của một engine ETL riêng.

Reverse ETL

Nếu tiền đề của ELT là “load dữ liệu thô vào warehouse, rồi transform nó ở đó,” thì Reverse ETL là pattern khép lại vòng lặp: nó lấy dữ liệu đã được transform, đã model, đang nằm trong warehouse và đồng bộ nó ngược ra ngoài các SaaS tool vận hành (operational) mà các đội kinh doanh dùng hàng ngày.

Warehouse (modeled data) ──▶ Reverse ETL tool ──▶ Operational SaaS tool (Salesforce, HubSpot, Zendesk...)

Vấn đề gốc rễ dẫn tới pattern này: một data team tính toán ra thứ gì đó có giá trị trong warehouse — ví dụ, một customer_health_score được xây dựng từ dữ liệu sử dụng sản phẩm, ticket hỗ trợ, và lịch sử billing kết hợp lại — nhưng con số đó vô dụng nếu chỉ nằm trong một bảng mà chỉ analyst mới query. Đội sales sống trong Salesforce, không phải trong một BI tool. Reverse ETL lấy cột customer_health_score đã tính từ một dbt mart và đẩy nó vào một custom field trên Account object của Salesforce, để một sales rep nhìn thấy nó ngay tại nơi họ làm việc.

Pattern này ra đời vì nó biến warehouse thành nguồn sự thật duy nhất (single source of truth) ngay cả với các hệ thống chưa bao giờ được thiết kế để trở thành đích đến “analytics” — thay vì mỗi team tự xây một tích hợp (integration) riêng lẻ để làm giàu Salesforce, Marketo, hay Intercom bằng derived business logic, logic đó được tính toán một lần duy nhất, chính xác, trong warehouse (có dbt test bảo vệ) và phân phối ra mọi tool cần đến nó. Các use case điển hình: đồng bộ điểm lead score vào CRM, đẩy cờ churn-risk vào tool customer success, cập nhật segment email marketing trong nền tảng marketing automation, hoặc đưa một con số lifetime-value đã tính vào tool hỗ trợ để agent ưu tiên khách hàng VIP.

Các tool chuyên dụng trong mảng này — Hightouch, Census, và (với các khả năng gần với Reverse ETL) Segment — kết nối trực tiếp với warehouse, cho phép bạn map một câu SQL query hoặc một dbt model vào các field trong tool đích thông qua connector dựng sẵn, và xử lý lịch đồng bộ, field mapping, và rate limit API của hàng chục SaaS tool đích để engineer không phải tự viết tay code tích hợp API cho từng cái một.

Khái niệm chính

dbt và “T” thời hiện đại

dbt (data build tool) là công cụ đã biến “T” trong ELT từ những script SQL viết tùy tiện (ad hoc) thành một kỷ luật kỹ thuật (engineering discipline). Ý tưởng cốt lõi của nó đơn giản nhưng mạnh mẽ: transformation chỉ là các câu lệnh SELECT, được viết dưới dạng file .sql, và dbt lo việc biến mỗi file thành một table hoặc view trong warehouse, theo đúng thứ tự phụ thuộc (dependency), kèm theo testing và documentation tích hợp sẵn.

Ba thành phần chính giúp điều này hoạt động:

Một quy ước phân lớp (layering) phổ biến, được áp dụng rộng rãi, phản ánh ý tưởng “pipeline của các pipeline” (bên dưới): staging → intermediate → marts.

-- models/staging/stg_orders.sql
-- Staging: 1:1 với một raw source table, chỉ rename/cast nhẹ nhàng, không join.
with source as (
    select * from {{ source('ecommerce', 'orders') }}
),
renamed as (
    select
        id as order_id,
        customer_id,
        cast(ordered_at as timestamp) as ordered_at,
        status,
        amount_cents / 100.0 as amount_usd
    from source
)
select * from renamed
-- models/intermediate/int_orders_joined_customers.sql
-- Intermediate: join/business logic mà chưa đạt hình dạng báo cáo cuối cùng.
with orders as (
    select * from {{ ref('stg_orders') }}
),
customers as (
    select * from {{ ref('stg_customers') }}
)
select
    orders.order_id,
    orders.ordered_at,
    orders.amount_usd,
    customers.customer_id,
    customers.signup_date,
    customers.country
from orders
left join customers on orders.customer_id = customers.customer_id
-- models/marts/fct_orders.sql
-- Mart: bảng cuối cùng, hướng tới business, mà analyst và BI tool query trực tiếp.
select
    order_id,
    customer_id,
    country,
    date_trunc('month', ordered_at) as order_month,
    amount_usd
from {{ ref('int_orders_joined_customers') }}
where status = 'completed'

Vì đây là các file thuần túy, chúng trải qua cùng vòng đời như application code: version-control trong git, review qua pull request, test (các test tích hợp sẵn của dbt như unique, not_null, relationships, cộng thêm custom SQL assertion — xem ./16-testing-for-data-pipelines.md), và document với lineage graph tự động sinh ra. Đây chính là cơ chế cụ thể đằng sau khẳng định ở phần trước rằng ELT cho phép “những người biết SQL sở hữu transformation” — dbt là phương tiện hiện thực hóa điều đó.

Idempotency

Một pipeline được gọi là idempotent nếu chạy nó hai lần với cùng đầu vào cho ra cùng kết quả như khi chạy một lần. Nghe có vẻ là một tiểu tiết kỹ thuật, nhưng đây lại là thuộc tính quan trọng nhất để xây dựng những pipeline có thể fail một cách an toàn — và pipeline sẽ fail, dù là do trục trặc mạng, worker hết bộ nhớ, hay API upstream timeout. Nếu một job idempotent, cách khắc phục cho gần như mọi lỗi đều giống nhau: chỉ cần chạy lại (rerun). Nếu không idempotent, mỗi lần lỗi đều đòi hỏi phải thủ công tìm hiểu xem đã có bao nhiêu thiệt hại một phần (partial damage) trước khi có thể retry an toàn.

Sai lầm kinh điển không idempotent là một phép append mù quáng:

-- KHÔNG idempotent: chạy lại sau một lần fail nửa chừng
-- sẽ nhân đôi mọi row đã lọt qua ở lần chạy đầu tiên.
insert into orders_raw (order_id, customer_id, amount_usd, ordered_at)
select order_id, customer_id, amount_usd, ordered_at
from staging_orders_batch;

Nếu job này fail giữa chừng — chẳng hạn kết nối bị đứt sau khi đã insert 6.000 trong tổng số 10.000 row — chạy lại job sẽ insert lại chính 6.000 row đó thêm một lần nữa. Cách khắc phục là làm cho việc ghi (write) trở nên idempotent, hoặc bằng một upsert native của database:

-- Idempotent: upsert của PostgreSQL. Chạy lại sẽ ghi đè
-- các row đã tồn tại thay vì nhân đôi chúng.
insert into orders_raw (order_id, customer_id, amount_usd, ordered_at)
select order_id, customer_id, amount_usd, ordered_at
from staging_orders_batch
on conflict (order_id)
do update set
    customer_id = excluded.customer_id,
    amount_usd = excluded.amount_usd,
    ordered_at = excluded.ordered_at;

hoặc bằng một MERGE của warehouse (pattern mà hầu hết cloud warehouse và incremental materialization của dbt sử dụng bên dưới):

-- Idempotent: MERGE (cú pháp Snowflake/BigQuery/Databricks).
merge into orders_raw as target
using staging_orders_batch as source
on target.order_id = source.order_id
when matched then update set
    customer_id = source.customer_id,
    amount_usd = source.amount_usd,
    ordered_at = source.ordered_at
when not matched then insert (order_id, customer_id, amount_usd, ordered_at)
    values (source.order_id, source.customer_id, source.amount_usd, source.ordered_at);

Cả hai cách viết lại đều dùng một định danh (identifier) ổn định (order_id) làm khóa cho phép write, nên chạy chính xác cùng một batch qua pipeline bao nhiêu lần cũng hội tụ về cùng một trạng thái cuối — đó chính là bản chất của idempotency, và đó là điều khiến “cứ retry task lỗi” trở thành mặc định an toàn trong một orchestrator (./09-workflow-orchestration.md) thay vì một cuộc điều tra thủ công đầy rủi ro.

Checkpointing và watermarking

Idempotency khiến việc chạy lại một job trở nên an toàn; checkpointing (còn gọi là watermarking) khiến việc chạy lại job trở nên rẻ bằng cách cho phép nó tiếp tục từ điểm đã dừng thay vì xử lý lại từ đầu. Một checkpoint đơn giản là một mốc đánh dấu (marker) được lưu trữ bền vững — một timestamp, một ID tăng dần, một Kafka offset — ghi lại pipeline đã tiến được đến đâu một cách thành công.

-- Bảng watermark theo dõi timestamp xử lý thành công gần nhất cho mỗi source.
create table pipeline_watermarks (
    source_name text primary key,
    last_loaded_at timestamp not null
);

-- Câu query extraction chỉ lấy các row mới hơn watermark thành công gần nhất.
select *
from source_orders
where updated_at > (
    select last_loaded_at from pipeline_watermarks where source_name = 'orders'
);

-- Sau khi load thành công, tiến watermark tới timestamp lớn nhất vừa xử lý.
update pipeline_watermarks
set last_loaded_at = (select max(updated_at) from staging_orders_batch)
where source_name = 'orders';

Không có watermark, một lần fail giữa chừng khi extract dữ liệu một năm nghĩa là phải bắt đầu lại toàn bộ năm đó. Có watermark, một job được resume sẽ tiếp tục chính xác từ điểm đã xác nhận thành công cuối cùng. Watermarking và idempotency bổ trợ cho nhau, không trùng lặp: watermarking giới hạn bao nhiêu công việc một lần retry phải làm lại, còn idempotency đảm bảo rằng bất kỳ phần trùng lặp nào bị xử lý lại (ví dụ watermark đã tiến lên một chút trước khi crash) cũng không làm hỏng dữ liệu ở đích.

Các hình dạng pipeline: fan-in, fan-out, và pipeline-of-pipelines

Kiến trúc pipeline trong thực tế hiếm khi là một đường thẳng duy nhất; chúng kết hợp thành một vài hình dạng lặp lại phổ biến.

Fan-in — nhiều nguồn hội tụ vào một đích duy nhất. Đây là hình dạng phổ biến khi xây dựng một warehouse thống nhất: Salesforce, Stripe, và production Postgres database đều đổ vào cùng một schema trong warehouse, sẵn sàng để join.

Salesforce / Stripe / Postgres → Warehouse (raw schema)
  1. Salesforce
    Stripe
    Postgres
  2. Warehouseraw schema

Fan-out — một nguồn duy nhất nuôi (feed) nhiều đích. Một stream sự kiện đơn hàng có thể đổ vào warehouse để phân tích vào một search index cho storefront kích hoạt một webhook tới một đối tác vận chuyển (fulfillment), tất cả từ cùng một sự kiện nguồn.

Order events → warehouse, search index, fulfillment
Order events
  • Warehouse
  • Search index
  • Fulfillment webhook

Pipeline-of-pipelines — một chuỗi các pipeline nơi output của giai đoạn này là input của giai đoạn tiếp theo, thường được tổ chức thành các lớp với mức độ tinh chỉnh tăng dần. Đây chính xác là quy ước staging → intermediate → marts mà dbt khuyến khích: công việc của pipeline staging hoàn tất ngay khi stg_orders tồn tại; pipeline intermediate lấy đó làm đầu vào và join; pipeline marts lấy kết quả đó và định hình cho một dashboard. Việc model điều này như một DAG gồm các pipeline nhỏ hơn thay vì một script khổng lồ duy nhất nghĩa là một lỗi hoặc một thay đổi schema ở một lớp bị cô lập và không đòi hỏi phải tái tạo lại các lớp không liên quan, và mỗi lớp có thể được test và sở hữu (own) độc lập.

Các managed ingestion tool và vị trí của chúng

Một phần lớn của “E” (và một phiên bản “L” mỏng, không có nhiều quan điểm riêng) trong các pipeline ELT hiện đại không còn được viết tay nữa — nó được xử lý bởi các managed ingestion tool duy trì sẵn hàng trăm connector dựng sẵn cho các nguồn phổ biến và đổ dữ liệu thô vào warehouse theo lịch, để data engineer dành thời gian cho “T” thay vì viết lại một Salesforce API client lần thứ năm. Các tên tuổi chính:

ToolMô hìnhGhi chú
FivetranSaaS được quản lý hoàn toànCatalog connector lớn, xử lý schema tự động toàn diện, tính phí theo usage
AirbyteOpen-source + tùy chọn managed cloudCó thể tự host, catalog connector open-source lớn, mở rộng được với connector tùy chỉnh
AWS Glue (ETL jobs)ETL managed dựa trên Spark trên AWSSpark serverless, tích hợp chặt với hệ sinh thái data lake của AWS (S3, Glue Catalog)
Azure Data FactoryOrchestration + ETL/ELT managed trên AzureTrình thiết kế pipeline trực quan (visual), bộ connector rộng, tích hợp với Azure Synapse
Google Cloud DataflowXử lý batch/stream managed (Apache Beam)Mô hình thống nhất batch và streaming, tích hợp chặt với BigQuery/Pub/Sub

Các tool này giải quyết extraction và loading một cách đáng tin cậy (tự xử lý pagination, rate limit, schema drift, và retry cho bạn) nhưng cố tình giữ mỏng phần transformation, để “T” lại cho các tool warehouse-native như dbt. Việc điều phối khi nào các ingestion job này chạy, và cách chúng nối vào các job transformation phía sau (downstream), là mối quan tâm của một workflow orchestrator, được trình bày sâu hơn trong ./09-workflow-orchestration.md.

Best Practices

Tài liệu tham khảo

Part of the Data Engineer Roadmap knowledge base.

Overview

A data pipeline is an automated sequence of steps that moves data from one or more sources to one or more destinations, applying transformations somewhere along the way. It is the plumbing of the data engineering lifecycle: ingestion (./03-data-sources-and-ingestion.md) puts raw data within reach, storage and modeling (./06-data-modeling-and-warehousing.md) give it a home and a shape, but it is the pipeline that actually runs, on a schedule or continuously, connecting the two and keeping data fresh. Without pipelines, a data warehouse is just an empty building with no roads leading to it.

The central design question every pipeline answers is: when does transformation happen, relative to loading? The answer splits pipelines into two families — ETL and ELT — and that ordering choice ripples into tooling, cost, latency, and who on the team is able to make changes. This note covers the ETL/ELT distinction, the newer Reverse ETL pattern that closes the loop by pushing warehouse data back into operational tools, dbt as the de facto standard for the “T” in ELT, and the reliability principles (idempotency, checkpointing) that separate a pipeline that merely runs from one that can be trusted to run unattended for years.

Fundamentals

What a data pipeline actually is

Strip away the branding and every data pipeline is doing the same three things, in some order and with some frequency:

  1. Extract — pull data out of a source system (an OLTP database, a SaaS API, a stream of events).
  2. Transform — reshape, clean, filter, join, and aggregate that data into a form useful for its destination.
  3. Load — write the (possibly transformed) data into a destination (a warehouse, a lake, another application).

A pipeline is the code and orchestration that chains these steps together, on a trigger — a cron schedule, a file landing in object storage, an upstream job finishing, or a continuous stream. What varies between architectures is not whether these three verbs happen, but where the “T” sits relative to the “L”, and that is precisely the ETL vs. ELT distinction.

ETL: Extract, Transform, Load

In the traditional ETL pattern, transformation happens in a dedicated processing layer before data ever reaches the destination:

Source ──▶ Extract ──▶ Transform (external engine) ──▶ Load ──▶ Destination

Historically this was not a stylistic choice — it was forced by economics. Data warehouses in the 1990s and 2000s (Teradata, on-prem Oracle) had expensive, fixed compute and storage. You could not afford to dump raw, duplicate, or unclean data into them and transform it later; every byte and every CPU cycle inside the warehouse was scarce and costly. So transformation was pushed onto separate ETL servers (Informatica, IBM DataStage, Talend) sized independently of the warehouse, and only the final, clean, modeled result was loaded in.

The cost of this approach is architectural rigidity: transformation logic lives in a separate tool with its own execution engine, often behind a GUI, decoupled from the analysts and SQL-fluent engineers who understand the business logic best. Schema changes or new transformation requirements mean touching a different system than the one the data eventually lands in, and there’s no easy way to inspect the untransformed data once you’ve decided not to load it.

ELT: Extract, Load, Transform

ELT flips the order: raw data is loaded into the destination first, and transformation happens inside the destination using its own compute:

Source ──▶ Extract ──▶ Load (raw) ──▶ Destination ──▶ Transform (in-warehouse SQL)

ELT became the dominant pattern over the last decade for one simple reason: cloud data warehouses made compute cheap, elastic, and separated from storage. Snowflake, BigQuery, and Redshift let you scale compute up for a transformation job and scale it back down afterward, paying only for what you use, and storage costs are low enough that keeping a full raw copy of every source table is no longer a meaningful expense. Once storing raw data cheaply and computing on it elastically became possible, there was no longer a reason to pre-shape data before it landed — you could load everything as-is and decide how to model it later, with full SQL and the warehouse’s own query engine.

This has several practical consequences:

The tradeoffs that remain are real, not eliminated: raw and transformed data are now both stored, which duplicates storage (usually a good trade given how cheap warehouse storage is, but not free); the warehouse now needs to be sized for both ingestion load and transformation compute, and a runaway transformation job burns the same compute budget as everything else; and you now depend on the warehouse having capable, warehouse-native SQL transformation tooling (window functions, incremental materialization, etc.) rather than a dedicated ETL engine’s specialized capabilities.

Reverse ETL

If ELT’s premise is “load raw data into the warehouse, then transform it there,” Reverse ETL is the pattern that closes the loop: it takes transformed, modeled data that already lives in the warehouse and syncs it back out into the operational SaaS tools that business teams use day to day.

Warehouse (modeled data) ──▶ Reverse ETL tool ──▶ Operational SaaS tool (Salesforce, HubSpot, Zendesk...)

The motivating problem: a data team computes something valuable in the warehouse — say, a customer health score built from product usage, support tickets, and billing history joined together — but that score is useless sitting in a table only analysts query. The sales team lives in Salesforce, not in a BI tool. Reverse ETL takes the computed customer_health_score column from a dbt mart and pushes it into a custom field on the Salesforce Account object, so a sales rep sees it right where they work.

This pattern emerged because it makes the warehouse the single source of truth even for systems that were never designed to be “analytics” destinations — instead of every team building its own bespoke integration to enrich Salesforce, Marketo, or Intercom with derived business logic, that logic is computed once, correctly, in the warehouse (with dbt tests protecting it) and fanned out to every tool that needs it. Typical use cases: syncing lead scores into a CRM, pushing churn-risk flags into a customer success tool, updating email marketing segments in a marketing automation platform, or feeding a computed lifetime-value figure into a support tool so agents can prioritize VIP customers.

Purpose-built tools in this space — Hightouch, Census, and (with its Reverse ETL-adjacent capabilities) Segment — connect directly to the warehouse, let you map a SQL query or dbt model to fields in a destination tool via pre-built connectors, and handle the sync scheduling, field mapping, and API rate limits of dozens of destination SaaS tools so engineers don’t hand-write API integration code for each one.

Key Concepts

dbt and the modern “T”

dbt (data build tool) is the tool that turned “T” in ELT from ad hoc SQL scripts into an engineering discipline. Its core idea is simple but powerful: transformations are just SELECT statements, written as .sql files, and dbt handles turning each file into a table or view in the warehouse, in the correct dependency order, with testing and documentation built in.

Three building blocks make this work:

A common, widely-adopted layering convention mirrors the “pipeline of pipelines” idea (below): staging → intermediate → marts.

-- models/staging/stg_orders.sql
-- Staging: 1:1 with a raw source table, light renaming/casting only, no joins.
with source as (
    select * from {{ source('ecommerce', 'orders') }}
),
renamed as (
    select
        id as order_id,
        customer_id,
        cast(ordered_at as timestamp) as ordered_at,
        status,
        amount_cents / 100.0 as amount_usd
    from source
)
select * from renamed
-- models/intermediate/int_orders_joined_customers.sql
-- Intermediate: joins/business logic that isn't yet a final reporting shape.
with orders as (
    select * from {{ ref('stg_orders') }}
),
customers as (
    select * from {{ ref('stg_customers') }}
)
select
    orders.order_id,
    orders.ordered_at,
    orders.amount_usd,
    customers.customer_id,
    customers.signup_date,
    customers.country
from orders
left join customers on orders.customer_id = customers.customer_id
-- models/marts/fct_orders.sql
-- Mart: the final, business-facing table analysts and BI tools query directly.
select
    order_id,
    customer_id,
    country,
    date_trunc('month', ordered_at) as order_month,
    amount_usd
from {{ ref('int_orders_joined_customers') }}
where status = 'completed'

Because these are plain files, they go through the same lifecycle as application code: version-controlled in git, reviewed in pull requests, tested (dbt’s built-in unique, not_null, relationships tests, plus custom SQL assertions — see ./16-testing-for-data-pipelines.md), and documented with auto-generated lineage graphs. This is the concrete mechanism behind the earlier claim that ELT lets “the people who know SQL own transformations” — dbt is the vehicle.

Idempotency

A pipeline is idempotent if running it twice with the same input produces the same result as running it once. This sounds like a minor technical nicety, but it is the single most important property for building pipelines that can fail safely — and pipelines will fail, whether from a network blip, an out-of-memory worker, or an upstream API timeout. If a job is idempotent, the fix for almost any failure is the same: just rerun it. If it is not idempotent, every failure requires manually figuring out how much partial damage was done before you can safely retry.

The classic non-idempotent mistake is a blind append:

-- NOT idempotent: rerunning this after a partial failure
-- duplicates every row that made it through the first time.
insert into orders_raw (order_id, customer_id, amount_usd, ordered_at)
select order_id, customer_id, amount_usd, ordered_at
from staging_orders_batch;

If this job fails halfway through — say, the connection drops after inserting 6,000 of 10,000 rows — rerunning it inserts those same 6,000 rows a second time. The fix is to make the write idempotent, either with a database-native upsert:

-- Idempotent: PostgreSQL upsert. Reruns overwrite existing rows
-- instead of duplicating them.
insert into orders_raw (order_id, customer_id, amount_usd, ordered_at)
select order_id, customer_id, amount_usd, ordered_at
from staging_orders_batch
on conflict (order_id)
do update set
    customer_id = excluded.customer_id,
    amount_usd = excluded.amount_usd,
    ordered_at = excluded.ordered_at;

or with a warehouse MERGE (the pattern most cloud warehouses and dbt’s incremental materializations use under the hood):

-- Idempotent: MERGE (Snowflake/BigQuery/Databricks syntax).
merge into orders_raw as target
using staging_orders_batch as source
on target.order_id = source.order_id
when matched then update set
    customer_id = source.customer_id,
    amount_usd = source.amount_usd,
    ordered_at = source.ordered_at
when not matched then insert (order_id, customer_id, amount_usd, ordered_at)
    values (source.order_id, source.customer_id, source.amount_usd, source.ordered_at);

Both rewrites key the write on a stable identifier (order_id), so running the exact same batch through the pipeline any number of times converges to the same final state — that is the essence of idempotency, and it is what makes “just retry the failed task” a safe default in an orchestrator (./09-workflow-orchestration.md) instead of a risky manual investigation.

Checkpointing and watermarking

Idempotency makes rerunning a job safe; checkpointing (also called watermarking) makes rerunning a job cheap by letting it resume from where it left off instead of reprocessing everything from the beginning. A checkpoint is simply a durably stored marker — a timestamp, an incrementing ID, a Kafka offset — that records how far the pipeline has successfully progressed.

-- A watermark table tracks the last successfully processed timestamp per source.
create table pipeline_watermarks (
    source_name text primary key,
    last_loaded_at timestamp not null
);

-- Extraction query only pulls rows newer than the last successful watermark.
select *
from source_orders
where updated_at > (
    select last_loaded_at from pipeline_watermarks where source_name = 'orders'
);

-- After a successful load, advance the watermark to the max timestamp just processed.
update pipeline_watermarks
set last_loaded_at = (select max(updated_at) from staging_orders_batch)
where source_name = 'orders';

Without a watermark, a failure midway through extracting a year of history means starting the whole year over. With one, a resumed job picks up exactly at the last confirmed-good point. Watermarking and idempotency are complementary, not redundant: watermarking limits how much work a retry has to redo, while idempotency guarantees that whatever overlap does get reprocessed (e.g., the watermark advanced slightly before a crash) doesn’t corrupt the destination.

Pipeline shapes: fan-in, fan-out, and pipeline-of-pipelines

Real pipeline architectures are rarely a single straight line; they compose in a few recurring shapes.

Fan-in — multiple sources converge into a single destination. This is the common shape for building a unified warehouse: Salesforce, Stripe, and the production Postgres database all land in the same warehouse schema, ready to be joined.

Salesforce / Stripe / Postgres → Warehouse (raw schema)
  1. Salesforce
    Stripe
    Postgres
  2. Warehouseraw schema

Fan-out — a single source feeds multiple destinations. A stream of order events might land in the warehouse for analytics and in a search index for the storefront and trigger a webhook to a fulfillment partner, all from the same source event.

Order events → warehouse, search index, fulfillment
Order events
  • Warehouse
  • Search index
  • Fulfillment webhook

Pipeline-of-pipelines — a chain of pipelines where each stage’s output is the next stage’s input, typically organized into layers of increasing refinement. This is exactly the staging → intermediate → marts convention dbt encourages: the staging pipeline’s job is done once stg_orders exists; the intermediate pipeline picks that up and joins it; the marts pipeline picks that up and shapes it for a dashboard. Modeling this as a DAG of smaller pipelines rather than one monolithic script means a failure or a schema change in one layer is isolated and doesn’t require re-deriving unrelated layers, and each layer can be tested and owned independently.

Managed ingestion tools and where they fit

A large share of the “E” (and a thin, unopinionated version of the “L”) in modern ELT pipelines is no longer hand-written — it’s handled by managed ingestion tools that maintain pre-built connectors to hundreds of common sources and land raw data into a warehouse on a schedule, so data engineers can spend their time on the “T” instead of re-implementing a Salesforce API client for the fifth time. Key players:

ToolModelNotes
FivetranFully managed SaaSLarge connector catalog, fully managed schema handling, usage-based pricing
AirbyteOpen-source + managed cloud optionSelf-hostable, large open connector catalog, extensible with custom connectors
AWS Glue (ETL jobs)Managed Spark-based ETL on AWSServerless Spark, tight integration with the AWS data lake ecosystem (S3, Glue Catalog)
Azure Data FactoryManaged orchestration + ETL/ELT on AzureVisual pipeline designer, broad connector set, integrates with Azure Synapse
Google Cloud DataflowManaged stream/batch processing (Apache Beam)Unified batch and streaming model, tightly integrated with BigQuery/Pub/Sub

These tools solve extraction and loading reliably (handling pagination, rate limits, schema drift, and retries for you) but deliberately stay thin on transformation, leaving the “T” to warehouse-native tools like dbt. Orchestrating when these ingestion jobs run, and how they chain into downstream transformation jobs, is the concern of a workflow orchestrator, covered in depth in ./09-workflow-orchestration.md.

Best Practices

References