A data pipeline is the code and scheduling that moves data from where it is produced (application databases, event streams, third-party APIs, documents) to where a model or a query needs it (a warehouse, a feature store, a training set, a vector index), reshaping it on the way. Interviewers probe it because the hard part is almost never the transformation itself: it is what happens when a run fails halfway, when the same run is executed twice, when yesterday's data arrives today, when an upstream team renames a column, or when the job stays green while producing wrong numbers. For a Backend or AI engineer this is the layer that decides whether a model is trained on the data you think it is, whether a RAG index reflects the current documents, and whether a metric drop is a real regression or a broken join.
The single most useful framing for an interview: a pipeline is a function from (source state, time window) to (destination state), and it must be safe to run more than once. Batch versus streaming, ETL versus ELT and the choice of orchestrator are all secondary to that property. It builds on idempotency and exactly-once semantics and pairs naturally with change data capture as a way to get changes out of a database.
A data pipeline is an automated series of steps that moves information from where it's created — an app's database, a website's click logs, a third-party service — to where someone or something needs to use it, such as a report, a dashboard or a model being trained. Instead of a person manually copying and cleaning data, the pipeline runs on a schedule (or continuously) and handles the plumbing so the data shows up in the right place, in the right shape, automatically.
ETL stands for Extract, Transform, Load: pull the raw data out of its source (Extract), clean it up and reshape it into a more useful form (Transform), and then save it into its destination, such as a database used for analysis (Load). It's simply the three-step recipe most data pipelines follow, describing the order in which the work happens rather than a specific tool.
Think of a post office's mail-sorting process. Letters arrive from all over (extract), get sorted by zip code and routed to the right truck (transform), and are delivered to the correct mailbox (load). If a truck breaks down, the mail doesn't vanish — it's rerouted and still arrives, just like a well-built data pipeline recovers from a failure instead of losing or duplicating data along the way.
Batch Streaming (micro-batch or per-event)
Trigger schedule (hourly, nightly) each event / small time window
Latency minutes to a day milliseconds to seconds
Correctness work re-run the whole window order, duplicates, state, late data
Failure recovery re-run the failed partition restore from checkpoint + replay offsets
Cost shape pay while it runs always-on workers and state
Typical ML use training sets, nightly real-time features, fresh RAG index,
embeddings, backfills fraud / abuse signals
ETL: source --extract--> [transform on a separate engine] --load--> warehouse
(only cleaned, final-shape data lands; raw is often discarded)
ELT: source --extract--> load raw --> lake / warehouse --transform in place (SQL)--> curated tables
(raw stays available; transformations can be re-run and changed later)
Batch is simpler because a run has a clear start, end and input: if it fails, you re-run it. Streaming turns every one of those into an ongoing problem: events arrive out of order, are delivered at least once (so duplicates must be handled), and per-key state has to survive restarts. The honest rule is to pick the slowest freshness that still meets the requirement. A recommendation model retrained nightly does not need a streaming pipeline; a fraud feature such as "transactions in the last 5 minutes" does. Many systems are hybrid: a batch path that is authoritative and re-computable, plus a streaming path that provides freshness and is reconciled against the batch result.
ETL versus ELT is about where and when the transform runs. ETL transforms before loading, which keeps the destination clean and can reduce what you store, but it couples you to the transform engine and throws away raw data, so a bug in the transform cannot be fixed by re-running over history. ELT lands raw data first (in an object store or warehouse, see object storage and data lakes) and transforms inside the destination with SQL; storage is cheap and the destination engine scales, so the raw layer becomes a replayable source of truth. The costs of ELT are governance (raw data may contain PII that now lives in more places) and compute bills in the warehouse. For ML, keeping immutable raw data is usually decisive, because a new feature definition can then be backfilled over history.
extract_orders --+
+--> validate --> build_features --> load_offline --> publish_online
extract_events --+ |
+--> embed_changed_docs (independent branch)
Each node = a task with: inputs, outputs, retry policy, timeout, and a partition (e.g. date) parameter.
An orchestrator (Airflow, Dagster, Prefect, and similar) runs a task once its upstream tasks succeed.
# Airflow-style pseudocode, not executed here:
@task(retries=3, retry_delay=timedelta(minutes=5))
def load_partition(ds): # ds = the partition being processed (e.g. "2026-03-01"), NOT now()
delete_or_upsert(partition=ds) # must be safe to run again for the same ds
A DAG (directed acyclic graph) expresses dependencies so the scheduler can run independent branches in parallel and stop downstream work when something upstream failed. Three properties make it operable. Retries handle transient failures (a timeout, a throttled API), and they are only safe when the task is idempotent: a retry after a partial write of a plain INSERT duplicates rows. Partition parameters make a run reproducible: a task should process "the 2026-03-01 partition", derived from the run's logical date, not "whatever is in the source right now", otherwise re-running yesterday's run produces a different result. In Airflow, for example, a run is generally triggered after its data interval has ended and is identified by that interval; check your orchestrator's exact semantics. Backfills re-run the same DAG for past partitions, either because you fixed a bug or added a feature; they are just "run the DAG for many dates", which is why idempotent, partition-scoped tasks matter.
Where this breaks at scale: a backfill of 400 daily partitions can overwhelm the source database or the embedding API, so backfills need concurrency limits and rate limits; retries with no backoff amplify an outage; catch-up settings can unexpectedly launch hundreds of runs after a pause; and a task that reads "latest" state (a mutable dimension table, now()) makes old partitions non-reproducible. Prefer immutable inputs (snapshots, append-only raw data) and make output writes atomic per partition (write to a staging location, then swap or upsert).
Source rows by updated_at: 100 110 120 130 140 | 150 160 (135 arrives LATE, after 140 was seen)
^
high watermark = 140 after run 1
strict watermark (updated_at > 140): reads 150,160 --> the late row 135 is never read
with lookback 30 (updated_at > 110): reads 120,130,135,... --> re-reads old rows, catches 135
Only safe because the write is an UPSERT keyed on id: re-reading a row rewrites the same row.
Load pattern comparison
append (INSERT) : re-run = duplicates not idempotent
overwrite partition : re-run = same partition idempotent (simple, coarse)
upsert / MERGE by key : re-run = same rows idempotent (fine-grained, needs a stable key)
Reloading everything each night is simple and correct but scales with total data, not with change. An incremental load stores a high watermark (the largest updated_at or offset processed) and next time reads only newer rows. It is fragile in specific ways. A row's updated_at is its time of change, not the time it became visible: a long transaction can commit after you have already advanced past its timestamp, so it is silently skipped. Clocks on different writers drift. Ties on the boundary need >= plus deduplication. And a hard DELETE at the source never shows up in "rows updated since". The standard defences are a lookback window (re-read the last N minutes or hours and rely on the idempotent upsert), soft deletes or tombstones, periodic full reconciliation (count and checksum comparison), and log-based change data capture, which reads the database's commit log instead of guessing from timestamps. Also advance the watermark in the same transaction as the data write, or a crash between the two either loses data or reprocesses it.
In streaming, "late" is defined by event time (when the thing happened) versus processing time (when you saw it). A watermark is the system's estimate "no more events older than T will arrive"; a window is emitted when the watermark passes its end. The tension is direct: a small allowed lateness gives low latency but drops or misroutes late events, a large one gives completeness but delays results and holds more state. Real engines (Flink, Spark Structured Streaming and others) implement this with their own APIs; the toy below shows just the mechanism, using 10-second tumbling windows and a watermark of max event time seen minus allowed lateness.
# Event-time tumbling windows of 10s, watermark = max_event_time_seen - allowed_lateness
def run(allowed_lateness):
events = [(1, "a"), (4, "b"), (12, "c"), (9, "d"), # 9 arrives after 12: out of order, still on time
(15, "e"), (23, "f"), (8, "g"), # 8 arrives after 23: very late
(31, "h")]
counts, closed, dropped, max_t = {}, set(), [], -1
for t, name in events:
max_t = max(max_t, t)
wm = max_t - allowed_lateness
w = t // 10 * 10
if w in closed:
dropped.append((t, name)) # window already emitted: too late
else:
counts[w] = counts.get(w, 0) + 1
for ws in sorted(counts): # close every window whose end <= watermark
if ws + 10 <= wm and ws not in closed:
closed.add(ws); print(f" emit window [{ws},{ws+10}) count={counts[ws]} (watermark={wm})")
print(f" allowed_lateness={allowed_lateness}: dropped late events {dropped}")
run(0); print(); run(6)
# Output:
# emit window [0,10) count=2 (watermark=12)
# emit window [10,20) count=2 (watermark=23)
# emit window [20,30) count=1 (watermark=31)
# allowed_lateness=0: dropped late events [(9, 'd'), (8, 'g')]
#
# emit window [0,10) count=3 (watermark=17)
# emit window [10,20) count=2 (watermark=25)
# allowed_lateness=6: dropped late events [(8, 'g')]
With zero allowed lateness both out-of-order events (9 and 8) are dropped; with 6 seconds of lateness event 9 is counted but 8, which arrived far too late, is still dropped, and the first window is emitted later. What you do with dropped events (a side output, a correction to the already-emitted result, or a batch reconciliation job) is a product decision, not a technical detail. The last window is never emitted in this toy because no later event advances the watermark, which is also true in real systems with idle sources.
Check layer Example On failure
schema / type column "amount" is numeric fail the run (contract broken)
null / range user_id NOT NULL, 0 <= amount < 1e6 quarantine the row, count it
uniqueness / keys id unique per partition fail or dedupe (decide explicitly)
referential every user_id exists in dim_users quarantine, alert if rate rises
volume row count within a band of last 7 days alert (zero rows is not "success")
freshness max(event_time) within 2h of now alert, block downstream publish
distribution mean/quantiles vs training baseline alert (see drift monitoring)
Schema change Safe for consumers?
add optional column yes (readers that ignore it are fine)
widen int -> bigint usually (depends on format/reader)
rename column NO: looks like "drop + add"; old readers get nulls or fail
change unit / meaning NO and silent: the type is unchanged, the numbers are wrong
A data quality check is an assertion about data, evaluated in the pipeline, with an explicit policy for failure: fail the run for contract violations that make everything untrustworthy, quarantine individual bad rows to a side table so the rest proceeds, or warn for soft thresholds. The important design point is the volume and freshness checks: a job that reads zero rows because the source was empty or the filter matched nothing usually "succeeds", and the model then trains on a truncated dataset. A data contract is the producer-side commitment (schema, semantics, units, delivery time, ownership) that lets consumers depend on data without reverse-engineering it; in practice it is a versioned schema (Avro, Protobuf, or JSON Schema, often enforced by a schema registry) plus checks that run in the producer's CI and the consumer's pipeline. Additive, optional changes are compatible; renames, removals and semantic changes are breaking and need a versioned rollout with both shapes supported for a period.
What breaks silently is the interview-worthy list: a timezone or unit change upstream (the type is the same, the values are not); a join that fans out and duplicates rows (revenue doubles); default-filling nulls with zero so a missing feature looks like a real value; a partial load that ends "successfully" after an early exit; late data dropped by a watermark with no counter; stale upstream tables that still exist and still join; and training features computed with a different code path than serving (see feature stores). Defences are cheap checks at stage boundaries, row-count reconciliation between stages, dead-letter counters that alert on rate, lineage so you can find what a bad table fed (data and model versioning and lineage), and monitoring of the resulting feature distributions (model monitoring and drift detection).
source docs --> parse --> clean --> chunk --> content hash --> changed? --yes--> embed --> upsert vectors
| (key = doc_id#chunk_no)
+--no--> skip (no embedding call)
chunks that disappeared --> DELETE from the index
metadata carried with every chunk: doc_id, version, ACL/tenant, source timestamp, chunker versionA RAG ingestion pipeline is an ordinary ETL job whose expensive step is the embedding call and whose destination is a vector index (see chunking and embedding strategies, vector databases and RAG). The same principles apply and the stakes are cost and correctness: re-embedding the whole corpus on every run wastes money and hits rate limits, so key each chunk deterministically and skip chunks whose content hash is unchanged; propagate deletions and access-control changes, or the retriever will keep serving removed or forbidden content; and treat the embedding model and chunker version as part of the data version. Vectors produced by different embedding models are not comparable, so changing the model means building a new index and switching over, not updating in place. Changing the chunker invalidates every hash, so it is a full re-ingest by design. The toy below shows the skip-unchanged and delete-stale logic with a hash standing in for "already embedded".
import hashlib
def h(text): return hashlib.sha256(text.encode()).hexdigest()[:8]
def chunk(doc): # naive paragraph chunking
return [p.strip() for p in doc.split("\n\n") if p.strip()]
index = {} # chunk_id -> content hash (stands in for "already embedded")
embed_calls = 0
def ingest(doc_id, doc):
global embed_calls
new_ids, embedded = set(), 0
for i, c in enumerate(chunk(doc)):
cid, hc = f"{doc_id}#{i}", h(c)
new_ids.add(cid)
if index.get(cid) != hc: # only pay for embeddings when content changed
index[cid] = hc; embed_calls += 1; embedded += 1
stale = [k for k in index if k.startswith(doc_id + "#") and k not in new_ids]
for k in stale: del index[k] # deletes must propagate or the retriever serves ghosts
print(f"{doc_id}: chunks={len(new_ids)} embedded={embedded} deleted={len(stale)} total_embed_calls={embed_calls}")
v1 = "Refunds take 5 days.\n\nShipping is free over $50.\n\nContact support by email."
ingest("faq", v1)
ingest("faq", v1) # rerun: nothing changes
ingest("faq", v1.replace("5 days", "7 days")) # one paragraph edited
ingest("faq", "Refunds take 7 days.\n\nShipping is free over $50.") # last paragraph removed
# Output:
# faq: chunks=3 embedded=3 deleted=0 total_embed_calls=3
# faq: chunks=3 embedded=0 deleted=0 total_embed_calls=3
# faq: chunks=3 embedded=1 deleted=0 total_embed_calls=4
# faq: chunks=2 embedded=0 deleted=1 total_embed_calls=4
The second run makes zero embedding calls, editing one paragraph makes one, and removing a paragraph triggers a delete but no embedding. Note the hazard the toy hides: paragraph chunk ids by position (#0, #1) shift when a paragraph is inserted near the top, so real systems often key on a content hash or a stable section id instead.
now()) so backfills reproduce results; limit backfill concurrency to protect sources and APIs.A small warehouse load in sqlite3: an incremental load with a watermark and lookback, an upsert so re-runs and backfills do not duplicate, a data-quality gate that quarantines bad rows, and, for contrast, a naive append table that receives the same rows. updated_at is an integer minute to keep the demo readable, and the stdlib only is required.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE src_events (id INTEGER PRIMARY KEY, user_id TEXT, amount REAL, updated_at INTEGER);
CREATE TABLE dw_events (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, amount REAL NOT NULL,
updated_at INTEGER NOT NULL);
CREATE TABLE dw_naive (id INTEGER, user_id TEXT, amount REAL, updated_at INTEGER); -- plain append
CREATE TABLE quarantine (id INTEGER PRIMARY KEY, reason TEXT);
CREATE TABLE etl_state (pipeline TEXT PRIMARY KEY, watermark INTEGER);
""")
def check(row):
"""Data-quality gate: return a reason string if the row is bad, else None."""
_id, user, amount, ts = row
if user is None: return "user_id is NULL"
if amount is None or amount < 0: return f"amount out of range ({amount})"
return None
def get_wm():
r = db.execute("SELECT watermark FROM etl_state WHERE pipeline='events'").fetchone()
return r[0] if r else -1
def run_load(label, lookback=0, since=None):
wm = get_wm() if since is None else since
rows = db.execute("SELECT id,user_id,amount,updated_at FROM src_events "
"WHERE updated_at > ? ORDER BY updated_at", (wm - lookback,)).fetchall()
good, bad = [], []
for r in rows:
why = check(r)
(bad.append((r[0], why)) if why else good.append(r))
with db: # one transaction: data + watermark move together
db.executemany( # idempotent upsert; older version never overwrites newer
"INSERT INTO dw_events VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET "
"user_id=excluded.user_id, amount=excluded.amount, updated_at=excluded.updated_at "
"WHERE excluded.updated_at >= dw_events.updated_at", good)
db.executemany("INSERT OR REPLACE INTO quarantine VALUES (?,?)", bad)
db.executemany("INSERT INTO dw_naive VALUES (?,?,?,?)", good) # the non-idempotent version
if rows:
db.execute("INSERT OR REPLACE INTO etl_state VALUES ('events', ?)",
(max(max(r[3] for r in rows), wm),))
n = lambda t: db.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0]
print(f"{label:<26} read={len(rows)} good={len(good)} bad={len(bad)} | "
f"dw={n('dw_events')} naive={n('dw_naive')} quarantine={n('quarantine')} watermark={get_wm()}")
db.executemany("INSERT INTO src_events VALUES (?,?,?,?)", [
(1, "u1", 10.0, 100), (2, "u2", 20.0, 110), (3, "u1", 30.0, 120),
(4, "u3", -5.0, 130), # bad: negative amount
(5, None, 15.0, 140)]) # bad: missing user
run_load("run 1 (initial)")
run_load("run 2 (nothing new)")
# New data arrives: a new row, an update to id 2, and a LATE row (event time 135 < watermark 140)
db.executemany("INSERT INTO src_events VALUES (?,?,?,?)", [(6, "u4", 60.0, 150)])
db.execute("UPDATE src_events SET amount=25.0, updated_at=160 WHERE id=2")
db.execute("INSERT INTO src_events VALUES (7, 'u5', 70.0, 135)")
run_load("run 3 (strict watermark)")
print(" late row 7 in warehouse? ", bool(db.execute("SELECT 1 FROM dw_events WHERE id=7").fetchone()))
run_load("run 4 (lookback=30)", lookback=30)
print(" late row 7 in warehouse? ", bool(db.execute("SELECT 1 FROM dw_events WHERE id=7").fetchone()))
run_load("backfill 1 (since=-1)", since=-1)
run_load("backfill 2 (same again)", since=-1)
print("dw_events :", db.execute("SELECT * FROM dw_events ORDER BY id").fetchall())
print("quarantine:", db.execute("SELECT * FROM quarantine ORDER BY id").fetchall())
# Output:
# run 1 (initial) read=5 good=3 bad=2 | dw=3 naive=3 quarantine=2 watermark=140
# run 2 (nothing new) read=0 good=0 bad=0 | dw=3 naive=3 quarantine=2 watermark=140
# run 3 (strict watermark) read=2 good=2 bad=0 | dw=4 naive=5 quarantine=2 watermark=160
# late row 7 in warehouse? False
# run 4 (lookback=30) read=4 good=3 bad=1 | dw=5 naive=8 quarantine=2 watermark=160
# late row 7 in warehouse? True
# backfill 1 (since=-1) read=7 good=5 bad=2 | dw=5 naive=13 quarantine=2 watermark=160
# backfill 2 (same again) read=7 good=5 bad=2 | dw=5 naive=18 quarantine=2 watermark=160
# dw_events : [(1, 'u1', 10.0, 100), (2, 'u2', 25.0, 160), (3, 'u1', 30.0, 120), (6, 'u4', 60.0, 150), (7, 'u5', 70.0, 135)]
# quarantine: [(4, 'amount out of range (-5.0)'), (5, 'user_id is NULL')]
Read the output line by line. Run 2 finds nothing new. Run 3 uses a strict watermark: the update to id 2 and the new id 6 arrive, but the late row 7 (timestamp 135, older than watermark 140) is silently missed, which is exactly the failure mode that looks like success. Run 4 with a 30-minute lookback re-reads a few rows, the upsert absorbs the re-reads without creating duplicates, and row 7 lands. The two backfills read all 7 source rows twice; the warehouse stays at 5 rows while the naive append table balloons to 18. The quarantine table holds the two bad rows with reasons instead of them disappearing or crashing the run. What the toy omits: real concurrency, a source that is being written while you read, and schema drift.
Q: ETL versus ELT: when would you choose each?
A: ETL transforms before loading, which suits strict cleaning or PII removal before data lands and small, well-defined destinations. ELT loads raw data first and transforms inside the warehouse or lake, which suits analytics and ML because the raw layer is a replayable source of truth and transformations can be changed and re-run. The trade-off is governance and compute cost in ELT, and inflexibility plus lost history in ETL.
Q: What does it mean for a pipeline to be idempotent, and how do you achieve it?
A: Running it twice for the same input produces the same destination state as running it once. Achieve it with partition overwrite (replace the whole partition atomically) or an upsert/MERGE on a stable business key, deterministic transforms, and inputs identified by partition rather than by "now". Blind appends are the classic violation: one retry after a partial write and you double count.
Q: How do you do incremental loads reliably from a database table?
A: Store a high watermark and read rows with a larger updated_at, but assume it is unreliable: long transactions commit late, clocks skew, and deletes are invisible. So add a lookback window with an idempotent upsert, use soft deletes or log-based CDC, and reconcile counts or checksums periodically. Update the watermark in the same transaction as the load.
Q: When would you choose streaming over batch for an ML pipeline?
A: When the value of the data decays faster than the batch interval: fraud signals, session-based recommendations, a support-bot index that must reflect edited policy pages within minutes. I would first ask what latency the business needs, because streaming brings ordering, deduplication, state management and late-data handling. If hourly is acceptable, a micro-batch or frequent batch is far cheaper to operate.
Q: What is a watermark in a streaming system and what does it cost you?
A: It is the engine's estimate that no events older than a given event time will still arrive, used to decide when a time window can be emitted. A tight watermark reduces latency and state but drops or diverts more late events; a loose one improves completeness but delays output and holds more state. You size it from the observed delay distribution and add a path (side output or batch correction) for the tail.
Q: How do you backfill safely?
A: Re-run the same partition-parameterised, idempotent job over the date range, with limited concurrency and rate limits so the source and downstream APIs are not overwhelmed. Write to staging and swap or upsert so consumers never see half-loaded partitions, and verify with row counts and checksums against the source. Beware jobs that read mutable "latest" tables, since old partitions then cannot be reproduced.
Q: How do you keep a RAG index in sync with changing documents without re-embedding everything?
A: Give each chunk a deterministic key, store a content hash, and only embed chunks whose hash changed; delete chunks that no longer exist and propagate ACL changes. Illustratively, if 1% of a 50M-chunk corpus changes per day, that is about 500k embedding calls instead of 50M, a 100x reduction. Version the embedding model and chunker, since changing either requires a full rebuild into a new index.
Follow-up: Your pipeline is green every day but model quality has been dropping for a week. How do you investigate?
A: Green only means the code ran, not that the data is right. Check volume and freshness metrics per stage, compare feature distributions against the training baseline, look for a source schema or unit change, and inspect quarantine and dropped-late-event counters. Then use lineage to find which upstream table changed, and add the missing check so it fails loudly next time.
Follow-up: Can a streaming pipeline give exactly-once results?
A: Not as delivery: brokers and workers generally give at-least-once, and failures cause redelivery. What you can get is effectively-once results by combining at-least-once delivery with idempotent or transactional sinks (upserts by key, or a sink that commits offsets and output atomically, where the framework supports it). The guarantee ends at systems that cannot participate, such as an arbitrary external API call, which needs its own idempotency key.
Follow-up: The source table has no reliable updated_at and no CDC. What are your options?
A: Fall back to a full snapshot compared with the previous one (hash per row or per key range) and load only differences, or partition-level overwrites of the recent window plus a periodic full refresh. Snapshot diffs cost source read load proportional to table size, so I would push the owner to add an audit column or enable CDC, and until then schedule the snapshot in off-peak hours against a replica.
In a typical ML platform design, the pipeline layer sits between operational systems and everything that learns or retrieves: CDC or event streams (see message queues and event streaming) land raw data in a lake, batch jobs build training sets and offline features, streaming jobs keep online features and search indexes fresh, and an orchestrator ties them together with quality gates. Estimation an interviewer wants to hear, with stated assumptions (illustrative): 1M events/day at about 1 KB is roughly 1 GB/day raw, trivially handled by a single batch job, so streaming is justified by latency requirements, not by volume; 50M chunks at 768 dimensions in float32 is about 50M x 768 x 4 bytes, roughly 150 GB of vectors before index overhead, which is why incremental embedding and index versioning matter; and a full re-embed at even a modest rate limit can take many hours, so it must be a planned, throttled backfill into a new index with a cutover. Mention the operational contract too: SLOs for freshness and completeness, alerting on volume and lateness, and lineage from a served prediction back to the data that trained it.
The core decision is how much correctness machinery to pay for. A nightly full-refresh batch job is the cheapest to reason about and is the right default; incremental loads, streaming, watermarks and CDC each add moving parts (state, lookback windows, ordering, more failure modes) and should be adopted only when data volume or freshness forces them. Idempotency and quality gates cost engineering time and some extra compute (re-reading lookback windows, keyed upserts are slower than appends), but they are what make retries and backfills routine instead of dangerous. Strict quality gates that fail the run protect models but can block delivery on a minor issue, while lenient ones let bad data through, so set the failure policy per check. Pipelines are the wrong place to hide business logic that must match the serving path; if training and serving compute the same feature in two code paths, you have built a skew generator, and a shared definition (or a feature store) is worth its overhead. Finally, streaming is the wrong choice when the requirement is really "fresher than daily": frequent micro-batches often deliver it at a fraction of the operating cost.