Data Engineering Path · Data & AI
Data Engineering + AI: The Tools Ecosystem
Overview
The modern data engineering toolchain has exploded in complexity. This guide maps the essential tools across every layer of the stack, with an honest assessment of when to use each.
Layer 1: Data Ingestion
| Tool |
Best For |
When to Avoid |
| Fivetran |
SaaS sources (Salesforce, HubSpot, Stripe), minimal engineering overhead |
Cost at scale, limited transformation capability |
| Airbyte |
Open-source Fivetran alternative, custom connectors, self-hosted |
More operational overhead than Fivetran |
| AWS Glue |
AWS-native ETL, serverless, tight S3 integration |
Slow startup, debugging is painful |
| Kafka Connect |
Real-time streaming ingestion from databases (CDC), Kafka ecosystem |
Requires Kafka expertise |
| Debezium |
CDC (Change Data Capture) from PostgreSQL, MySQL, Oracle |
Complex setup, needs careful Kafka sizing |
| Apache NiFi |
Complex routing, transformation, drag-and-drop UI |
Heavy, requires dedicated ops |
# Airbyte programmatic connection setup (AI-friendly IaC)
import requests
def create_airbyte_connection(source_id: str, destination_id: str,
stream_name: str, sync_mode: str = "incremental_append_dedup"):
"""Create a programmatic Airbyte connection for a new data source."""
payload = {
"sourceId": source_id,
"destinationId": destination_id,
"syncCatalog": {
"streams": [{
"stream": {"name": stream_name},
"config": {
"syncMode": sync_mode,
"cursorField": ["updated_at"],
"primaryKey": [["id"]]
}
}]
},
"scheduleType": "cron",
"scheduleData": {"cron": {"cronExpression": "0 6 * * *", "cronTimeZone": "UTC"}},
"status": "active"
}
response = requests.post(
"http://localhost:8000/api/v1/connections/create",
json=payload
)
return response.json()
Layer 2: Storage
Object Storage
| Platform |
Ideal Use |
| AWS S3 |
AWS ecosystem, mature, cheapest at scale |
| GCS |
GCP ecosystem, BigQuery native integration |
| Azure ADLS Gen2 |
Azure ecosystem, fine-grained ACLs |
Open Table Formats (The Game-Changers)
# Delta Lake - Write, Read, Merge (ACID on object storage)
from delta.tables import DeltaTable
from pyspark.sql.functions import col
# MERGE (Upsert) - handle late-arriving/updated records
target = DeltaTable.forPath(spark, "s3://curated/customers/")
updates = spark.read.parquet("s3://raw/customer_updates/today/")
target.alias("target").merge(
updates.alias("updates"),
"target.customer_id = updates.customer_id"
).whenMatchedUpdate(set={
"email": col("updates.email"),
"updated_at": col("updates.updated_at")
}).whenNotMatchedInsert(values={
"customer_id": col("updates.customer_id"),
"email": col("updates.email"),
"created_at": col("updates.created_at"),
"updated_at": col("updates.updated_at")
}).execute()
# Time Travel - query data as of 7 days ago
historical_df = spark.read \
.format("delta") \
.option("timestampAsOf", "2024-01-15") \
.load("s3://curated/customers/")
# Or by version number
df_v5 = spark.read \
.format("delta") \
.option("versionAsOf", 5) \
.load("s3://curated/customers/")
# GDPR Delete (row-level)
target.delete(col("customer_id") == "user_to_erase_123")
# Apache Iceberg - Multi-engine compatible
from pyiceberg.catalog import load_catalog
catalog = load_catalog("glue", **{
"type": "glue",
"region_name": "us-east-1"
})
# Schema evolution - add column without rewriting data
table = catalog.load_table("ecommerce.orders")
with table.update_schema() as update:
update.add_column("delivery_method", StringType())
# Partition evolution - change how data is partitioned
with table.update_spec() as update:
update.remove_field("year") # Old partition
update.add_field("month", MonthTransform(), "order_date") # New partition
# Old data retains old partitioning, new data uses new spec
Layer 3: Processing
Batch Processing
# PySpark production patterns
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, when, coalesce
spark = SparkSession.builder \
.appName("ProductionETL") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.adaptive.skewJoin.enabled", "true") \
.config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") \
.getOrCreate()
# Reading with predicate pushdown (critical for performance)
df = spark.read \
.format("iceberg") \
.option("pushdown-limit", "true") \
.load("s3://data-lake/catalog.ecommerce.orders") \
.filter(
(col("order_date") >= "2024-01-01") & # Partition pruning
(col("status").isin(["completed", "shipped"])) # Predicate pushdown
) \
.select("order_id", "customer_id", "revenue", "order_date") # Column pruning
Stream Processing
# Apache Flink for true streaming (lower latency than Spark Streaming)
from pyflink.table import StreamTableEnvironment, EnvironmentSettings
env_settings = EnvironmentSettings.new_instance().in_streaming_mode().build()
t_env = StreamTableEnvironment.create(environment_settings=env_settings)
# Define Kafka source with exactly-once semantics
t_env.execute_sql("""
CREATE TABLE clicks (
user_id BIGINT,
page_url STRING,
event_time TIMESTAMP(3),
WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'page_clicks',
'properties.bootstrap.servers' = 'kafka:9092',
'properties.isolation.level' = 'read_committed',
'format' = 'json',
'scan.startup.mode' = 'latest-offset'
)
""")
# Tumbling window aggregation with late data handling
t_env.execute_sql("""
SELECT
TUMBLE_START(event_time, INTERVAL '5' MINUTE) as window_start,
page_url,
COUNT(*) as click_count,
COUNT(DISTINCT user_id) as unique_users
FROM clicks
GROUP BY TUMBLE(event_time, INTERVAL '5' MINUTE), page_url
""")
Layer 4: Transformation
dbt - The Transformation Standard
-- dbt macro: reusable transformation logic
-- macros/calculate_percentile.sql
{% macro calculate_percentile(column, percentile, partition_by=None) %}
PERCENTILE_CONT({{ percentile }}) WITHIN GROUP (ORDER BY {{ column }})
{% if partition_by %}
OVER (PARTITION BY {{ partition_by }})
{% endif %}
{% endmacro %}
-- Usage in model
SELECT
region,
order_date,
{{ calculate_percentile('revenue', 0.5, 'region') }} as median_revenue,
{{ calculate_percentile('revenue', 0.95, 'region') }} as p95_revenue
FROM orders
Layer 5: Orchestration
Apache Airflow vs. Modern Alternatives
# Dagster - Modern alternative with better type safety and UI
from dagster import asset, AssetIn, define_asset_job
@asset(
description="Raw orders from the transaction database",
metadata={"source": "postgresql://orders_db"}
)
def raw_orders(context):
"""Extract raw orders from PostgreSQL."""
df = pd.read_sql("""
SELECT * FROM orders
WHERE updated_at > NOW() - INTERVAL '1 DAY'
""", conn)
context.log.info(f"Extracted {len(df)} orders")
return df
@asset(
ins={"raw_orders": AssetIn()},
description="Cleaned orders with validated schemas"
)
def cleaned_orders(raw_orders: pd.DataFrame):
"""Validate and clean orders."""
return raw_orders \
.pipe(remove_test_orders) \
.pipe(validate_revenue) \
.pipe(standardize_dates)
@asset(
ins={"cleaned_orders": AssetIn()},
description="Daily revenue aggregates by region"
)
def daily_revenue(cleaned_orders: pd.DataFrame):
return cleaned_orders.groupby(["region", "order_date"])["revenue"].sum().reset_index()
# Dagster tracks lineage, data freshness, and materializations automatically
daily_job = define_asset_job("daily_pipeline", selection=[raw_orders, cleaned_orders, daily_revenue])
Layer 6: AI / ML Tools
Key AI Tools for Data Engineers
| Tool |
Purpose |
Maturity |
| LangChain |
LLM application framework, RAG, agents |
Stable (v0.3+) |
| LlamaIndex |
Data indexing for LLMs, excellent for RAG |
Stable |
| Feast |
Feature store (open source) |
Stable |
| MLflow |
ML experiment tracking, model registry |
Mature |
| Great Expectations |
Data quality testing |
Mature |
| Monte Carlo |
Data observability (commercial) |
Mature |
| Pinecone |
Managed vector database |
Stable |
| Weaviate |
Open-source vector database |
Stable |
| dbt Semantic Layer |
Define metrics once, use everywhere |
New |
# MLflow - Model tracking and registry
import mlflow
import mlflow.sklearn
from sklearn.ensemble import GradientBoostingClassifier
mlflow.set_experiment("churn_prediction_v3")
with mlflow.start_run(run_name="gbt_hypertuning_01"):
# Log parameters
mlflow.log_params({
"n_estimators": 200,
"max_depth": 6,
"learning_rate": 0.1,
"feature_set_version": "v2.3",
"training_data_date": "2024-01-15"
})
# Train model
model = GradientBoostingClassifier(n_estimators=200, max_depth=6, learning_rate=0.1)
model.fit(X_train, y_train)
# Log metrics
mlflow.log_metrics({
"accuracy": accuracy_score(y_test, model.predict(X_test)),
"precision": precision_score(y_test, model.predict(X_test)),
"recall": recall_score(y_test, model.predict(X_test)),
"auc_roc": roc_auc_score(y_test, model.predict_proba(X_test)[:, 1]),
"psi_score": 0.08 # Stability metric
})
# Register model
mlflow.sklearn.log_model(
model,
"churn_model",
registered_model_name="churn_prediction",
signature=mlflow.models.infer_signature(X_train, model.predict(X_train))
)
# Transition to staging
client = mlflow.MlflowClient()
client.transition_model_version_stage(
name="churn_prediction",
version=3,
stage="Staging"
)
Layer 7: Governance & Catalog
# DataHub - Open source data catalog
from datahub.emitter.rest_emitter import DatahubRestEmitter
from datahub.metadata.schema_classes import (
DatasetSnapshotClass,
DatasetPropertiesClass,
SchemaMetadataClass,
SchemaFieldClass,
SchemaFieldDataTypeClass,
StringTypeClass
)
emitter = DatahubRestEmitter("http://datahub-gms:8080")
# Register a dataset with rich metadata
dataset_urn = "urn:li:dataset:(urn:li:dataPlatform:s3,curated/orders,PROD)"
metadata_event = MetadataChangeProposalWrapper(
entityUrn=dataset_urn,
aspect=DatasetPropertiesClass(
description="Production orders table - source of truth for all revenue metrics",
customProperties={
"team": "data-platform",
"sla": "available by 6am UTC",
"update_frequency": "daily",
"data_classification": "internal",
"owner": "data-platform@company.com"
},
tags=["finance", "revenue", "critical", "sla-bound"],
externalUrl="https://internal-wiki.company.com/data/orders"
)
)
emitter.emit(metadata_event)
Tool Selection Decision Tree
flowchart LR
A{What are you<br>building?}
%% Ingestion
A -->|Ingesting SaaS Data| B[Ingestion]
B -->|Small team, low ops| B1(Fivetran)
B -->|Cost-conscious, technical| B2(Airbyte)
%% Processing
A -->|Processing Data| C[Processing]
C -->|Batch, SQL-native| C1(dbt + Snowflake/BigQuery)
C -->|Batch, complex logic| C2(PySpark)
C -->|Streaming, high throughput| C3(Kafka + Flink)
C -->|Streaming, simpler ops| C4(Spark Structured Streaming)
%% Orchestration
A -->|Orchestrating Pipelines| D[Orchestration]
D -->|Standard ETL| D1(Apache Airflow)
D -->|Python-first, modern| D2(Dagster / Prefect)
D -->|Simple cron jobs| D3(GitHub Actions + Lambda)
%% AI Features
A -->|Building AI Features| E[AI / ML]
E -->|Feature Store| E1(Feast / Tecton)
E -->|LLM Integration| E2(LangChain + Anthropic)
E -->|Semantic Search| E3(Pinecone / pgvector)
E -->|Model Tracking| E4(MLflow)
%% Governance
A -->|Governing Data| F[Governance]
F -->|Catalog| F1(DataHub)
F -->|Quality| F2(Great Expectations)
F -->|Lineage| F3(OpenLineage)
classDef decision fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px;
classDef category fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px;
classDef tool fill:#e8f5e9,stroke:#4caf50,stroke-width:1px;
class A decision;
class B,C,D,E,F category;
class B1,B2,C1,C2,C3,C4,D1,D2,D3,E1,E2,E3,E4,F1,F2,F3 tool;
Summary
The data engineering toolchain is both wide and deep. The key to navigating it:
- Don't try to master every tool — understand the problem category, then learn the tool that fits
- Prefer boring technology — Airflow, Spark, S3 have solved hard problems. Use them.
- Evaluate AI tools critically — Many "AI-native" tools add cost without commensurate value
- Converge on open standards — Iceberg, OpenLineage, OpenAPI ensure you're not locked in
- The best tool is the one your team can operate — Operational complexity kills productivity