🧠 Context Engineering Quiz (2026)
Test your understanding of the hottest skill in AI Engineering
Read the full guide: Top 10 Context Engineering Interview Questions & Answers (2026)
Practice the top 100 interview questions asked at Google, Amazon, Microsoft & startups. Covers JOINs, subqueries, window functions, indexing, and more — with clear explanations.
Interactive quizzes on SQL, Spark, PySpark, Hadoop, Networking, DSA and more. Instant answers, full explanations, zero signup. Used by 100+ engineers every day.
Test your understanding of the hottest skill in AI Engineering
Read the full guide: Top 10 Context Engineering Interview Questions & Answers (2026)
Where prompt engineering focused on crafting clever one-off instructions, context engineering is about systematically designing, populating, and managing the information an LLM receives at runtime. As models now sport 128k–1M+ token context windows and power production workloads in finance, healthcare, and data platforms, knowing what to put in that window — and how — separates senior AI engineers from juniors.
Key drivers behind the 2026 boom:
Expect context engineering questions in virtually every senior ML/data/AI engineering interview in 2026. Here's what you need to know.
Prompt engineering is the craft of writing effective instructions for an LLM in a single, mostly static prompt. Context engineering is the broader discipline of dynamically constructing the entire input an LLM receives at runtime — including system prompts, retrieved documents, conversation history, tool outputs, and structured data — so the model can reason and act correctly.
In practical terms: a prompt engineer writes better sentences; a context engineer builds the pipeline that decides which sentences, data, and history enter the model's context window, in what order, and how they're formatted. Context engineering is software engineering applied to LLM inputs.
Research (Liu et al., 2023, widely cited into 2026) demonstrated that LLMs perform best when critical information is at the beginning or end of a long context. Information buried in the middle of a large context window is disproportionately ignored, causing retrieval-style tasks to degrade.
Mitigation strategies:
<context>, <question>) to help the model attend to the right regions.A production RAG context window typically follows a layered structure:
Each layer is separated by explicit delimiters and token-budgeted. The entire payload is assembled by an orchestration layer (LangChain, LlamaIndex, custom pipeline) before the API call.
Context compression is the process of reducing the number of tokens in a context window without losing the information the model needs to answer the query. Techniques include:
Use compression when your raw inputs would exceed the model's context limit, or when cost and latency are concerns — fewer tokens = lower cost and faster inference.
A context budget is a token allocation strategy that sets hard limits for each layer of the context window so the total never exceeds the model's context limit (with a safety margin). For example, with a 128k model:
You enforce the budget by counting tokens (via tiktoken for OpenAI or anthropic.count_tokens()) before assembly and truncating/compressing layers that exceed their allocation.
In single-turn RAG, context is assembled once per request — retrieve, inject, generate. In agentic workflows, context is stateful and dynamic:
Senior engineers design explicit state machines or context managers (e.g., LangGraph's state graph) that control what information persists, what is pruned, and what is summarized at each node in the agent graph.
Structured context injection means formatting injected information with explicit XML or Markdown tags that help the model parse and attend to different information types. Instead of concatenating raw text, you wrap each piece of context in semantic tags:
<system>You are a data engineering assistant. Answer only from the provided context.</system>
<retrieved_docs>
<doc id="1" source="snowflake_docs">
Snowflake's MERGE statement syntax: ...
</doc>
<doc id="2" source="dbt_docs">
dbt incremental models use ...
</doc>
</retrieved_docs>
<user_query>How do I run an incremental dbt model on Snowflake?</user_query>
This improves reliability because models trained on structured data (HTML, XML, code) are better at following schema boundaries. Claude and GPT-4o class models show measurably higher answer accuracy with tagged context versus flat concatenation in internal benchmarks.
Evaluation is the hardest part of context engineering. Production approaches in 2026:
Frameworks like RAGAS, TruLens, and Arize Phoenix are commonly used for automated eval pipelines in 2026.
Here's a production-style context assembler with per-layer token budgets:
import tiktoken
from dataclasses import dataclass
from typing import List
ENCODER = tiktoken.encoding_for_model("gpt-4o")
def count_tokens(text: str) -> int:
return len(ENCODER.encode(text))
def truncate_to_budget(text: str, budget: int) -> str:
tokens = ENCODER.encode(text)
if len(tokens) <= budget:
return text
return ENCODER.decode(tokens[:budget])
def assemble_rag_context(
system_prompt: str,
retrieved_docs: List[dict], # [{id, source, content}]
history_summary: str,
user_query: str,
budgets: dict = None
) -> str:
"""Assemble a token-budgeted context window for a RAG LLM call."""
budgets = budgets or {
"system": 800,
"docs": 40_000,
"history": 8_000,
"query": 2_000,
}
# --- Layer 1: System prompt (truncate if over budget) ---
system_block = truncate_to_budget(system_prompt, budgets["system"])
# --- Layer 2: Retrieved docs (fill budget greedily by rank) ---
doc_tokens_used = 0
doc_blocks = []
for doc in retrieved_docs:
chunk = (
f'<doc id="{doc["id"]}" source="{doc["source"]}">\n'
f'{doc["content"]}\n</doc>'
)
tokens = count_tokens(chunk)
if doc_tokens_used + tokens > budgets["docs"]:
break
doc_blocks.append(chunk)
doc_tokens_used += tokens
docs_block = f'<retrieved_docs>\n{chr(10).join(doc_blocks)}\n</retrieved_docs>'
# --- Layer 3: Conversation history ---
history_block = (
f'<history>\n{truncate_to_budget(history_summary, budgets["history"])}\n</history>'
)
# --- Layer 4: User query ---
query_block = (
f'<user_query>\n{truncate_to_budget(user_query, budgets["query"])}\n</user_query>'
)
return "\n\n".join([system_block, docs_block, history_block, query_block])
# Example usage
context = assemble_rag_context(
system_prompt="You are a Snowflake SQL expert. Answer only from provided docs.",
retrieved_docs=[
{"id": "1", "source": "snowflake_docs", "content": "MERGE INTO syntax..."},
{"id": "2", "source": "dbt_docs", "content": "Incremental models..."},
],
history_summary="User asked about dbt setup previously.",
user_query="How do I merge records in Snowflake using dbt incremental?"
)
print(f"Total tokens: {count_tokens(context)}")
For agents with many turns, storing the full history quickly blows the context budget. A common production pattern is a rolling summary buffer:
from anthropic import Anthropic
client = Anthropic()
class CompressedHistoryManager:
"""Maintains a compressed summary of conversation history."""
def __init__(self, max_recent_turns: int = 6, summary_token_limit: int = 2000):
self.summary = ""
self.recent_turns = []
self.max_recent_turns = max_recent_turns
self.summary_token_limit = summary_token_limit
def add_turn(self, role: str, content: str):
self.recent_turns.append({"role": role, "content": content})
if len(self.recent_turns) > self.max_recent_turns:
# Compress oldest turns into the rolling summary
to_compress = self.recent_turns[:-self.max_recent_turns]
self.recent_turns = self.recent_turns[-self.max_recent_turns:]
self.summary = self._compress(to_compress)
def _compress(self, turns: list) -> str:
turns_text = "\n".join(
[f'{t["role"].upper()}: {t["content"]}' for t in turns]
)
prompt = (
f"Existing summary:\n{self.summary}\n\n"
f"New turns to integrate:\n{turns_text}\n\n"
"Write a concise updated summary capturing all key decisions, "
"facts, and context. Max 300 words."
)
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=500,
messages=[{"role": "user", "content": prompt}]
)
return response.content[0].text
def get_context_block(self) -> str:
recent_text = "\n".join(
[f'{t["role"].upper()}: {t["content"]}' for t in self.recent_turns]
)
return (
f"<history_summary>\n{self.summary}\n</history_summary>\n\n"
f"<recent_turns>\n{recent_text}\n</recent_turns>"
)
# Usage
history = CompressedHistoryManager(max_recent_turns=6)
history.add_turn("user", "What is a dbt incremental model?")
history.add_turn("assistant", "A dbt incremental model processes only new rows...")
# ... many more turns ...
print(history.get_context_block())
This approach uses a cheap, fast model (Haiku) for compression and reserves the expensive frontier model for the actual reasoning task — a key cost optimization in production 2026 pipelines.
The difference between passing and failing an SQL interview isn't just knowing the syntax — it's knowing how to think, structure your answers, and avoid the traps most candidates fall into.
Before touching the keyboard, spend 60 seconds clarifying: "Do I need to handle NULLs? What's the expected output for ties? Should I include/exclude rows with no matches?"
Interviewers give credit for asking the right questions. A query that handles edge cases correctly scores higher than a "perfect" query with silent bugs. Silence is the worst approach.
NULLs trip up the majority of SQL interview candidates. Remember: any comparison with NULL returns NULL, not TRUE or FALSE.
-- WRONG — will silently exclude NULLs SELECT * FROM users WHERE discount = NULL; -- CORRECT SELECT * FROM users WHERE discount IS NULL; -- WRONG — COUNT(*) counts NULLs, COUNT(col) does NOT SELECT COUNT(discount) FROM orders; -- excludes NULL discounts SELECT COUNT(*) FROM orders; -- counts everything -- NULL-safe aggregation pattern SELECT COALESCE(SUM(discount), 0) AS total_discount FROM orders; -- Returns 0 even if all discounts are NULL
AVG(col) automatically ignores NULLs. If 3 of 10 rows have NULL revenue, AVG(revenue) divides the sum by 7, not 10. If you want a true average treating NULLs as zero: AVG(COALESCE(revenue, 0)).WHERE filters individual rows before aggregation. HAVING filters groups after aggregation.
-- Find departments with average salary > 80k (only active employees) SELECT department, AVG(salary) AS avg_salary FROM employees WHERE status = 'active' -- WHERE: filter rows first GROUP BY department HAVING AVG(salary) > 80000; -- HAVING: filter groups after aggregation -- Performance tip: always push filters to WHERE when possible -- HAVING(some_column = value) is slower than WHERE(some_column = value) -- because HAVING runs after grouping and aggregating all rows
SQL doesn't execute top-to-bottom. The actual execution order is:
1. FROM + JOINs — identify and join tables 2. WHERE — filter individual rows 3. GROUP BY — group remaining rows 4. HAVING — filter groups 5. SELECT — compute output columns 6. DISTINCT — remove duplicates 7. ORDER BY — sort the result 8. LIMIT/OFFSET — return subset
This is why you cannot reference a SELECT alias in a WHERE clause — WHERE runs before SELECT computes the alias.
SELECT salary * 1.1 AS adjusted, ... WHERE adjusted > 50000 — This FAILS. adjusted doesn't exist yet when WHERE runs. Solution: use a subquery or CTE, or repeat the expression: WHERE salary * 1.1 > 50000.Nothing impresses an interviewer more than clean, readable SQL. CTEs (Common Table Expressions) break complex logic into named, readable steps — and they also signal seniority.
-- Messy nested subquery approach (junior style)
SELECT * FROM (
SELECT customer_id, SUM(amount) AS total
FROM (SELECT * FROM orders WHERE status = 'completed') o
GROUP BY customer_id
) totals WHERE total > 1000;
-- CTE approach (senior style — clearly shows intent)
WITH completed_orders AS (
SELECT customer_id, amount
FROM orders
WHERE status = 'completed'
),
customer_totals AS (
SELECT customer_id, SUM(amount) AS total
FROM completed_orders
GROUP BY customer_id
)
SELECT customer_id, total
FROM customer_totals
WHERE total > 1000;
The golden rule: use GROUP BY when you want to collapse rows into one per group. Use Window Functions when you want to add aggregated values to each row without collapsing the result set.
-- "Show each employee and how their salary compares to their department average"
-- GROUP BY CANNOT do this cleanly — it would lose employee details
-- Window function approach:
SELECT
name, department, salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees;
For any non-trivial query, add: "In production, I'd ensure there's an index on the join keys and filter columns." This one sentence signals senior-level thinking.
-- This query will be slow on large tables without indexes SELECT o.*, c.name FROM orders o JOIN customers c ON o.customer_id = c.id -- index on customer_id and c.id WHERE o.created_at > '2026-01-01' -- index on created_at AND o.status = 'pending'; -- consider composite index -- In interviews, say: -- "I'd add a composite index on (status, created_at) for this filter -- since both columns appear in the WHERE clause together frequently."
A perennial interview question — know 3 ways to solve it.
-- Method 1: Subquery (simple, widely supported)
SELECT MAX(salary) FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
-- Method 2: LIMIT/OFFSET (clean, PostgreSQL/MySQL)
SELECT DISTINCT salary FROM employees
ORDER BY salary DESC LIMIT 1 OFFSET 1;
-- Method 3: Window Function (best for Nth highest in general)
WITH ranked AS (
SELECT salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
)
SELECT salary FROM ranked WHERE rnk = 2;
-- Change rnk = 2 to any N for the Nth highest
-- INNER JOIN — only rows matching in BOTH tables -- LEFT JOIN — all rows from left, NULLs for unmatched right -- RIGHT JOIN — all rows from right, NULLs for unmatched left -- FULL OUTER — all rows from both, NULLs on non-matching sides -- CROSS JOIN — cartesian product (every combination) -- Interviewer trap: "Find customers with NO orders" -- WRONG — this returns customers WITH orders: SELECT c.* FROM customers c JOIN orders o ON c.id = o.customer_id; -- CORRECT — LEFT JOIN + filter for NULL SELECT c.* FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.customer_id IS NULL;
After writing any query in an interview, do this: "Let me walk through what this does..." and narrate each clause in plain English.
This shows you understand your own code (not just copied a pattern), catches any bugs before the interviewer does, and demonstrates the communication skills every senior role requires.
Practice our free interactive SQL quiz — 100 real interview questions with instant colour-coded feedback and full explanations.
Take the SQL Quiz → Get the PDF BundleOne of the most common interview topics for data engineers. Know when to use each format, the trade-offs, and how to choose for any scenario.
Choosing the wrong file format can mean 10x slower queries and 5x higher storage costs. Interviewers ask this to see if you understand the fundamentals of how data is physically stored.
Parquet stores data column by column rather than row by row. When you run SELECT revenue, date FROM sales on a 500-column table, Parquet reads only those 2 column files — ignoring all other 498 columns entirely. This makes analytical queries dramatically faster.
Key features:
• Predicate pushdown — Stores min/max statistics per row group. Spark can skip entire chunks without reading them based on your WHERE clause.
• Dictionary encoding — Repeated values (like country codes, status flags) are stored as integer lookups, not repeated strings.
• Nested data support — Natively handles arrays and nested structs (Dremel encoding).
• Schema embedded in footer — File is self-describing.
# Writing Parquet in PySpark
df.write.mode('overwrite') \
.partitionBy('year', 'month') \
.parquet('s3://my-bucket/sales/')
# Reading with column pruning (only reads 2 columns from disk)
spark.read.parquet('s3://my-bucket/sales/') \
.select('revenue', 'date') \
.filter('year = 2026')
ORC (Optimized Row Columnar) is also a columnar format, similar to Parquet. It was designed specifically for Hive and offers slightly better compression ratios and faster Hive queries, but Parquet is more universally supported across tools.
ORC vs Parquet key differences:
• ORC uses ACID transactions natively — supports UPDATE and DELETE in Hive (Parquet doesn't in Hive).
• ORC stores Bloom filters by default — faster equality lookups.
• Parquet has better ecosystem support — Spark, Arrow, Pandas, BigQuery all prefer Parquet.
• ORC has better Hive performance — if your stack is Hive-heavy, ORC is the native choice.
Avro is a row-based serialization format with an embedded JSON schema. It's the dominant format for Kafka messages because it writes one record at a time efficiently and supports robust schema evolution.
Key features:
• Schema evolution — Forward and backward compatible changes (add optional fields, remove fields with defaults).
• Schema Registry — In Kafka pipelines, Avro schemas are registered in Confluent Schema Registry so all producers/consumers share the same schema.
• Compact binary encoding — No field names repeated in each record (stored once in schema), making messages small.
• Row storage — Writing one record at a time is efficient (unlike Parquet which needs to buffer column data).
# Avro schema example (JSON format)
{
"type": "record",
"name": "OrderEvent",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "amount", "type": "double"},
{"name": "status", "type": "string"},
{"name": "discount", "type": ["null", "double"], "default": null} ← optional field
]
}
| Feature | Parquet | ORC | Avro | CSV |
|---|---|---|---|---|
| Storage type | Columnar | Columnar | Row | Row |
| Best for | Analytics (OLAP) | Hive / ACID | Streaming | Interchange only |
| Compression | Excellent | Best of all | Good | None built-in |
| Schema evolution | Limited | Limited | Excellent | None |
| Splittable | ✅ | ✅ | ✅ (with sync marks) | ✅ (if uncompressed) |
| Self-describing | ✅ (footer) | ✅ (footer) | ✅ (header) | ❌ |
| Human readable | ❌ | ❌ | ❌ | ✅ |
| Kafka use | ❌ Poor | ❌ Poor | ✅ Ideal | ⚠️ Possible but inefficient |
Answer: Parquet with Snappy compression, partitioned by date.
Athena is a columnar query engine — Parquet aligns perfectly. Partition by year/month/day so Athena can skip entire partitions for date-range queries. Snappy compression for a balance of speed and size. Expected cost savings vs CSV: 70–90% reduction in bytes scanned = direct cost reduction since Athena charges per TB scanned.
Answer: Avro with Confluent Schema Registry.
Avro's forward/backward compatible schema evolution means producers can add new optional fields without breaking existing consumers. Schema Registry enforces schema compatibility rules (BACKWARD, FORWARD, FULL) centrally — preventing incompatible schema changes from reaching production.
Practice free interactive quizzes on Hadoop, Spark, SQL, Networking and DSA — and get the 300Q PDF bundle for your deepest prep.
Visit the Blog → Get the PDF BundleThe most-asked Airflow questions for data engineering roles — covering DAG design, scheduling, operators, sensors, XComs, and real-world workflow patterns.
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime
with DAG(
dag_id='daily_etl_pipeline',
start_date=datetime(2026, 1, 1),
schedule_interval='0 6 * * *', # every day at 6am
catchup=False,
tags=['production', 'etl']
) as dag:
extract = PythonOperator(task_id='extract', python_callable=extract_data)
transform = PythonOperator(task_id='transform', python_callable=transform_data)
load = PythonOperator(task_id='load', python_callable=load_data)
extract >> transform >> load # dependency chain
| Executor | Best For | Notes |
|---|---|---|
| SequentialExecutor | Local dev only | One task at a time, SQLite backend |
| LocalExecutor | Small teams | Parallel tasks on one machine via subprocesses |
| CeleryExecutor | Multi-worker production | Distributed workers via Celery + Redis/RabbitMQ |
| KubernetesExecutor | Cloud-native | Each task runs in its own pod — scales to zero |
# Operator example — does work
from airflow.operators.python import PythonOperator
run_transform = PythonOperator(task_id='transform', python_callable=run_transform_fn)
# Sensor example — waits for a condition
from airflow.sensors.s3_key_sensor import S3KeySensor
wait_for_file = S3KeySensor(
task_id='wait_for_input_file',
bucket_name='my-data-lake',
bucket_key='input/{{ ds }}/data.csv', # uses Jinja templating
aws_conn_id='aws_default',
poke_interval=300, # check every 5 minutes
timeout=3600 # fail after 1 hour
)
wait_for_file >> run_transform # only transform after file arrives
mode='reschedule' to free the worker between checks — critical for long-wait sensors in production.| Operator | Use Case |
|---|---|
PythonOperator | Run any Python function |
BashOperator | Run shell commands |
SparkSubmitOperator | Submit a Spark job to a cluster |
PostgresOperator / BigQueryOperator | Execute SQL queries |
S3ToGCSOperator | Cross-cloud data transfer |
DummyOperator | Placeholder for branching/grouping |
BranchPythonOperator | Conditional path selection |
TriggerDagRunOperator | Trigger another DAG |
@task decorator (TaskFlow API) over PythonOperator. It's cleaner, handles XComs automatically, and reduces boilerplate significantly.# TaskFlow API — XComs happen automatically
from airflow.decorators import dag, task
@dag(schedule_interval='@daily', start_date=datetime(2026,1,1), catchup=False)
def etl_pipeline():
@task
def extract():
return {"record_count": 5000, "source_file": "s3://bucket/data.csv"}
@task
def transform(extracted_data):
count = extracted_data["record_count"]
print(f"Transforming {count} records")
return count * 2
@task
def load(transformed_count):
print(f"Loading {transformed_count} records")
data = extract()
count = transform(data)
load(count)
etl_pipeline()
Limitations (critical to know for interviews):start_date and today. Useful for historical data ingestion.catchup=False for 90% of pipelines unless you specifically need backfilling.
execution_date mean in Airflow? Why is it confusing?execution_date (Airflow 1.x) / data_interval_start (Airflow 2.x) is the start of the scheduling interval, NOT the actual time the task ran.schedule_interval='@daily':2026-04-01 (execution_date = 2026-04-01 00:00:00) will actually trigger on 2026-04-02 (after the interval closes).{{ ds }} (the date string of the execution) in your SQL/file paths to reference the correct data date.
with DAG(
dag_id='monitored_pipeline',
dagrun_timeout=timedelta(hours=4), # fail entire DAG after 4 hours
default_args={
'retries': 2,
'retry_delay': timedelta(minutes=5),
'retry_exponential_backoff': True, # 5m, 10m, 20m delays
'execution_timeout': timedelta(hours=1), # per-task timeout
'on_failure_callback': alert_slack, # notify on failure
'sla': timedelta(hours=2), # SLA miss alert
}
) as dag:
...
Additional strategies:TriggerDagRunOperator.max_active_runs=1 to prevent concurrent runs from overlapping.depends_on_past=True for tasks that must not run until the previous day's run succeeded.
Practice free quizzes on SQL, Spark, PySpark, Hadoop and Networking — and get the 300Q PDF bundle for focused offline prep.
Visit the Blog → Get the PDF BundleThe Python questions you'll actually face in data engineering interviews — from core language features to real-world pipeline patterns with pandas and file processing.
[x*2 for x in range(1000000)] → allocates a million integers in RAM right now.(x*2 for x in range(1000000)) → produces values one at a time, using almost no memory.# Memory comparison
import sys
lst = [x*2 for x in range(100000)]
gen = (x*2 for x in range(100000))
print(sys.getsizeof(lst)) # ~800,056 bytes
print(sys.getsizeof(gen)) # ~104 bytes — fixed size!
# Use generator for large file processing
def read_large_file(filepath):
with open(filepath) as f:
for line in f:
yield line.strip() # processes one line at a time
Rule of thumb: If you're iterating through the result once and the dataset is large → use a generator. If you need random access or multiple passes → use a list.
@my_decorator is syntactic sugar for func = my_decorator(func).
import time
import functools
# Decorator to log execution time of pipeline steps
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} completed in {elapsed:.2f}s")
return result
return wrapper
# Retry decorator for flaky API calls
def retry(max_attempts=3):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1: raise
print(f"Attempt {attempt+1} failed: {e}. Retrying...")
return wrapper
return decorator
@timer
@retry(max_attempts=3)
def fetch_api_data(endpoint):
# your API call here
pass
*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dictionary.def pipeline_config(*sources, **options):
# sources = ('s3://bucket/a', 's3://bucket/b') ← tuple
# options = {'partition_by': 'date', 'mode': 'append'} ← dict
for source in sources:
print(f"Processing {source} with options {options}")
pipeline_config('s3://a', 's3://b', partition_by='date', mode='append')
Common DE use: Building flexible pipeline runner functions that accept variable numbers of data sources and arbitrary configuration options.
threading works well for these.multiprocessing instead (separate processes, each with their own GIL).import pandas as pd
import csv
# Method 1: pandas chunking
chunk_size = 100_000
for chunk in pd.read_csv('large_file.csv', chunksize=chunk_size):
# process each 100k-row chunk
processed = chunk[chunk['status'] == 'active']
processed.to_sql('target_table', engine, if_exists='append', index=False)
# Method 2: pure Python generator (no pandas memory overhead)
def csv_row_generator(filepath):
with open(filepath, newline='') as f:
reader = csv.DictReader(f)
for row in reader:
yield row
for row in csv_row_generator('large_file.csv'):
process_row(row)
Series.map(func) — Element-wise on a single Series. Fast for simple transformations.DataFrame.apply(func, axis=0/1) — Applies a function along an axis (rows or columns). Used for row/column-level aggregations.DataFrame.applymap(func) / DataFrame.map(func) (pandas 2.1+) — Element-wise on the entire DataFrame.# map — element-wise on one column
df['status_clean'] = df['status'].map({'Y': True, 'N': False})
# apply — custom function per row
df['full_name'] = df.apply(lambda r: f"{r['first']} {r['last']}", axis=1)
# vectorized (always prefer this over apply when possible)
df['revenue_usd'] = df['revenue_eur'] * 1.08 # 10-100x faster than apply
import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
def process_record(record):
try:
# transformation logic
return transform(record)
except KeyError as e:
logger.warning(f"Missing field {e} in record {record.get('id')}")
return None # skip bad record, don't crash the pipeline
except ValueError as e:
logger.error(f"Data type error: {e} for record {record.get('id')}")
raise # re-raise critical errors
# Dead-letter queue pattern for pipelines
good_records, bad_records = [], []
for record in incoming_records:
result = process_record(record)
if result is None:
bad_records.append(record)
else:
good_records.append(result)
# Write failed records to a dead-letter file for investigation
if bad_records:
pd.DataFrame(bad_records).to_csv('dead_letter.csv', index=False)
logger.warning(f"{len(bad_records)} records written to dead_letter.csv")
import cProfile
import pstats
from io import StringIO
# Profile the script
profiler = cProfile.Profile()
profiler.enable()
run_my_pipeline() # your function here
profiler.disable()
stream = StringIO()
stats = pstats.Stats(profiler, stream=stream).sort_stats('cumulative')
stats.print_stats(20) # top 20 slowest functions
print(stream.getvalue())
After profiling, common optimizations in order of impact:apply() with vectorized pandas/NumPy operations — 10–100x speedup.category for low-cardinality strings, int32 instead of int64 when safe.multiprocessing.Pool for CPU-bound parallel processing.== checks value equality (are the values the same?)is checks identity (are they the same object in memory?)# DANGEROUS - common bug
def is_null(val):
return val == None # Wrong! Can be overridden by custom __eq__
# CORRECT
def is_null(val):
return val is None # Checks identity — always reliable
# In pandas, this is why you use pd.isna() / pd.isnull()
# Never: df[df['col'] == None]
# Always: df[df['col'].isna()]
Take our free interactive quizzes on SQL, Spark, PySpark, Hadoop and Networking — and grab the 300Q PDF bundle for offline prep.
Visit the Blog → Get the PDF BundleContext Engineering Quiz (2026) — Test Your Knowledge 🧠 Context Engineering Quiz (2026) Test your understanding of...