Skip to content
Home
ETL Pipelines: Building Reliable Data Workflows at Scale

ETL Pipelines: Building Reliable Data Workflows at Scale

Data Engineering Data Engineering 8 min read 1546 words Beginner ExcellentWiki Editorial Team

ETL pipelines move and transform data from source systems to analytics platforms. Building reliable pipelines is the core work of data engineering. According to a McKinsey survey, data engineers spend 60-80% of their time on data preparation and pipeline maintenance, making efficiency in this area a significant competitive advantage. Well-designed pipelines are idempotent, observable, and resilient to failures, enabling data teams to focus on deriving insights rather than fixing broken data flows.

Extraction Strategies

Full Refresh

The simplest approach: extract all data from the source every time the pipeline runs. This is appropriate for small datasets where the extraction completes within the batch window and the source system can handle repeated full scans. Full refreshes are easy to implement and reason about — there is no state to track and no risk of missing incremental changes. However, they become impractical as data volumes grow beyond millions of rows, and they place unnecessary load on source systems.

Incremental Extraction

Extract only new or modified records since the last successful run. This requires a reliable way to identify changed records — typically a timestamp column (last_updated, modified_at) or a monotonically increasing ID column. Incremental extraction reduces load on source systems, speeds up pipeline execution, and enables more frequent updates.

def extract_incremental(last_run):
    query = f"SELECT * FROM orders WHERE updated_at > '{last_run}'"
    return pd.read_sql(query, connection)

The trade-off is complexity: you must track the last successful extraction time (the watermark), handle edge cases like late-arriving data (records updated after their extraction window), and detect deleted records that timestamp-based extraction misses. Implement watermarks as a dedicated control table in your warehouse that records the last successful extraction timestamp for each source table.

Change Data Capture

CDC captures database changes at the transaction log level. Tools like Debezium, Kafka Connect, and AWS DMS read the database’s binary log (Write-Ahead Log, Binlog, or similar) and emit change events for every insert, update, and delete operation. CDC provides the lowest latency and the most complete change history, including record deletions that timestamp-based incremental extraction cannot detect.

CDC is the preferred strategy for mission-critical pipelines where data completeness and low latency are essential. The trade-off is infrastructure complexity — CDC tools require access to database transaction logs, which may require database configuration changes and additional monitoring.

Transformation Logic

Transformations clean, validate, and restructure data for analytical use. They should be designed for testability, readability, and idempotency. Each transformation function should accept a DataFrame or SQL query result and return a transformed version, with clear expectations about input schema and output schema.

def transform_orders(df):
    df = df.drop_duplicates(subset=["order_id"])
    df = df[df["amount"] > 0]
    df["order_date"] = pd.to_datetime(df["order_date"])
    df["tax_amount"] = df["amount"] * 0.08
    df["total_with_tax"] = df["amount"] + df["tax_amount"]

    daily_totals = df.groupby(df["order_date"].dt.date).agg({
        "amount": "sum",
        "order_id": "count"
    }).reset_index()
    return daily_totals

SQL Transformations with dbt

For warehouse-based transformations, SQL is the preferred language because it is closer to the data — transformations run in the warehouse, avoiding data movement — and is more accessible to analysts who may not know Python. Tools like dbt make SQL transformations testable, documented, and version-controlled:

WITH orders AS (
    SELECT * FROM {{ source('shop', 'orders') }}
),
daily_metrics AS (
    SELECT
        DATE(order_date) AS order_day,
        COUNT(*) AS order_count,
        SUM(amount) AS total_revenue,
        AVG(amount) AS avg_order_value
    FROM orders
    GROUP BY 1
)
SELECT * FROM daily_metrics

SQL transformations are generally more performant than Python transformations for warehouse-based workloads because they avoid moving data out of the warehouse. Python is better suited for transformations that require external API calls, complex conditional logic not expressible in SQL, or integration with ML libraries.

Loading Patterns

Append

Insert new records into the target table. This is the simplest load pattern — just INSERT INTO ... SELECT * FROM source. However, append-only loads can create duplicate records on re-runs, making them suitable only for immutable data sources where idempotency is guaranteed by the extraction logic. Append is appropriate for event data like clickstream logs that are never modified.

Upsert (Merge)

Insert new records and update existing ones in a single atomic operation. The MERGE statement (or equivalent INSERT ON CONFLICT UPDATE) checks for existing records based on a key and either updates them or inserts new ones:

MERGE INTO daily_orders AS target
USING staging_orders AS source
ON target.order_date = source.order_date
WHEN MATCHED THEN UPDATE SET
    order_count = source.order_count,
    total_revenue = source.total_revenue
WHEN NOT MATCHED THEN INSERT (order_date, order_count, total_revenue)
    VALUES (source.order_date, source.order_count, source.total_revenue)

Upsert is the preferred pattern for incremental loads because it ensures idempotency: running the same pipeline twice produces the same result. Target tables remain consistent regardless of how many times the pipeline executes, which is essential for retry safety.

Full Refresh

Truncate the target table and reload all data. This is the simplest approach for small tables (dimension tables, reference data) where a full reload takes seconds. Full refreshes are also useful when the transformation logic changes and the old results need to be completely replaced.

Error Handling

Robust error handling distinguishes production-grade pipelines from prototypes. Every pipeline should handle extraction failures, transformation errors, loading conflicts, and data quality violations with appropriate responses:

def run_pipeline():
    try:
        data = extract()
        transformed = transform(data)
        load(transformed)
    except ExtractionError:
        alert("Source system unavailable, will retry in 1 hour")
    except ValidationError as e:
        alert(f"Data quality check failed: {e}")
    except Exception as e:
        alert(f"Pipeline failed: {e}")
        raise

Dead letter queues capture records that fail validation, allowing investigation without blocking the entire pipeline. Retry policies with exponential backoff and jitter handle transient failures like network timeouts and service throttling. Structured logging with consistent severity levels (INFO, WARNING, ERROR) enables effective monitoring and debugging through log aggregation tools.

Pipeline Testing

Test TypeWhat It ValidatesExample
Schema testColumn names, types, nullabilityCheck all expected columns exist with correct types
Freshness testTimely data arrival within SLAsMAX(updated_at) is less than 1 hour ago
Volume testExpected row count rangeRow count within 10% of 7-day historical average
Quality testBusiness rule complianceNo negative amounts, no null primary keys
Lineage testEnd-to-end correctnessRow count matches between source and target

Orchestration Best Practices

  • Idempotency: Running the same pipeline twice with the same input must produce the same result — use upsert patterns and deterministic transformations
  • Retries with backoff: Retry failed tasks with exponential delay and jitter to avoid overwhelming external systems during recovery
  • Notifications: Alert on failures via Slack, email, or PagerDuty; consider success summaries for critical pipelines that need confirmation
  • Structured logging: Include correlation IDs, pipeline version, row counts, and duration in every log entry for effective debugging
  • Data lineage: Tag every record with source system, pipeline version, and processing timestamp to enable root cause analysis
  • SLA monitoring: Track pipeline completion time against service level agreements with escalating alerting for missed SLAs

Common Pitfalls and Solutions

PitfallSolution
Not detecting schema changesImplement schema drift detection with automated alerts and compatibility checks
Missing incremental logicAdd watermark tracking with control tables and incremental extraction logic
Non-idempotent pipelinesUse upsert (MERGE) instead of INSERT for incremental loads
Ignoring null handlingDefine explicit null handling rules for every column — COALESCE, default values, or reject
No data quality checksAdd validation at every transformation stage — not null, unique, referential integrity
Hardcoded configurationsUse environment variables, configuration files, and parameterized pipeline definitions

Frequently Asked Questions

What is the difference between ETL and ELT? ETL transforms data before loading it into the target system, requiring dedicated transformation infrastructure. ELT loads raw data first, then transforms it in the warehouse using the warehouse’s compute power. ELT is simpler to implement and more scalable, while ETL provides more control over the transformation environment. Cloud warehouses make ELT the dominant approach for most use cases.

Should I use Python or SQL for transformations? Use SQL for transformations that can be expressed as set operations — aggregations, joins, filtering, window functions. SQL is more concise, performant (no data movement), and accessible to analysts. Use Python for transformations that require external API calls, complex conditional logic not expressible in SQL, or integration with libraries like pandas, scikit-learn, or NumPy. Most production pipelines use both languages as appropriate.

How do I handle late-arriving data? Define a late-arriving threshold based on business requirements — typically 24-48 hours for daily pipelines. Data arriving after the threshold is either rejected with alerting or loaded into a correction mechanism. In streaming systems, watermarks with configurable lateness thresholds handle this natively. In batch systems, periodic full refreshes can correct earlier incremental loads by reapplying transformations to the complete dataset.

What is the best scheduling frequency for ETL pipelines? Match the frequency to business needs and source system capabilities. Daily updates are simplest and cheapest, sufficient for most strategic reporting. Hourly pipelines serve operational dashboards with near-real-time data. Real-time (sub-minute) pipelines handle time-sensitive use cases like fraud detection and personalization. Start with daily and increase frequency only when justified by clear business requirements — each increase compounds complexity and cost.

How do I monitor ETL pipeline health? Monitor four key metrics: freshness (time since last successful run), volume (row counts within expected range), duration (pipeline runtime within acceptable limits), and failure rate (percentage of failed runs over a rolling window). Each metric should have warning and critical thresholds with corresponding alerts routed to the appropriate channels (email for warnings, PagerDuty for criticals).

Data Engineering OverviewApache Spark GuideData Warehousing Guide

Section: Data Engineering 1546 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top